From b03a7615b1045f234cd5a5a8811f207251de2aad Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Thu, 6 Aug 2026 09:45:20 +0300 Subject: [PATCH] =?UTF-8?q?release:=20v1.5.0=20=E2=80=94=20discovery=20lay?= =?UTF-8?q?er,=20honesty=20debt=20closed,=20Wave=20A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tool count 131 -> 154; 474 -> 1242 collected tests. The release exists for two measured reasons. A 149-tool catalog costs a client ~40,300 tokens before it has asked for anything, and a drafter searching the way drafters think -- FILLET, BPOLY, QSELECT, WBLOCK -- scored df=0 against the stock index, because those words were simply not in the data. Discovery and batching - DISCOVERY_MODE=search replaces the catalog with search_tools + call_tool, ranked over an AutoCAD command and synonym corpus (discovery/aliases.py) on top of fastmcp's BM25 index. Measured: 40,305 -> 356 tokens advertised. - cad_batch runs a step list in one round trip; fields= projects result-heavy responses. The `core` profile is gone -- discovery replaced it. Honesty debt (Tier 0) - OCS/ECS: `extrusion` appeared nowhere in this codebase, so mirrored and rotated geometry reported coordinates that were quietly wrong. backends/ocs.py translates at the backend boundary; every tool coordinate is WCS on both engines, and a tilted plane refuses rather than answering with a number that looks fine. - drawing_save_as(fmt="dwg") wrote DXF bytes to a .dwg path. drawing_audit never repaired. analysis_measure_area shoelaced points the caller typed in. - ezdxf calls gained a per-call timeout, so one hung call can no longer wedge a server whose document lock is a single asyncio.Lock. Architecture - The 1854-line backend ABC is 19 contract modules; @capability(key, reason=) makes optional methods declarative and a gate fails on undeclared holes. - 8 layout/viewport tools, CHSPACE, CAD_PROGID escape hatch. - Wave A: selection filters, boundary tracing, hatch depth, annotation objects, measurement -- 13 tools of a planned 39. The REGION and 2D-boolean family was cut on evidence: add_region() yields zero ACIS bytes and the greiner_hormann substitute loses 28.2% of the area on a square with one semicircular edge. Verified against a live AutoCAD 2026, which found four COM defects no amount of reading would have caught -- AcadPViewport has no ViewCenter, and the line that set it had been swallowed by a bare except since v1.4. Benchmark v3 (15 tasks) and what it caught - v2 scored this server 10/10, which carried no information: every task in it exercised something the server was built around. Five tasks were added because they can fail, and three did while being written -- a hatch could not be measured though the refusal listed HATCH as measurable, the boundary tools and the measure tool disagreed about the same shape (139.2177 vs 139.2699), and a circle stored as two bulged vertices measured 0.0. - Competitor reports are NOT re-scored. They are pinned v1.4 runs against v2 and were never asked these five questions; the chart shows those rows `not run`. - Correctness A/B: 21/24 -> 24/24, zero regressions. Performance, stated plainly The published v1.4 perf report was recorded on CPython 3.14 and the new one on 3.11.15, so the apparent 2-6x speedup is the interpreter, not this release. Re-measured on one interpreter, v1.5.0 is 28% slower creating entities and 22% slower on the 10k roundtrip. Fully attributed: EZDXF_CALL_TIMEOUT=0 restores v1.4.0's exact numbers. That is the deadlock fix being paid for, the knob is documented, and winning it back is on the 1.6 roadmap. Co-Authored-By: Claude Opus 5 --- .env.example | 56 +- .github/workflows/benchmark.yml | 16 +- .github/workflows/ci.yml | 56 +- .github/workflows/deps-floating.yml | 47 + .github/workflows/release.yml | 21 +- .gitignore | 6 +- CHANGELOG.md | 731 ++++- CLAUDE.md | 24 +- Dockerfile | 1 + README.md | 257 +- backends/base.py | 1818 +---------- backends/capability.py | 134 + backends/com_backend.py | 1202 +++++++- backends/contracts/__init__.py | 51 + backends/contracts/analysis.py | 164 + backends/contracts/blocks.py | 50 + backends/contracts/corner_ops.py | 66 + backends/contracts/dimensions.py | 89 + backends/contracts/drawing.py | 40 + backends/contracts/entity_creation.py | 255 ++ backends/contracts/entity_modification.py | 125 + backends/contracts/entity_query.py | 158 + backends/contracts/gdt.py | 157 + backends/contracts/identity.py | 33 + backends/contracts/layers.py | 61 + backends/contracts/layouts.py | 170 ++ backends/contracts/linetypes.py | 22 + backends/contracts/premium.py | 763 +++++ backends/contracts/settings.py | 139 + backends/contracts/solids.py | 79 + backends/contracts/system.py | 28 + backends/contracts/transactions.py | 21 + backends/contracts/view.py | 43 + backends/ezdxf_backend.py | 2705 +++++++++++++++- backends/ocs.py | 100 + benchmarks/README.md | 155 +- benchmarks/adapters/autocad_mcp_pro.py | 230 +- benchmarks/correctness_suite.py | 49 + benchmarks/render_matrix_chart.py | 13 +- .../published/ab-v1.4.0-vs-v1.5.0.json | 158 + .../results/published/autocad-mcp-pro.json | 214 +- benchmarks/results/published/perf-ezdxf.json | 24 +- benchmarks/run_competitors.py | 64 +- benchmarks/tasks_v3.py | 84 + benchmarks/token_suite.py | 1117 +++++++ config.py | 74 +- discovery/__init__.py | 7 + discovery/aliases.py | 1342 ++++++++ discovery/serialize.py | 114 + discovery/transform.py | 402 +++ docs/assets/autocad-mcp-livebench.svg | 1250 ++++---- docs/assets/autocad-mcp-perf.svg | 2185 +++++++------ docs/assets/autocad-mcp-taskmatrix.svg | 2086 ++++++++----- ...-17-repository-cleanup-readme-benchmark.md | 5 +- docs/tool-inventory.json | 2347 ++++++++++++++ engineering/fits.py | 242 +- engineering/measure.py | 149 + engineering/preflight.py | 15 +- engineering/titleblock.py | 43 +- pyproject.toml | 67 +- scripts/generate_tool_inventory.py | 258 ++ security.py | 206 +- server.json | 13 +- server.py | 2707 ++++++++++++++++- tests/conftest.py | 27 + tests/data/__init__.py | 1 + tests/data/discovery_golden.py | 179 ++ tests/test_annotation_objects.py | 220 ++ tests/test_backend_selection.py | 101 + tests/test_benchmark_v3.py | 169 + tests/test_block_from_entities.py | 168 + tests/test_boundary_trace.py | 447 +++ tests/test_cad_batch.py | 935 ++++++ tests/test_cad_progid.py | 153 + tests/test_capabilities.py | 9 +- tests/test_capability_contract.py | 118 + tests/test_capability_refusal.py | 158 + tests/test_change_space.py | 319 ++ tests/test_com_timeout.py | 132 + tests/test_discovery_aliases.py | 210 ++ tests/test_discovery_transform.py | 668 ++++ tests/test_drawing.py | 29 +- tests/test_entity_measure.py | 267 ++ tests/test_entity_properties.py | 325 ++ tests/test_ezdxf_honesty.py | 371 +++ tests/test_fits.py | 213 +- tests/test_hatch_depth.py | 364 +++ tests/test_layout_lifecycle.py | 392 +++ tests/test_layouts_solids.py | 34 +- tests/test_measure.py | 200 ++ tests/test_ocs_extrusion.py | 274 ++ tests/test_paper_space.py | 142 + tests/test_preflight.py | 44 + tests/test_refiner.py | 10 +- tests/test_release_consistency.py | 90 +- tests/test_result_projection.py | 477 +++ tests/test_screenshot_overlay.py | 94 + tests/test_security.py | 118 +- tests/test_selection_filters.py | 546 ++++ tests/test_sprint4_ezdxf.py | 9 +- tests/test_token_suite.py | 306 ++ tests/test_tool_inventory.py | 232 ++ tests/test_tool_profiles.py | 86 +- tests/test_tool_registry.py | 530 ++++ tests/test_undo_redo.py | 173 ++ tests/test_viewport_ops.py | 393 +++ uv.lock | 2409 +++++++++++++++ 107 files changed, 32640 insertions(+), 4810 deletions(-) create mode 100644 .github/workflows/deps-floating.yml create mode 100644 backends/capability.py create mode 100644 backends/contracts/__init__.py create mode 100644 backends/contracts/analysis.py create mode 100644 backends/contracts/blocks.py create mode 100644 backends/contracts/corner_ops.py create mode 100644 backends/contracts/dimensions.py create mode 100644 backends/contracts/drawing.py create mode 100644 backends/contracts/entity_creation.py create mode 100644 backends/contracts/entity_modification.py create mode 100644 backends/contracts/entity_query.py create mode 100644 backends/contracts/gdt.py create mode 100644 backends/contracts/identity.py create mode 100644 backends/contracts/layers.py create mode 100644 backends/contracts/layouts.py create mode 100644 backends/contracts/linetypes.py create mode 100644 backends/contracts/premium.py create mode 100644 backends/contracts/settings.py create mode 100644 backends/contracts/solids.py create mode 100644 backends/contracts/system.py create mode 100644 backends/contracts/transactions.py create mode 100644 backends/contracts/view.py create mode 100644 backends/ocs.py create mode 100644 benchmarks/results/published/ab-v1.4.0-vs-v1.5.0.json create mode 100644 benchmarks/tasks_v3.py create mode 100644 benchmarks/token_suite.py create mode 100644 discovery/__init__.py create mode 100644 discovery/aliases.py create mode 100644 discovery/serialize.py create mode 100644 discovery/transform.py create mode 100644 docs/tool-inventory.json create mode 100644 engineering/measure.py create mode 100644 scripts/generate_tool_inventory.py create mode 100644 tests/data/__init__.py create mode 100644 tests/data/discovery_golden.py create mode 100644 tests/test_annotation_objects.py create mode 100644 tests/test_backend_selection.py create mode 100644 tests/test_benchmark_v3.py create mode 100644 tests/test_block_from_entities.py create mode 100644 tests/test_boundary_trace.py create mode 100644 tests/test_cad_batch.py create mode 100644 tests/test_cad_progid.py create mode 100644 tests/test_capability_contract.py create mode 100644 tests/test_capability_refusal.py create mode 100644 tests/test_change_space.py create mode 100644 tests/test_com_timeout.py create mode 100644 tests/test_discovery_aliases.py create mode 100644 tests/test_discovery_transform.py create mode 100644 tests/test_entity_measure.py create mode 100644 tests/test_entity_properties.py create mode 100644 tests/test_ezdxf_honesty.py create mode 100644 tests/test_hatch_depth.py create mode 100644 tests/test_layout_lifecycle.py create mode 100644 tests/test_measure.py create mode 100644 tests/test_ocs_extrusion.py create mode 100644 tests/test_paper_space.py create mode 100644 tests/test_result_projection.py create mode 100644 tests/test_screenshot_overlay.py create mode 100644 tests/test_selection_filters.py create mode 100644 tests/test_token_suite.py create mode 100644 tests/test_tool_inventory.py create mode 100644 tests/test_tool_registry.py create mode 100644 tests/test_undo_redo.py create mode 100644 tests/test_viewport_ops.py create mode 100644 uv.lock diff --git a/.env.example b/.env.example index 387fe61..c8c3d4b 100644 --- a/.env.example +++ b/.env.example @@ -1,8 +1,37 @@ # AutoCAD MCP Pro — Environment Variables +# Rendering (view_screenshot / drawing_export_pdf) is an install-time choice, +# not an env var — the core install cannot render at all. ezdxf's drawing +# add-on imports Pillow unconditionally, so every render path needs it: +# pip install "autocad-mcp-pro[pdf]" ezdxf backend, matplotlib renderer +# (also pulls Pillow in transitively) +# pip install "autocad-mcp-pro[com]" live AutoCAD Win32 window capture +# pip install "autocad-mcp-pro[full]" both +# With only [com] the ezdxf backend still cannot render (no matplotlib). + # Backend selection: auto | com | ezdxf AUTOCAD_MCP_BACKEND=auto +# COM ProgID the live backend attaches to. Escape hatch for AutoCAD-compatible +# applications that expose the same ActiveX object model. +# +# CAD_PROGID only changes which COM application the backend attaches to — every +# tool beyond the connection itself is developed and tested against AutoCAD +# alone, and AutoCAD-clone compatibility is unverified, so treat a non-default +# ProgID as experimental. An unrecognised ProgID fails loudly rather than +# falling back to AutoCAD, because the fallback would launch the very +# application you said you were not using. +# +# Candidates below are UNVERIFIED — none was tested against a running seat. +# List what is actually registered on your machine with: +# Get-ChildItem Registry::HKEY_CLASSES_ROOT | +# Where-Object { $_.PSChildName -like '*.Application*' } | +# Select-Object -Expand PSChildName +# CAD_PROGID=BricscadApp.AcadApplication # UNVERIFIED +# CAD_PROGID=ZWCAD.Application # UNVERIFIED +# CAD_PROGID=GstarCAD.Application # UNVERIFIED +CAD_PROGID=AutoCAD.Application + # Comma-separated list of allowed directories for file operations. # Leave empty to allow all paths (not recommended for production). # Example: ALLOWED_PATHS=C:\Drawings,D:\Projects\CAD @@ -18,10 +47,31 @@ LOG_LEVEL=INFO # Set to true only if you trust all MCP clients DANGEROUS_COMMANDS_ENABLED=false -# Exposed tool profile: full (default) | core | lean -# core hides raw escape hatches and long-tail tools; lean is a curated -# ~46-tool drafting/inspection core for clients with tight tool caps. +# Exposed tool profile: full (default) | lean +# lean is a curated ~47-tool drafting/inspection set for clients with tight +# tool caps. The "core" profile was removed in v1.5.0 — the discovery layer +# below solves the crowded-surface problem it existed for. TOOL_PROFILE=core +# still boots, falling back to full with a warning. TOOL_PROFILE=full +# Tool discovery: off (default) | search +# "search" replaces the advertised catalog with search_tools + call_tool, so a +# client sees two tools instead of ~126 and searches by AutoCAD command name +# ("QSELECT") or plain English ("round off this corner"). +DISCOVERY_MODE=off + +# Per-call backend timeouts in seconds; 0 disables the check. +# A hung call would otherwise hold the backend lock forever. +COM_CALL_TIMEOUT=60 +EZDXF_CALL_TIMEOUT=120 + +# Undo/redo history for the headless backend, in steps. 0 disables it. +# Not free: ezdxf has no journal, so each step is a full DXF snapshot. +# Measured 37x slower entity creation with it on (0.18 -> 6.65 ms/call), +# and a snapshot of a 5000-entity drawing costs 130 ms / 658 KB. For a +# single checkpoint use transaction_begin/rollback instead. +# The live COM backend uses AutoCAD's own undo and ignores this. +EZDXF_UNDO_DEPTH=0 + # Opt-in 3D solids (COM backend only; ezdxf reports them as unsupported) ENABLE_3D=false diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 9396c33..e34b95d 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -10,6 +10,10 @@ on: schedule: - cron: "17 5 * * 1" +env: + # Keep in step with ci.yml; all three workflows read the same uv.lock. + LOCKED_UV_VERSION: "0.12.1" + jobs: headless-matrix: runs-on: ubuntu-latest @@ -23,13 +27,19 @@ jobs: steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 + id: python with: python-version: "3.12" - - name: Install - run: pip install -e ".[pdf]" + - run: pip install "uv==${{ env.LOCKED_UV_VERSION }}" + - name: Install the locked harness environment + # This lane publishes evidence, so the harness must not float: an ezdxf + # release would otherwise move our own numbers between runs and the + # diff would be unattributable. Competitors keep their own pinned + # checkouts and venvs (benchmarks/competitors_env.py) either way. + run: uv sync --locked --extra pdf --python "${{ steps.python.outputs.python-path }}" - name: Run benchmark (ezdxf lane) run: | - python -m benchmarks.run_competitors \ + uv run --no-sync python -m benchmarks.run_competitors \ --server "${{ matrix.server }}" \ --backend ezdxf \ --artifact-dir "benchmarks/results/ci/${{ matrix.server }}" \ diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e6beec1..34c190e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,25 @@ concurrency: group: ci-${{ github.ref }} cancel-in-progress: true +# Two deliberately different installation paths run in this file: +# +# lint / test-* install from uv.lock with `--locked`. Every version, down to +# the transitive graph, is the one the suite was last run +# against; `--locked` *fails* rather than re-resolving if +# uv.lock has drifted from pyproject.toml. A release upstream +# can no longer turn main red on its own schedule. +# package installs the built wheel with plain pip, from the dependency +# ranges in pyproject.toml — on purpose. That is the path a +# user gets from PyPI, and it is the only job that exercises +# it, so it must not be locked. +# +# Locked CI is blind to upstream drift by construction; deps-floating.yml is the +# deliberate counterweight that resolves the ranges fresh on a schedule. +env: + # Pinned for the same reason ruff is: the tool that reads the lockfile is part + # of the reproducible environment. Bump this and re-run `uv lock` together. + LOCKED_UV_VERSION: "0.12.1" + jobs: lint: runs-on: ubuntu-latest @@ -19,9 +38,14 @@ jobs: - uses: actions/setup-python@v5 with: python-version: "3.11" - - run: pip install ruff - - run: ruff check . - - run: ruff format --check . + - run: pip install "uv==${{ env.LOCKED_UV_VERSION }}" + # Dev group only: ruff comes from the lock, the project is not built. + # The ruff version lives in [dependency-groups] dev in pyproject.toml and + # nowhere else — the formatter's output changes between minors, so a + # floating ruff fails `ruff format --check .` on unchanged code. + - run: uv sync --locked --only-group dev + - run: uv run --no-sync ruff check . + - run: uv run --no-sync ruff format --check . test-linux: runs-on: ubuntu-latest @@ -32,14 +56,17 @@ jobs: steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 + id: python with: python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - pip install -e ".[pdf]" - pip install pytest pytest-asyncio pytest-cov + - run: pip install "uv==${{ env.LOCKED_UV_VERSION }}" + - name: Install the locked environment + # uv.lock is a universal resolution over requires-python >=3.11, so the + # same file serves both matrix legs; --python keeps uv on the + # interpreter setup-python just provisioned instead of picking its own. + run: uv sync --locked --extra pdf --python "${{ steps.python.outputs.python-path }}" - name: Run tests - run: pytest --cov=. --cov-report=xml --cov-report=term-missing -v + run: uv run --no-sync pytest --cov=. --cov-report=xml --cov-report=term-missing -v - name: Upload coverage if: matrix.python-version == '3.11' uses: actions/upload-artifact@v4 @@ -54,14 +81,14 @@ jobs: steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 + id: python with: python-version: "3.11" - - name: Install dependencies - run: | - pip install -e ".[full]" - pip install pytest pytest-asyncio pytest-cov + - run: pip install "uv==${{ env.LOCKED_UV_VERSION }}" + - name: Install the locked environment + run: uv sync --locked --extra full --python "${{ steps.python.outputs.python-path }}" - name: Run tests - run: pytest -v + run: uv run --no-sync pytest -v package: runs-on: ubuntu-latest @@ -75,6 +102,9 @@ jobs: pip install build twine python -m build twine check dist/* + # Unlocked on purpose: this resolves the published dependency ranges the + # way a user's `pip install autocad-mcp-pro` does. Locking it would mean + # nothing in CI ever proves those ranges are installable. - name: Install wheel in a clean venv and smoke test run: | python -m venv /tmp/smoke diff --git a/.github/workflows/deps-floating.yml b/.github/workflows/deps-floating.yml new file mode 100644 index 0000000..97d8932 --- /dev/null +++ b/.github/workflows/deps-floating.yml @@ -0,0 +1,47 @@ +name: Floating deps + +# The counterweight to a locked CI. +# +# ci.yml installs from uv.lock, so a PR can never be broken by an upstream +# release. That is the point — and it is also a blind spot: a new fastmcp, +# ezdxf or pydantic inside the ranges declared in pyproject.toml would go +# unnoticed here until a user hit it, because the only unlocked job in CI +# (`package`) merely imports the wheel and never runs the suite. +# +# This job is the deliberate opposite: resolve the declared ranges fresh and +# run the full suite against whatever the index serves today. It runs on a +# schedule and on demand only, so it never gates a pull request — a red run +# here means "the ceiling needs a look", not "your change is broken". +# +# Lint is intentionally absent: ruff's formatter output moves between minors, +# so a floating ruff would fail on unchanged source and say nothing about the +# runtime dependencies this job exists to watch. + +on: + schedule: + # Mondays, 06:17 UTC. Off the hour so it does not queue behind the + # every-cron-on-the-hour crowd. + - cron: "17 6 * * 1" + workflow_dispatch: + +jobs: + floating: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.11", "3.12"] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install from the declared ranges (uv.lock deliberately ignored) + run: | + pip install -e ".[pdf]" + pip install pytest pytest-asyncio + - name: Record what actually resolved + # Printed unconditionally: when this job fails, the version list is the + # first thing needed to tell which upstream moved. + run: pip list + - run: pytest -q diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 877c48b..e8b6d75 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -13,20 +13,29 @@ on: push: tags: ["v*"] +env: + # Keep in step with ci.yml; both read the same uv.lock. + LOCKED_UV_VERSION: "0.12.1" + jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 + id: python with: python-version: "3.11" - - name: Install dependencies - run: | - pip install -e ".[pdf]" - pip install pytest pytest-asyncio ruff - - run: ruff check . - - run: pytest -q + - run: pip install "uv==${{ env.LOCKED_UV_VERSION }}" + - name: Install the locked environment + # `--locked` refuses to re-resolve, so the artifact that goes to PyPI is + # gated by the exact dependency set the suite was run against — not by + # whatever the index happens to serve at tag time. ruff's version comes + # from [dependency-groups] dev via the lock; it is not restated here. + run: uv sync --locked --extra pdf --python "${{ steps.python.outputs.python-path }}" + - run: uv run --no-sync ruff check . + - run: uv run --no-sync ruff format --check . + - run: uv run --no-sync pytest -q build: needs: test diff --git a/.gitignore b/.gitignore index 6b2cc6e..d46dc2e 100644 --- a/.gitignore +++ b/.gitignore @@ -53,5 +53,7 @@ benchmarks/.competitors/ # Local editor/agent state .cursor/ -# Stale/unvetted lockfile (regenerate deliberately with 'uv lock' if adopting uv) -uv.lock +# NOTE: uv.lock is deliberately *not* ignored. It used to be — back when the +# committed one was stale and unvetted — but CI now installs from it with +# `uv sync --locked`, so ignoring it would silently disarm every locked lane. +# Regenerate with `uv lock` whenever pyproject.toml dependencies change. diff --git a/CHANGELOG.md b/CHANGELOG.md index f658dc6..df05da9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,264 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [1.5.0] — 2026-08-06 ### Added +- **Selection filters — 3 tools.** `selection_window` (AutoCAD's ssget + window/crossing), `selection_polygon` and `selection_filter` (QSELECT). + Window returns only entities wholly inside; crossing also returns the ones + straddling the edge — the modes are reported back and `window` is the + default, because the over-selecting mode must never be the implicit one. A + zero-area box is refused rather than answered with an empty list, which would + read as "nothing there". + `selection_filter` takes named parameters rather than a query string + deliberately: ezdxf's query language answers an unknown attribute with an + empty result, so `LINE[nosuchattr=='x']` is indistinguishable from "no + matches". Here a typo is a `TypeError` at the boundary, and `filtered_by` + reports which filters actually ran. `min_area` excludes shapes that have no + area at all rather than treating them as zero, and a negative `min_area` is + refused — no area is negative. +- **Boundary tracing — 2 tools, and five deliberately not built.** + `boundary_trace` is AutoCAD's BOUNDARY/BPOLY: a seed point in, the nearest + enclosing closed polyline out. It never calls `edgeminer.find_all_loops` — + measured at **63.9 s for 40 edges and a TimeoutError at 60**, because the + docstring's O(n!) is real — and walks one loop per seed-adjacent edge + instead, which is 0.0057 s at the same size. Straight edges are split where + they cross, so a construction line drawn across a shape actually divides it; + without that the line shares no endpoint with anything and is invisible to + the edge graph. Curved edges are not split, and the capability says so. + Two things the obvious implementation gets silently wrong, both now tested: + `edgesmith.lwpolyline_from_chain` emits n+1 points with the last duplicating + the first and `closed` left False — a polyline that draws like a square and + behaves like an open path to hatch association, area queries and + `entity_offset`; and `edgesmith.loop_area` works off edge endpoints, so two + semicircular arcs closing into a circle measure **0.0** against a true + 314.159. The area now comes from the flattened curve and each curved edge + keeps its bulge. + **Not built: every REGION and 2D-boolean tool the plan listed.** ezdxf cannot + author a REGION — `add_region()` returns one with zero ACIS bytes — and the + substitute, `greiner_hormann`, takes flat vertex lists. Measured on a square + with one edge bulged into a semicircle, routing it through that boolean loses + **28.2% of the area and 12.5% of the perimeter**. That is the same magnitude + as the `analysis_measure_area` bug this release fixed, and shipping it would + have re-introduced the class. +- **Hatch depth — 3 tools.** `hatch_set_gradient`, `hatch_edit` and + `hatch_add_boundary`. `hatch_edit` leaves omitted parameters alone — a + partial edit that resets the rest is data loss — and `changed` reports the + attributes that actually *moved*, so re-setting a value to what it already + was comes back empty rather than as a false positive. `hatch_add_boundary` + takes typed edges (line/arc/ellipse) because a boundary API that only accepts + vertex lists silently straightens every curve it is given; every edge is + validated before any is written, so a malformed list refuses instead of + leaving a half-built path. On COM it refuses with + `capability: "hatch_edge_paths"`: ActiveX appends boundary loops as existing + objects, not as typed edges, so the fidelity this tool exists for cannot be + expressed there. +- **`analysis_list_properties` — AutoCAD's LIST.** The raw DXF attribute set + for one handle, which `entity_get` deliberately does not carry. It cannot be + handed over as-is for two reasons, both fixed here: `dxfattribs()` holds + `Vec3` values and is not JSON-serialisable, and its coordinates are in the + entity's own frame, so for anything with an extrusion they would contradict + every other coordinate this server reports. +- **Annotation objects — 4 tools.** `entity_create_wipeout` (mask what is + behind a closed polygon), `entity_create_revcloud`, `text_set_background` + (MTEXT background mask) and `text_find_replace`. Each carries a refusal that + was measured rather than assumed. A wipeout of fewer than three points + encloses no area, so it is refused instead of returning a handle to a mask + that masks nothing. A revision cloud *is* its arcs — `ezdxf.revcloud`'s own + helper stamps the `RevcloudProps` xdata and takes the bulge sign from the + winding direction, so the result satisfies `is_revcloud()` — and a + `segment_length` longer than the shortest edge produces no arcs at all, which + would be a plain rectangle reported as a cloud; that case is refused. + `text_set_background` refuses TEXT (only MTEXT has a background-fill + attribute, so setting one on TEXT would report success and change nothing) + and refuses a scale below 1.0 (a box smaller than its text is a stripe + through the text). + `text_find_replace` searches TEXT, MTEXT, ATTRIB **and** ATTDEF, including + inside block definitions — leaving the ATTDEF behind would fix the drawing + and reintroduce the old text on the next insert. It returns + `searched_types`, because "no matches" and "that type was never searched" are + different answers and a caller cannot otherwise tell them apart. DIMENSION + text is deliberately out of scope: its text field holds the `<>` override + placeholder rather than the measurement, so editing it would either do + nothing or detach the dimension from what it measures — the response says so. + On COM, `entity_create_revcloud` refuses with `capability: "revcloud"`: + ActiveX has no revision-cloud member and driving the command blind against a + live drawing is unverified. +- **The rest of the paper-space lifecycle — 8 tools.** v1.4 could make a sheet + and never unmake it. Added `layout_delete`, `layout_rename`, `layout_copy`, + `viewport_list`, `viewport_set_scale`, `viewport_lock`, `viewport_delete` and + `entity_change_space` (CHSPACE). Verified headless (ezdxf); the COM paths for + the first seven are implemented against the ActiveX reference but **never + executed against a live AutoCAD**, and each is marked so in the source. + Most of the work went into refusals, because ezdxf's own guards stop short of + where a tool boundary has to: `layouts.delete("")` deletes the *first* sheet + rather than refusing, `layouts.get("")` resolves to whichever is active, and + `layouts.rename()` skips the name validation `new()` performs, so `Bad/Name` + and even `""` land in the table and the file still audits clean. Deleting or + renaming the current tab used to leave three different answers to "where does + my next line go?" — `layout_list` said one thing, `$TILEMODE` another, and + `_msp()` quietly wrote to model space; all three now agree. A handle from a + deleted sheet refuses instead of dying with `AttributeError` (`entitydb.get` + is documented not to filter destroyed entities, and `_get_entity` believed + it). + `layout_copy` is a construction — ezdxf has no layout-copy API and + `xref.load_paperspace` is cross-document only — and every way it can go wrong + is invisible to the Auditor, which reports zero errors on a duplicated tab + order, two main viewports, and a hatch whose boundary still points into the + layout it was copied from and dangles the moment that layout is deleted. All + three are prevented structurally; `skipped` and `associativity_dropped` report + what could not be carried over rather than letting a bare `ok: true` imply a + complete copy. + `viewport_list` reports the layout's own main viewport with `is_main: true` + rather than hiding it — it is what remains after every drafting viewport is + deleted — and refuses to report a scale on R12, where `view_height` and + `flags` are dropped on export and `get_scale()` then returns a fabricated + number (150.0 measured for a viewport storing no scale at all). The two + writers refuse on R12 outright rather than reporting a success the file + cannot keep, and `viewport_lock` read-modify-writes the flags bitfield, which + also carries the UCS-icon and grid bits a bare assignment would silently drop. +- **`entity_change_space` (CHSPACE), headless only.** AutoCAD moves objects + *through* a viewport, rescaling them so they look identical on screen. + ezdxf's `move_to_layout` applies no geometric change whatsoever, so the + obvious implementation reports success, turns a 100 mm feature into 100 mm of + paper inside a 1:2 viewport, and writes a perfectly clean file. The transform + is therefore the tool, and it uses `viewport.get_transformation_matrix()` + rather than a hand-rolled one: the hand-rolled version ignores + `view_target_point` and misplaced geometry by 25 mm in testing while + `is_top_view` was still true and the twist angle still zero — past every guard + one would naturally write. A regression test pins the result against what + ezdxf's own rendering pipeline projects. + It refuses rather than guessing: non-plan and twisted viewports (ezdxf returns + a meaningless matrix for the first and rotates about the paper origin for the + second), dimensions unless `freeze_dimensions=true` bakes the measurement in + first (`transform` halves `get_measurement()` while the baked block text keeps + the old value, so one of the two is always lying), ACIS bodies (`transform` + is accepted and moves nothing), tables and proxies (`transform` raises, and + `hasattr(e, "transform")` is `True` for all of them), viewports themselves, + and entities already in the target space. Geometry that lands outside the + viewport or off the sheet is moved and **flagged** (`inside_viewport`, + `on_sheet`), not refused — AutoCAD allows it too. + On the COM backend this refuses with `capability: "chspace"`: ActiveX has no + change-space member, so the alternatives are `CopyObjects` + delete or driving + the command blind, and neither could be verified against a live seat. +- **`CAD_PROGID` — the COM ProgID is configurable.** `"AutoCAD.Application"` + was hardcoded at both connection sites, so AutoCAD-compatible applications + (BricsCAD, ZWCAD, GstarCAD) were unreachable even though they expose the same + ActiveX object model. The setting is honoured on *both* paths deliberately: + `Dispatch` is not a passive probe — it COM-launches the application and sets + `Visible = True` — so honouring the setting on `GetActiveObject` and then + falling back would start the very AutoCAD the operator said they were not + using. An unrecognised ProgID fails loudly for the same reason, and the error + names both the ProgID it tried and the variable to change. `system_status` + now reports `cad_progid`, without which a non-AutoCAD operator reads an + `autocad_version` key naming a product they do not own. + **Caveat, stated everywhere it appears:** `CAD_PROGID` only changes which COM + application the backend attaches to — every tool beyond the connection itself + is developed and tested against AutoCAD alone, and clone compatibility is + unverified, so treat a non-default ProgID as experimental. The candidate + ProgIDs in `.env.example` are commented out and marked UNVERIFIED: none has + been tested against a running seat. + +- **`analysis_measure_entity` — measure the drawing, not your memory of it.** + Neither backend could answer "what is the area of the polyline I just drew": + `analysis_measure_area` shoelaced points the *caller* typed in, and the live + COM backend did the same despite having the document, a `HandleToObject` + lookup it already used fourteen times, and an ActiveX `Area` property. The + only available workflow was also silently wrong — `entity_get` read LWPOLYLINE + vertices through the default format and kept x and y, dropping the **bulge** + that makes an edge an arc. Measured on a 100×100 square with one edge bulged + into a semicircle: 10000 reported against a true 13926.9908, **28.2% low**, + perimeter **12.5% low**, nothing in the response saying so. And + `props["length"] = ent.length()` was dead code — `LWPolyline` has no + `length()`, so the key never appeared at all. + Bulge geometry is always circular, so the new `engineering/measure.py` is + exact rather than approximate: shoelace plus each arc's signed circular + segment, and arc length for perimeter. `entity_get` now reports `area` and + `length` from the same maths. The payload states its own accuracy — `exact`, + `flatten_tolerance`, `assumed_closed` (an open boundary is closed the way + AutoCAD's AREA does), and `self_intersecting`, because the shoelace *cancels* + crossed lobes and a bowtie measures 0.0. REGION/3DSOLID refuse with + `capability: "measure_area_acis"` naming the live backend; LINE/TEXT/INSERT + raise a plain error, since no engine can give them an area and a capability + tag would falsely suggest switching helps. `AREA`/`MEASUREGEOM` now route here + rather than at the points-based tool — a drafter typing AREA picks objects, + they do not retype coordinates. +- **Handle grounding: `view_screenshot(overlay_handles=true)`.** A screenshot + showed the model what the drawing looks like and nothing it could act on — + every modify tool takes a handle, and nothing connected "the circle at the + top-left" to a hex string. Each entity is now labelled with its handle at its + own bounding-box centre (taken from the renderer's extents, so the label lands + where the entity is *drawn* even in a rotated frame). Off by default, because + labels are ink on the drawing; past 40 entities the render is capped and the + image itself says "handles shown for N of M" rather than letting a labelled + subset read as the whole sheet. The live backend captures the AutoCAD window + rather than rendering, so there is nothing to label there — it refuses with + `capability: "handle_overlay"` instead of quietly returning an unlabelled + image, since producing the overlay would mean creating and deleting entities + in the user's open drawing. +- **`block_create_from_entities` works on the live backend.** It refused + outright and pointed at `system_run_command` with `_BLOCK`, pushing callers + through the free-text escape hatch for something ActiveX does directly + (`Blocks.Add` + `Document.CopyObjects`). The tool's own docstring said the + opposite — "works by using AutoCAD's BLOCK command (COM backend only)" — so + the one engine that could not do it was advertised as the one that could. + Both engines now also report `skipped` handles instead of swallowing them, and + a call where nothing resolves fails rather than leaving an empty definition + behind. COM path unit-tested against a fake ActiveX object, not + hardware-verified. +- **Undo and redo on the headless backend (`EZDXF_UNDO_DEPTH`, default 0).** + The plan called this "add redo". The larger half was that **undo never worked + either**: `_undo_stack` was declared, read and cleaned up, but nothing ever + appended to it, so `drawing_undo` could only answer "Nothing to undo" — while + the tool summary said "Step back one operation" and the alias corpus routed + `UNDO` and "take that back" straight at it. The old test asserted + `isinstance(result, dict)`, which a permanent failure satisfies. + It is opt-in because it is not free. ezdxf keeps no journal, so a history step + is a whole DXF snapshot: measured 3.1 ms / 19 KB at 10 entities but 130 ms / + 658 KB at 5000, and switching it on costs **37×** on entity creation (0.18 → + 6.65 ms per call) even on a small drawing. Off, it costs nothing; on, you get + real multi-step undo/redo. Both tools refuse with + `capability: "undo_history"` naming the setting rather than pretending. The + live COM backend uses AutoCAD's own undo and ignores it. Drawing after an undo + discards the redo branch, as in AutoCAD — otherwise redo would restore a state + that never existed, with geometry you had removed reappearing beside geometry + you drew afterwards. +- **BREAKING (behavior): geometry now goes into the layout you selected.** + `layout_set_current("A3-Sheet")` answered `{"ok": true, "current": + "A3-Sheet"}` and then every entity created afterwards landed in **model + space** anyway, on both engines — `_msp()` returned model space + unconditionally, so the tool reported a state change that changed nothing. + Proven: a line drawn straight after selecting a layout was in modelspace and + not in the layout. That was also why a title block could not go on a sheet, so + `titleblock_apply_iso_a3` gains a `layout` parameter — where a title block + belongs, since the border frames the printed sheet rather than the model — and + restores your current space afterwards rather than leaving you on the sheet. + Existing model-space workflows are unaffected: a fresh drawing starts in model + space, and a refused layout switch leaves the current space where it was. +- **ISO 286 transition and interference holes (K, M, N, P).** `fit="P7"` used to + raise by name. They are now derived by the standard's delta rule, + `ES = -ei + Δ` with `Δ = IT(n) − IT(n−1)` — a *rule* over the shaft and IT + tables already in the module, not new table data, which is why it can be + pinned against published values (K7/M7/N7/P7 and K6/N6/P6 cross-checked by + hand) instead of merely trusted. Unlocks the hole-basis pairs K7/h6, M7/h6, + N7/h6, P7/h6. +- **ISO 286 interference shafts (r, s, t, u).** `fit="s6"` and `fit="u6"` — the + standard press and drive fits — used to raise. Unlike K/M/N/P these *are* new + table data, sub-stepped more finely than the main size steps (ISO 286 splits + 18–30 into 18–24/24–30, 30–50 into 30–40/40–50, and every step above 50 mm in + two or three; flattening that is a silent error of tens of microns at exactly + the sizes press fits are used at). Because it is data rather than a rule, its + **provenance is tested**: every value is cross-checked against ISO 286-1's own + derivation formulae, an independent source. `r` reproduces as the geometric + mean of `p` and `s` on **24/24** steps — with `p` pinned in this module before + `r` existed — and `s`/`t`/`u` track their IT7-based formulae within 1 µm on + every step from 18 mm up. The five places where the standard's rounding puts a + published value 2–3 µm off its own formula are listed by name in the tests, so + a reader holding ISO 286 checks five numbers rather than ninety-five. Shaft `t` + is undefined below 24 mm in the standard and refuses there rather than being + extrapolated. Unlocks H7/r6, H7/s6, H7/t6 and H7/u6. - **Headless performance lane** (`benchmarks/perf_suite.py` + `render_perf_chart.py`): fixed wall-time workloads (2k individual creates, 10k build/export/reopen roundtrip, 10k region query, full premium quality @@ -17,18 +271,487 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Task-matrix chart** (`benchmarks/render_matrix_chart.py`): tasks × servers status heatmap rendered from the published live-run reports, so coverage gaps are visible per capability instead of one folded score. +- **Tool discovery** (`DISCOVERY_MODE=search`, default `off`): replaces the + advertised catalog with `search_tools` + `call_tool`, so a client sees two + tools instead of 127. Ranking layers an AutoCAD command / synonym corpus + (`discovery/aliases.py`, 132 tools · 148 command tokens · 583 synonyms) over + fastmcp's BM25 index, and hits are returned as one compact line per tool + rather than full JSON schemas. Measured on a 66-query golden set with a + 15-query holdout: **top-1 62/66 vs 23/66** for stock BM25, top-3 64/66 vs + 33/66 (holdout top-3 14/15 vs 9/15); stock returns *nothing at all* for 21 of + the 66, and this transform for none of them — that gap is the corpus, not the + ranking, and no BM25 parameter closes it. (Both sides moved slightly during + the release as tool docstrings were rewritten: better prose feeds *both* + indexes, which is why the headline is the top-1 gap rather than a ratio + between two scores over a shared corpus.) A `risk` ceiling + keeps destructive tools out of read-only answers and says when it hid one. + Off by default — flipping it changes what every connected client sees on + `list_tools`, which needs its own migration note. +- **`@cad_tool` — a discovery-metadata channel next to the tool contract.** + A decorator written *above* an `mcp.tool` registration attaches a `cad` key + to that tool's MCP `meta`: a one-line `summary` for a search hit's preview, + a `cost` from a closed set (`read`, `safe`, `mutate`, `destructive`, + `escape`) for clients that want to gate or colour-code the surface before + calling, and the tool's AutoCAD command names and synonyms — imported from + `discovery/aliases.py`, never restated, so the corpus stays the one edit + site. `tags=` and `annotations=` deliberately stay on the `mcp.tool` call, so + the channel cannot perturb `_tool_groups()` or the registration-counting + release gate. Misuse fails at import rather than silently no-opping: an + unknown cost raises, and a decorator written below the registration (or on a + bare function) raises with the ordering rule in the message. Applied to the + three `transaction_*` tools so far; every other tool still falls back to the + first line of its docstring in search results, which is why the rollout can + be section-by-section instead of a 131-tool patch. +- **`EZDXF_CALL_TIMEOUT`** (default `120`s, `0` disables): per-call timeout for + the headless backend, mirroring `COM_CALL_TIMEOUT`. Every ezdxf method runs + under one document lock, so a single hung call previously deadlocked every + later tool call. +- **`uv.lock` — the dependency graph CI actually installs.** 95 packages, + resolved universally over `requires-python >=3.11`, so one file serves the + Linux 3.11/3.12 matrix and the Windows leg (platform-only packages like + pywin32 carry markers). Every lint and test lane now installs with + `uv sync --locked`, which *fails* rather than re-resolving when the lock and + `pyproject.toml` disagree — a fresh CI run can no longer pick up an upstream + release nobody chose, and the locked set is the set the suite is green on. + The `package` job stays unlocked on purpose: it is the only job that proves + the published dependency *ranges* still resolve for a plain + `pip install autocad-mcp-pro`. The `uv.lock` line in `.gitignore` (added when + the committed lockfile was stale) is gone with it — a lockfile git ignores is + not a lockfile. +- **Scheduled floating-dependency job** (`.github/workflows/deps-floating.yml`): + weekly and on demand, it ignores the lock, resolves the declared ranges fresh + on 3.11/3.12 and re-runs the suite. Locked lanes cannot be broken by an + upstream release, which also means they cannot *notice* one; this is where a + range ceiling that needs moving shows up. It never gates a pull request. +- **Benchmark matrix v3 — five tasks that can fail.** The v2 matrix scored this + server 10/10, which was not evidence of anything: every task in it exercised + something the server was built around, so full marks were the only available + outcome. `benchmarks/tasks_v3.py` keeps the v2 ten byte-for-byte (their ids + and weights key the published v1.4 reports) and adds `tool_discovery`, + `token_budget`, `hatch_islands`, `selection_filter` and + `measure_from_handle`. Each verifies against a value derived independently of + the code under test — a closed-form area, entities placed on purpose, a token + ceiling fixed before the measurement. **Three of the five failed while being + written**, and the defects they found are in *Fixed* below. + Two are renamed from the release plan, and the renames are the point rather + than bookkeeping. `tool_discovery_bilingual` lost its "bilingual": the + Turkish query normalizer was written and then removed, because this + repository is English throughout and the Turkish was inferred from the + author's chat language, not required by the product — a task named + "bilingual" would benchmark a capability that does not exist. + `measurement_massprops` became `measure_from_handle`: no centroid or + moment-of-inertia tool ships, since F2 was cut to `entity_measure`. + The competitor reports are **not** re-scored. Those servers were pinned and + run against v2 at v1.4 and have never been asked these five questions; the + matrix chart renders the five rows blank with a `not run` legend entry rather + than zero, because an invented zero is indistinguishable from a measured one. + `run_competitors` grows `--matrix v2|v3` (v2 stays addressable so a v1.4 + report can be reproduced rather than only described), records which matrix + ran in the report, and grows `--publish`, which writes the artifact-path-free + file that `results/published/` holds — that sanitisation used to be a manual + edit. +- A capability refusal in the benchmark runner is now recorded `unsupported` + instead of `fail`. The result enum has carried `unsupported` since v2 for + exactly this, but a refusal is still an exception and was being caught by the + generic handler — "your engine cannot reach this" and "your server got this + wrong" are different findings about a competitor. +- Three measurement checks added to `correctness_suite.py` (24, was 21). They + are new capability rather than repaired regressions — v1.4.0 misses them by + not having the method — and they are there because all three broke during + this release and nothing outside their own unit tests would have noticed. ### Changed +- **The backend contract is 19 modules instead of one 1854-line file, and + optional methods are declarative.** `backends/base.py` held a single ABC with + 112 abstract methods; adding one made *both* backends fail at instantiation + until both were written, and two test modules construct `ComBackend` at + import time, so a half-finished contract edit took the entire suite red. The + interface now lives in `backends/contracts/`, one module per domain, split + along the section markers that were already in the file — the seams were not + re-decided. `AutoCADBackend` is the composition of all 19 and stays the + import surface, so `from backends.base import AutoCADBackend` and every + `isinstance` check are unchanged, and the abstract-method count is identical + before and after. + The split surfaced something the file's name hid: roughly 740 of those lines + are shared *concrete* implementation (premium meta-tools, GD&T, the settings + facade), not interface. Those slices are mixins and are labelled as such + rather than presented as pure contracts. + New `backends/capability.py` carries `@capability(key, reason=...)`, which + marks a contract method optional and supplies a default. The default + **raises** `UnsupportedCapabilityError` rather than returning a quiet + `{"ok": False}` — a method nobody implemented has to be indistinguishable + from a deliberate refusal, not from a soft failure, which is the distinction + this release spent a milestone restoring. A key used by the decorator must be + declared in *both* capability maps or `tests/test_capability_contract.py` + fails; without that gate the decorator would just be a way to ship refusals + `system_capabilities` never mentions, which is worse than the build break it + replaces. Its first user is COM's `entity_change_space`, which now inherits + the refusal instead of hand-rolling it. The refusal vocabulary moved into + this module as well (the contracts need it and `base` imports the contracts); + `backends.base` re-exports it, so there is still exactly one exception class + object in play. +- **BREAKING (env): the `core` tool profile was removed.** `TOOL_PROFILE` is + now `lean` or `full`; the discovery layer above solves the crowded-surface + problem `core` existed for. `TOOL_PROFILE=core` still boots — it falls back + to `full` with a warning naming the removal, and `system_about` reports the + requested profile alongside the applied one. + +- **BREAKING (install): `ezdxf>=1.4` is now the floor** (was `>=1.3`). The 1.4 + line is the only one this release is developed and tested against; 1.3 is no + longer exercised by CI and is unsupported. Upgrade with + `pip install -U "ezdxf>=1.4"`. +- **Runtime pins narrowed.** A published library cannot pin exactly and stay + co-installable, so what a user's `pip install` gets is still a range — and a + range is the only guard that install has. They are therefore deliberately + narrow. (`uv.lock`, above, is the exact guard on *our* side; the two are not + substitutes for each other.) + - `fastmcp>=3.0` → `fastmcp>=3.4.5,<3.5`. `fastmcp` is now a thin + meta-package (`Requires-Dist: fastmcp-slim[client,server]`) and 3.x moves + fast enough that a new minor can reshape the API the server is built on. + Tested against 3.4.5; the ceiling moves only after the suite is re-run. + - `ezdxf>=1.3` → `ezdxf>=1.4,<2` (see the BREAKING note above). + - `pydantic>=2.0` → `pydantic>=2.0,<3`, so a future pydantic 3 cannot be + pulled into an install of this release. +- **Rendering requires an extra — documented, not changed.** + `ezdxf.addons.drawing.__init__` imports `.frontend`, which does an + unconditional top-level `import PIL.Image`. Pillow is therefore a hard + requirement of *every* ezdxf render path (PNG screenshot and PDF export + alike), not just of the COM window-capture path it is listed under. A bare + `pip install autocad-mcp-pro` resolves 77 packages with neither Pillow nor + matplotlib, so it cannot render at all; `[pdf]` (or `[full]`) is required, + and satisfies Pillow transitively via matplotlib. Note that `[com]` alone + installs Pillow but not matplotlib, so the ezdxf backend still cannot render + under it. Pillow deliberately stays out of the core dependencies for now. +- **CI lint pinned to `ruff==0.16.1`** (was an unpinned `pip install ruff` / + `ruff>=0.8`). The version is now written in exactly one place — + `[dependency-groups] dev` — and frozen there by `uv.lock`; the workflows + install ruff *from* the lock instead of restating the pin, so the three + copies that had to be bumped together no longer exist. +- **Dev toolchain moved to a PEP 735 dependency group.** `[tool.uv] + dev-dependencies`, which uv now warns is deprecated, became + `[dependency-groups] dev` — same four tools, byte-identical resolution. The + standard spelling is also understood by pip >=25.1, so `pip install --group + dev` gives contributors who do not use uv the same list without a second + copy of it. +- **CI and the release gate install from the lock.** `lint` (dev group only — + ruff without building the project), `test-linux`, `test-windows` and the + `release.yml` test job now run `uv sync --locked` + `uv run --no-sync`, with + uv itself pinned in the workflow env for the same reason ruff is. `package`, + `docker` and `registry-validate` are unchanged. `benchmark.yml` is locked + too: that lane publishes evidence, and an ezdxf release moving our own + numbers between runs would make the diff unattributable. - README rebuilt: PyPI-first install, a four-lane benchmark section with Python-generated visuals (scores, task matrix, performance, source review), v1.4 feature table, updated client/config/roadmap sections. - `render_live_chart` now skips non-task-matrix reports so multiple lanes can share `benchmarks/results/published/`. -_Next (1.5): screenshot overlay + handle grounding v1, ezdxf redo (forward -snapshots), COM `block_create_from_entities`, ISO 286 transition/interference -hole letters (delta rule), titleblock on paper-space layouts._ +### Fixed + +- **The COM paths were run against a live AutoCAD 2026, and four of them were + broken.** Every COM implementation added in this release was written against + the ActiveX reference and shipped labelled unverified. Executing them against + a real seat — a fresh instance, a throwaway drawing, nothing saved — found + four defects that no amount of reading would have caught, one of them + pre-existing: + * **`AcadPViewport` has no `ViewCenter` member.** `viewport_list` died on it + outright. Worse, `viewport_create` has carried the same line since v1.4 + wrapped in a bare `except: pass`, so it raised on every single call and + said nothing — every COM viewport has been looking at the model origin + instead of the `view_center_x/y` the caller asked for. The member is + `Target`, and the failure is no longer swallowed. + * **`ModelSpace.AddWipeout` does not exist.** ActiveX has no wipeout + constructor at all; WIPEOUT is a command. Now a typed refusal + (`capability: "wipeout"`) instead of an `AttributeError`. + * **`AcDbMText.BackgroundFillColor` does not exist** — it raises on read as + well as on write, with an int and with an `AcCmColor` object alike. + `BackgroundFill` (the on/off flag) does work, so `text_set_background` + enables and disables the mask on COM and refuses a *colour* request with + `capability: "mtext_background_color"`, rather than switching the fill on + in the wrong colour and reporting success. + * `analysis_list_properties` called a helper that does not exist on the class. + After the fixes, all 25 verification calls behave correctly: 18 succeed, 1 + refuses per-argument (deleting model space), and 6 refuse with the capability + keys they declare. The remaining COM refusals + (`chspace`, `revcloud`, `boundary_trace`, `hatch_edge_paths`) were confirmed + to hold rather than leak a result. The source comments now say VERIFIED with + the date instead of UNVERIFIED. +- **The headless renderer *does* project model content through viewports. + v1.4 said it did not.** `viewport_render` was declared unsupported on ezdxf, + every `drawing_export_pdf(layout=...)` response carried the note "projecting + model content through viewports is COM-only", and the README table agreed — + so a caller with no AutoCAD was told to go get one for something that already + worked. Measured on ezdxf 1.4.4: a sheet holding *nothing but a viewport* + renders the model geometry that viewport looks at, two viewports at 1:1 and + 1:2 render the same circle at two sizes in the right places, and geometry + outside a viewport's window is correctly left out. Understating a capability + is the same class of untruth as overstating one, and it is the more expensive + one for the user. What is genuinely missing is the viewport *border*, which + the headless renderer does not draw — the capability now says exactly that + (`reason: viewport_borders_not_drawn`), and the old note's claim that + "viewport frames are rendered" was wrong in the other direction. Two tests + that pinned the false claim were corrected rather than deleted, and the + replacement proves the behaviour by subtraction: the sheet renders less ink + once the model geometry is gone. +- **Layout names are matched case-insensitively, the way the engine underneath + already matched them.** ezdxf resolves layout names case-insensitively in + `get`, `new`, `rename`, `delete` and `set_active_layout` — but `names()` + returns the *stored* spelling, so every guard written as + `name in doc.layouts.names()` disagreed with the code it was guarding, in both + directions. `layout_create("SHEET")` next to an existing `Sheet` walked past + its own duplicate check and died on a raw `DXFValueError` from inside ezdxf; + `layout_set_current("SHEET")` reported "Layout not found" for a tab that was + right there. Both shipped in v1.4. One resolver now backs + create/set_current/delete/rename/copy and `viewport_list` on both engines, and + the current space is stored in the canonical spelling. Re-spelling a layout's + own name (`Sheet` → `SHEET`) is a rename onto itself rather than a collision; + ezdxf's `rename` refuses that outright, so it goes through a staging name. +- **`entity_change_space` refuses an entity that lives on another sheet instead + of half-applying.** `move_to_layout` raises `DXFValueError` from deep inside + ezdxf when the entity is not in the source layout — and it raises *after* the + transform has already been applied, so the failed call left the line rescaled + and translated by a viewport it never moved through, still sitting on the + wrong sheet. Membership is now checked before anything is written, and the + refusal names the space the entity is actually in. One bad handle in a batch + no longer aborts the good ones. +- **BREAKING (behavior): coordinates crossing the boundary are now WCS. They were + not.** DXF stores CIRCLE, ARC, LWPOLYLINE, TEXT, INSERT and a dimension's text + position in a per-entity frame derived from the entity's `extrusion` vector — + and the string "extrusion" appeared *nowhere* in this repository, so every such + read returned a raw attribute rather than a world coordinate. Mirroring flips + extrusion to `(0,0,-1)` and leaves the stored x unnegated, so after the + server's own `entity_mirror` across the Y axis a circle drawn at (30, 20) + reported `center [30.0, 20.0]` while the **same response's** `bounding_box` + said `min [-35, 15] / max [-25, 25]`: the payload contradicted itself by 60 mm, + silently, with no error anywhere. `point_from_snap` returned points on the + wrong side of the axis, `entity_select_smart` found the entity where it wasn't, + and `entity_offset` placed the new circle 60 mm away *and* grew it when asked + to shrink. Reads are now normalised at the single `_entity_info_dxf` funnel, so + all 38 call sites and every consumer of `EntityInfo.properties` are fixed with + them. The write paths (`entity_edit_geometry`, `entity_offset`) got the + matching inverse — mandatory, not cosmetic: read and write were *equally* + wrong, which made a read-then-write round trip an accidental no-op, and fixing + reads alone would have turned that no-op into a real 60 mm move. + - **Not touched, deliberately:** LINE, MTEXT, ELLIPSE, SPLINE and POINT carry + an `extrusion` attribute too and were always correct (ezdxf makes their + `ocs()` a pass-through); `entity_rotate` was never affected — it preserves + extrusion `(0,0,1)`. The trigger was `entity_mirror` and foreign DXF files. + A dimension's `defpoint` is DXF group code 10 and WCS already; only + `text_midpoint` (code 11) is in the entity frame. + - **Tilted planes.** An entity whose plane is not parallel to WCS XY cannot be + described by an xy pair — a tilted circle of radius 5.0 has a 5.0 × 3.99 + footprint. `entity_get` reports the WCS centre plus `plane_normal` and + **omits** `radius` / `start_angle` / `end_angle` rather than reporting a + number that is not true in the frame the rest of the dict uses, and + `entity_edit_geometry` refuses with `capability: "ocs_tilted_plane"`, + pointing at `entity_move` (which transforms the whole entity and preserves + its plane). New capability keys `ocs_normalized` and `ocs_tilted_plane` on + both backends make the boundary pre-checkable via `system_capabilities`. + - **Named residuals** (in the `ocs_normalized` reason rather than left silent): + the OCS z component is not reported, HATCH boundaries are not reported at all + (silence, not a wrong number), and TEXT `rotation` stays in the entity frame + because a mirrored TEXT is mirror-imaged and no single scalar angle expresses + that — measured 29.999 reported against a true WCS 150.0. + - **COM:** ActiveX returns WCS natively for every point property *except* + `AcDbLWPolyline.Coordinates`, which is OCS; that one read is now translated. + Verified only against a fake COM object — this machine has a live AutoCAD and + connecting to it during development is forbidden, so the path ships + unit-tested, not hardware-verified. `AcDb2dPolyline` is deliberately out of + scope: its `Coordinates` may be 3D triples, and changing the stride on a + documentation reading alone would risk a path that works today. +- **`validate_path` handed out working handles to `C:\Windows`.** Windows names + the same file through several anchors that compare unequal. The backslash + spellings were rejected, but `pathlib` normalises the *forward-slash* forms + into them only after `.resolve()` — so `//?/C:/Windows/win.ini` slipped past + the pattern check and then failed to match `C:/Windows` in the + system-directory loop. Proven: it returned a handle that `os.path.samefile`'d + the real file. Non-local anchors are now rejected in every spelling. This + removes no capability anyone had: the equivalent backslash paths were already + refused, so it only makes one policy consistent across spellings. +- **The AutoLISP guard read drawing text as code.** The symbol denylist scanned + inside double-quoted literals, so an engineering server refused its own + vocabulary: `(setq desc "Max load 500 kg")` matched the `load` pattern and + `(setq layer "C:CENTER")` matched the custom-command pattern. Literals are now + blanked before scanning. Every known-dangerous vector still blocks, including + `(load "malicious.lsp")` and `(vl-catch-all-apply (quote command) ...)`, whose + dangerous token is outside the quotes. +- **The command denylist matched characters, not commands.** A word-boundary + regex over the whole string refused `MTEXT 0,0 10,10 Do not erase this part` + — annotation text — while missing spellings AutoCAD accepts. SendCommand takes + a *macro*: segments split on newline or `;`, and only a segment's first token + is a verb. Matching is now verb-scoped with decorators (`_`, `.`, `-`, `'`) + stripped, the set gained the load/run channels it was missing (`ARX`, + `VBASTMT`, `VBAIDE`, `CUILOAD`, `SCR`, `SAVE`, `EXPORT`, `ETRANSMIT`, …), and + the refusal now names the offending verb — previously a user whose annotation + was refused had no way to tell why. Single-letter aliases stay out on purpose: + `E` is the ERASE alias but also ZOOM's Extents option, so blocking it would + refuse `_ZOOM\nE\n`, the canonical zoom-extents macro. +- **README no longer claims the denylist is a security boundary.** It said + "blocked by default, with regression tests for known bypass patterns"; the + bypasses above had no tests, and no denylist over a command language that + loadable modules extend can be complete. It now says what the guard is — a + guardrail against an agent accidentally issuing `ERASE ALL` — and names the + actual boundaries: `ALLOWED_PATHS`, not exposing the server over the network, + and the typed tools. The same sentence is in the two tool docstrings, so it + reaches the model that calls them. *Not* changed: blocking `INSERT`/`SAVEAS` + as command strings while `block_insert`/`drawing_save_as` exist was flagged as + incoherent and is not — the policy is scoped to the free-text channel, and the + typed tools take validated arguments where the raw commands take file paths. + The reasoning is recorded in `security.py` so it is not re-opened. +- **`config.settings.backend` was a control that controlled nothing.** + `_make_backend` read `os.environ["AUTOCAD_MCP_BACKEND"]` directly, so the + setting had no production reader at all and + `monkeypatch.setattr(config.settings, "backend", "ezdxf")` — which two tests + used — read like a safety guard while doing nothing. On a developer machine + with AutoCAD open that meant `auto`, i.e. attaching to the operator's live + unsaved drawing and creating entities in it. `Settings.backend` is now a + read-only property that reads the environment live, `_make_backend` goes + through it, and the test suite pins the headless backend for every test via an + autouse fixture (opt out with `@pytest.mark.live_backend`). Assigning to + `config.settings.backend` now raises `AttributeError` instead of silently + protecting nothing. `monkeypatch.setenv` — what the correct call sites already + used — is unaffected. +- **A capability refusal now survives the trip to the client.** The backends + always knew exactly why they could not do a thing — they raise + `UnsupportedCapabilityError(capability, message)`, where `capability` is a key + of the capability map, so `system_capabilities` answers the same question + up-front. None of that reached anyone: `FastMCP.call_tool` rewraps a + non-`FastMCPError` as `ToolError(f"Error calling tool {name!r}: {e}") from e`, + and while the `from e` keeps the cause alive in-process (which is why + `cad_batch` could classify refusals), `__cause__` does not serialise. A remote + client got an English sentence, and telling "this backend cannot" from "your + arguments were wrong" meant substring-matching prose. A refusal now returns + `is_error: true` with + `{"ok": false, "kind": "unsupported", "capability", "error", "tool", + "backend"}` in `structured_content`, produced by one new + `CapabilityRefusalMiddleware` — no tool signature changed. *Not* done by + rebasing the exception on `FastMCPError`: that was measured and changes + nothing a client can observe, since neither the payload nor the error flag + travels with the exception class. Non-capability errors are byte-for-byte + unchanged. `UnsupportedCapabilityError` moved to `backends/base.py` (still + importable from `backends.ezdxf_backend`, and the *same* class object) so both + backends raise one type, and the five headless `solid_*` methods now raise it + instead of returning `{"ok": false}` — same payload, but `is_error` stops + being quietly `false` on a call that did nothing. +- **BREAKING (behavior): `cad_batch` counted a step that declined *by value* as + a success.** A tool that returns `{"ok": false, ...}` rather than raising was + recorded `status: "ok"` and folded into `succeeded`, so a batch could report + `succeeded: 12` over work that never happened — the same class of lie as the + DWG bytes and the unverified rollback. Such a step is now `status: "error"` + with kind `unsupported` (when it names a capability) or `refused`, counts + toward `failed`, and honours `on_error` like any other failure. Batches that + relied on, say, a no-op `drawing_redo` passing silently will now stop or roll + back; that is the point. **Read-only tools are excluded on purpose**: + `validation_check` / `drawing_critique` / `drawing_preflight` answer + `{"ok": len(issues) == 0}`, so their `ok: false` means *I found something*, + not *I could not run* — reading the two the same way would let a read-only + check trigger a rollback that destroys the batch's own geometry. The + discriminator is the `readOnlyHint` annotation each tool already publishes. +- **BREAKING (behavior): `drawing_audit` said "0 errors" about documents it had + just silently rewritten — and then threw the repairs away.** `Drawing.audit()` + is not a read: it fixes every fixable problem in place, so `auditor.fixes` is + a log of mutations already applied. The tool discarded that list entirely, + never marked the document dirty — so `drawing_close(save=True)` saw a "clean" + document, skipped the save, and the repairs it had just made were lost — and + rendered the errors it *did* report through `str()` on an `ErrorEntry` that + defines no `__str__`, i.e. as ``. + The tool now returns `repaired` / `fixes` / `fix_count` alongside + `errors` / `error_count`, both as `{code, name, message, handle}` dicts, and + marks the document dirty when it repaired something. **The behavioral break: + an audit that repairs now leaves unsaved changes, so a following + `drawing_close(save=True)` rewrites the file where it previously did not.** + That is the repair finally persisting; the tool was already annotated + `readOnlyHint: false` / `cost: mutate`. **Audit can delete entities** — an + entity whose owner handle points nowhere is removed, not patched — and with + the dirty flag fixed, that deletion now reaches disk on the next save where + before it was accidentally discarded. ezdxf reports those particular fixes + without the entity attached, so `handle` is `null` for them; the message names + the type and handle in prose. Read `fixes` before saving. On the COM backend `_AUDIT Y` likewise + repairs, but AutoCAD returns nothing machine-readable over `SendCommand`, so + the counts come back as `null` with `detail: "unavailable"` and + `capability: "audit_detail"` — never `0`, which would claim nothing was wrong + — plus the path of the `.adt` log. That backend also no longer leaves the + user's `AUDITCTL` permanently set to 1. +- **BREAKING (behavior): the ezdxf backend no longer writes DXF bytes into a + `.dwg` name.** `drawing_save("part.dwg")` previously called `doc.saveas()` + and produced a file whose contents did not match its extension — AutoCAD + cannot open it, and nothing in the response said so. Both save paths now + refuse, driven by the extension, so `drawing_save_as("x.dwg", fmt="dxf")` is + refused too. Refusals carry a machine-readable + `{"ok": false, "capability": "dwg"}`, and `dwg` is now a declared capability + in both backends (ezdxf: unsupported; COM: native). Save as `.dxf`, or use + the COM backend for DWG. +- **`drawing_open` no longer pretends it can read DWG.** ezdxf detects format + by content, so a DXF renamed `.dwg` (exactly what the bug above produced) + opened "successfully" as a document that does not exist in that format, + while a genuine DWG died with a raw `OSError` blaming the file rather than + the backend. The tool now refuses `.dwg` unless the active backend declares + the capability, and its parameter text no longer advertises DWG + unconditionally. Workflows holding files produced by the old `drawing_save` + bug must rename them to `.dxf`; the error message says so. +- **`TOOL_PROFILE` was a silent no-op whenever a tool transform was attached.** + `_registered_tools()` read the post-transform tool list, so with a transform + in place it saw 2 tools instead of 131 — the lean profile computed 0 + disabled tools and `system_about` reported a collapsed inventory. It now + reads the underlying registry. +- **`ruff format --check .` failed on unmodified `main`.** Ruff 0.16 began + formatting Python code blocks inside Markdown, so the committed + `docs/superpowers/plans/2026-07-17-repository-cleanup-readme-benchmark.md` + started failing the format gate with no source change. Reformatted the file + and pinned the CI ruff version so a future release cannot re-break the gate + the same way. +- `release.yml` now runs `ruff format --check .` alongside `ruff check .`, + matching the CI lint job — the release gate previously skipped formatting. + +- **A HATCH could not be measured, and the refusal listed HATCH as + measurable.** `entity_measure` had no HATCH branch, so a hatch fell through + to the generic error — whose own text named HATCH among the measurable types. + Whichever half was right, the pair could not both be. It now walks the + boundary paths and reports the area the hatch actually *fills*: outer loops + minus the islands inside them, 300 rather than 400 on a 20×20 square with a + 10×10 island. Nesting depth is computed by containment rather than read off + the boundary flags, because DXF marks a path external or outermost and never + "two levels down" — and depth is exactly where AutoCAD's three island styles + stop agreeing. `hatch_style` comes back with the number so it can be read + against the drawing's own setting. On a curved edge `flatten_tolerance` is + not the accuracy knob it looks like: ezdxf hands boundaries over as cubic + Beziers, so tightening it converges on the Bezier's area, ~0.028% above the + circle it stands for, which is why `exact` goes False and stays False. +- **`boundary_trace` and `entity_measure` disagreed about the same shape.** + The boundary tools reported an area taken from a copy of the loop flattened + at a fixed 0.01 sag while `entity_measure` read the stored bulges + analytically: 139.2177 against 139.2699 for one figure, with nothing in + either payload marking one as the approximation. The polyline the boundary + tools store carries exact bulges, so the exact answer was always available; + both now read it and round to the same six decimals. The flattened figure + survives only where it is harmless — ranking candidate loops, where a 0.01% + error cannot change which is smallest. +- **A circle stored as two bulged vertices measured 0.0.** Two semicircular + arcs joined into a circle store as a *two*-vertex closed LWPOLYLINE with both + bulges 1.0 — what AutoCAD writes for a JOINed pair of arcs, and what + `boundary_from_entities` builds from any two-arc loop. `polygon_area_perimeter` + treated fewer than three vertices as degenerate and returned zero, when the + shoelace over two points is genuinely 0 and the two circular segments are the + entire shape. Only zero or one vertex bounds nothing now. +- **The v1.4 → v1.5 performance comparison was measured, and it is not + flattering.** The published perf report was recorded on CPython 3.14 and the + new one on 3.11.15, which alone accounts for nearly all of an apparent 2–6× + "speedup". Re-running v1.4.0 in a worktree on the same interpreter with the + same suite file gives the real picture: entity creation is **28% slower** + (245 → 315 ms for 2,000 lines) and the 10,000-line roundtrip **22% slower**. + Fully attributed — `EZDXF_CALL_TIMEOUT=0` returns v1.5.0 to 244.8 ms and + 1,679 ms, v1.4.0's numbers exactly. The whole cost is the per-call + `asyncio.wait_for` this release wrapped around every headless call so that + one hung ezdxf call can no longer wedge a server whose document lock is a + single `asyncio.Lock`. Deliberate trade, documented knob, and on the 1.6 + roadmap to win back. + +_The five items carried into this release from 1.4 are all done: screenshot +overlay + handle grounding, ezdxf undo/redo, COM `block_create_from_entities`, +ISO 286 transition/interference holes, and titleblock on paper-space layouts. +ISO 286 shafts r/s/t/u are deliberately **not** done — they need table data +nothing here can derive, and the refusal says so rather than guessing._ ## [1.4.0] — 2026-07-23 diff --git a/CLAUDE.md b/CLAUDE.md index 47ba365..c0923a8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Overview -AutoCAD MCP Pro is a FastMCP 3.0 server that exposes ~131 tools, 6 resources, and 5 prompt templates for AutoCAD automation. (The exact tool count is reported dynamically by `system_status` / `system_about` — never hardcode it.) It runs with a dual-engine architecture: a live COM backend (Windows/AutoCAD required) and a headless ezdxf backend (works anywhere). +AutoCAD MCP Pro is a FastMCP 3.0 server that exposes ~154 tools, 6 resources, and 5 prompt templates for AutoCAD automation. (The exact tool count is reported dynamically by `system_status` / `system_about` — never hardcode it.) It runs with a dual-engine architecture: a live COM backend (Windows/AutoCAD required) and a headless ezdxf backend (works anywhere). ## Running the Server @@ -53,7 +53,9 @@ Uses `pytest-asyncio` for async test support. The server uses a strategy pattern with an abstract base: -- `backends/base.py` — `AutoCADBackend` ABC defining the full interface + shared dataclasses (`EntityInfo`, `LayerInfo`, `BlockInfo`, `DrawingInfo`) +- `backends/contracts/` — the interface, one module per domain (`drawing.py`, `layouts.py`, `entity_creation.py`, …). Split in v1.5.0 out of a single 1854-line ABC; some slices (premium, gdt, settings) also carry shared *concrete* implementations, so they are mixins rather than pure interfaces. +- `backends/capability.py` — the refusal vocabulary (`UnsupportedCapabilityError`) plus `@capability(key, reason=...)`, which marks a contract method optional and gives it a default that **raises** that refusal. Use it only for genuine engine boundaries, never for an unfinished method; `@abstractmethod` stays the default. Every key used must be declared in *both* capability maps — `tests/test_capability_contract.py` fails otherwise, which is what stops the decorator becoming a way to ship undeclared holes. +- `backends/base.py` — shared dataclasses (`EntityInfo`, `LayerInfo`, `BlockInfo`, `DrawingInfo`, `CapabilityMap`) and the `AutoCADBackend` composition of every contract. Import surface is unchanged: `from backends.base import AutoCADBackend, UnsupportedCapabilityError` still works. - `backends/ezdxf_backend.py` — `EzdxfBackend`: file-based DXF operations using the `ezdxf` library. All sync ezdxf calls are wrapped with `asyncio.to_thread` via `_async()`. Transactions are implemented as full DXF snapshots on an `_undo_stack`. - `backends/com_backend.py` — `ComBackend`: live AutoCAD control via `pywin32` COM. All COM calls are routed through a `ThreadPoolExecutor` with a single thread to satisfy AutoCAD's STA (Single-Threaded Apartment) COM requirement. @@ -84,10 +86,12 @@ Tools are organized into sections (counts are indicative — `system_about` is a 12. Engineering / Deterministic CAD (8 tools): `gear_draw_*`, `keyway_draw_*`, `titleblock_apply_iso_a3`, `drawing_finalize` 13. Premium meta-tools (12): `drawing_preflight`, `drawing_plan`, `drawing_critique`, `drawing_refine`, `drawing_deliver`, `point_from_snap/intersection/tangent`, `construction_*`, `drawing_apply_iso_layers`, `dimension_auto`, `entity_select_smart` 14. GD&T (ISO 1101 / ASME Y14.5): `gd_frame` (feature control frames), `datum_feature` — enforced by the `gdt` critique focus -15. Layouts & Paper Space (4 tools): `layout_list/create/set_current`, `viewport_create` (scaled model viewports); `drawing_export_pdf` takes an optional `layout` param. Viewport model-content projection is COM-only (`viewport_render` capability). +15. Layouts & Paper Space (12 tools): tab lifecycle `layout_list/create/set_current/delete/rename/copy`, viewports `viewport_create/list/set_scale/lock/delete`, and `entity_change_space` (CHSPACE — tagged `layout` *and* `modify`, and `layout` wins the group priority, so it files here). `drawing_export_pdf` takes an optional `layout` param and **does** project model content through viewports on both engines (`viewport_render` — v1.4 declared this COM-only and was wrong; the headless renderer only omits the viewport borders). `entity_change_space` is the mirror case — ezdxf-only (`chspace`), because ActiveX exposes no change-space member and the COM route is unverified. 16. 3D Solids (5 tools, opt-in via `ENABLE_3D=true`, COM only): `solid_box/cylinder/extrude/revolve/boolean` — hidden from discovery and rejected while disabled; ezdxf reports `solid_3d` as unsupported (no headless ACIS). -**Tool profiles:** `TOOL_PROFILE=lean|core|full` (default `full`) controls the advertised surface — `lean` ≈ 46 curated drafting tools, `core` hides raw escape hatches (`system_run_command/lisp`, low-level variables, long-tail tools). Applied in the lifespan; reported by `system_about`. +**Tool profiles:** `TOOL_PROFILE=lean|full` (default `full`) controls the advertised surface — `lean` ≈ 47 curated drafting tools (including `cad_batch`, since a client with a tight tool cap is exactly the client paying most per turn). Applied in the lifespan; reported by `system_about`. The `core` profile was removed in v1.5.0 (the discovery layer replaced it); `TOOL_PROFILE=core` falls back to `full` with a warning. + +**Tool discovery:** `DISCOVERY_MODE=off|search` (default `off`). `search` replaces the advertised catalog with `search_tools` + `call_tool`, ranking hits over an AutoCAD command / synonym corpus (`discovery/aliases.py`) layered on fastmcp's BM25 index. **ISO 129 tolerances:** `dimension_linear` / `dimension_radius` / `dimension_diameter` take `tol_upper` / `tol_lower` / `tol_mode` (`symmetric` ± / `deviation` +a/-b / `limit` / `basic`) and `text_override` (e.g. `⌀20 H7`). Prefer these over hand-drawn tolerance text. @@ -95,9 +99,13 @@ Tools are organized into sections (counts are indicative — `system_about` is a **Drawing score:** `drawing_finalize` returns `payload["score"]` — a 0-100 scalar + `invalidity_ratio` + A-F `grade` over the validator + critique union. Use it as the objective quality metric. +**Measuring:** never read vertices back and shoelace them — that loses 28.2% of the area on a semicircular edge, silently. `analysis_measure_entity(handle)` reads the real geometry, and its payload states its own accuracy: `exact`, `flatten_tolerance`, `assumed_closed`, `self_intersecting`. A HATCH reports the area it *fills* (outer loops minus islands) plus `hatch_style`; on a curved hatch edge `flatten_tolerance` is not the accuracy knob, because ezdxf hands boundaries over as cubic Beziers whose ~0.028% circle error is already baked in. `boundary_trace` / `boundary_from_entities` report the same number `analysis_measure_entity` gives for the polyline they just drew — if those ever diverge, one of them is approximating and neither says so. + +**Benchmark matrix:** `benchmarks/tasks_v3.py` is the current set (15 tasks; `--matrix v2` reproduces the v1.4 ten). The published competitor reports are pinned v1.4 runs against v2 and are **never** back-filled for the five v3 tasks — the chart shows them `not run`, not zero. `benchmarks/correctness_suite.py` (24 checks) is the release A/B gate: `python benchmarks/compare_versions.py v1.4.0`. + ### Adding a New Tool -1. Add the abstract method to `AutoCADBackend` in `backends/base.py` +1. Add the method to the matching module in `backends/contracts/` — `@abstractmethod` if both engines must have it, `@capability("key", reason=...)` only if it is a real engine boundary (and then declare `key` in both capability maps, or the gate fails) 2. Implement it in `EzdxfBackend` (`backends/ezdxf_backend.py`) using the `_async(func)` wrapper 3. Implement it in `ComBackend` (`backends/com_backend.py`) using `self._com(func)` 4. Register the tool in `server.py` with `@mcp.tool(...)` calling `_backend(ctx).your_method(...)` @@ -107,6 +115,12 @@ Tools are organized into sections (counts are indicative — `system_about` is a - **Entity handles**: hex strings (e.g. `"1A2B"`). Always returned from create/copy operations; required by all modify/query operations. - **Coordinates**: drawing units (mm by default); angles in degrees, counter-clockwise from X axis. + **Every coordinate in or out of a tool is WCS on both engines.** DXF stores + CIRCLE/ARC/LWPOLYLINE/TEXT/INSERT geometry in a per-entity frame (`extrusion`); + `backends/ocs.py` translates at the backend boundary, so OCS never reaches a + tool. An entity in a plane tilted out of WCS XY reports `plane_normal` and omits + the fields xy cannot express; writing an xy into such an entity is refused with + `capability: "ocs_tilted_plane"` — use `entity_move` instead. - **ACI colors**: 1–255 for specific colors, 256=ByLayer, 0=ByBlock. - **COM backend only**: `system_run_command`, `system_run_lisp`, and `view_zoom_extents/window` (view ops are no-ops in ezdxf). - **Screenshot**: COM uses Win32 window capture (Pillow required); ezdxf renders via matplotlib. diff --git a/Dockerfile b/Dockerfile index 814afc7..aac0f9b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,6 +8,7 @@ COPY pyproject.toml README.md LICENSE ./ COPY server.py config.py security.py version.py ./ COPY backends/ ./backends/ COPY engineering/ ./engineering/ +COPY discovery/ ./discovery/ RUN pip install --no-cache-dir -e . diff --git a/README.md b/README.md index 642944f..52dd9cf 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,8 @@ query exact geometry, apply engineering standards (ISO 128/129/286/1101), refine quality issues inside transactions, and deliver hashed artifacts with validation evidence. -> **v1.4 release snapshot:** 131 tools · 6 resources · 5 prompt templates · -> 474 collected tests. Runtime discovery through `system_about` is authoritative. +> **v1.5 release snapshot:** 154 tools · 6 resources · 5 prompt templates · +> 1242 collected tests. Runtime discovery through `system_about` is authoritative. ## Install in 10 seconds @@ -68,6 +68,12 @@ locally executed evidence, **B** = deep source review without a full live run, **C** = documented tool surface only. This is a source review, not a shared live run — the lanes below are the runtime evidence. +**The table above is not re-scored per release.** It reads the dated review, +and this repository's row is still the one written against v1.4.0. Raising our +own number for v1.5.0 would mean re-reviewing eight other projects on the same +day to keep the comparison fair, and that has not been done — so the score +stays where the evidence is. + ```bash python -m benchmarks.render_chart ``` @@ -76,23 +82,32 @@ python -m benchmarks.render_chart ![Live benchmark](docs/assets/autocad-mcp-livebench.svg) -| Server | Pinned commit | Score | Pass | Coverage | -|---|---|---:|---:|---:| -| **autocad-mcp-pro** (this repo) | working tree | **100.0** | 10/10 | 100% | -| [beiming183-cloud/AutoCAD-MCP](https://github.com/beiming183-cloud/AutoCAD-MCP) | `11f7c47` | 50.0 | 5/10 | 50% | -| [puran-water/autocad-mcp](https://github.com/puran-water/autocad-mcp) | `95476a3` | 45.0 | 4/10 | 50% | +| Server | Matrix | Pinned commit | Score | Pass | Coverage | +|---|---|---|---:|---:|---:| +| **autocad-mcp-pro** (this repo) | v3 (15 tasks) | working tree | **100.0** | 15/15 | 100% | +| [beiming183-cloud/AutoCAD-MCP](https://github.com/beiming183-cloud/AutoCAD-MCP) | v2 (10 tasks) | `11f7c47` | 50.0 | 5/10 | 50% | +| [puran-water/autocad-mcp](https://github.com/puran-water/autocad-mcp) | v2 (10 tasks) | `95476a3` | 45.0 | 4/10 | 50% | -Ten fixed tasks, one runner, headless ezdxf lane. Competitors are driven -**black-box over MCP stdio** using their own documented tool contracts at the -pinned commit, and every geometry claim is verified by re-opening the exported -DXF with ezdxf inside the harness — no self-reporting. `unsupported` scores -zero in the fixed matrix; per-task reasons are committed under +**Read the matrix column before the score column.** The five v3 tasks are new +in v1.5.0; the competitor runs are pinned at v1.4 and were never put to them. +Their scores are over the ten tasks they actually ran, and the five new rows +are left blank in the chart rather than scored zero — an invented zero is +indistinguishable from a measured one. + +One runner, headless ezdxf lane. Competitors are driven **black-box over MCP +stdio** using their own documented tool contracts at the pinned commit, and +every geometry claim is verified by re-opening the exported DXF with ezdxf +inside the harness — no self-reporting. `unsupported` scores zero within a +server's own matrix; a capability refusal is recorded as `unsupported` rather +than `fail`, because "cannot reach this" and "got this wrong" are different +findings. Per-task reasons are committed under [`benchmarks/results/published/`](benchmarks/results/published/). File-IPC (live AutoCAD) lanes run locally, not in CI. ```bash -python -m benchmarks.run_competitors --server puran-water-autocad-mcp --backend ezdxf -python -m benchmarks.run_competitors --server beiming183-autocad-mcp --backend ezdxf +python -m benchmarks.run_competitors --list --matrix v3 +python -m benchmarks.run_competitors --server autocad-mcp-pro --matrix v3 \ + --publish benchmarks/results/published/autocad-mcp-pro.json python -m benchmarks.render_live_chart ``` @@ -103,7 +118,34 @@ python -m benchmarks.render_live_chart The single score hides *which* capabilities exist, so the matrix shows every task against every server: green = verified pass, amber = partial, gray = the server documents no equivalent (planning, refinement, TABLE/MLEADER, hashed -delivery), red = attempted but failed verification. +delivery), red = attempted but failed verification, blank = never run against +that server. + +The v2 matrix scored this repository 10/10, and that was not a good sign — +every task in it exercised something the server was built around, so full +marks were the only possible outcome. **v3 adds five tasks chosen because they +can fail**, and three of them did fail while being written: a hatch could not +be measured at all, the boundary tools disagreed with the measurement tool +about the same shape, and a circle stored as two bulged vertices measured zero. + +| v3 task | What it pins | Verified against | +|---|---|---| +| `tool_discovery` | six AutoCAD command names resolve to the right tool | all six rank #1 of a 154-tool catalog | +| `token_budget` | idle catalog cost in discovery mode | 40,305 → 356 tokens, ceiling 2,000 | +| `hatch_islands` | a hatch reports the area it *fills* | 300 with the island, 400 ignoring it | +| `selection_filter` | window ≠ crossing, polygon ≠ bounding box | 1 / 2 / 3 / 1 on placed geometry | +| `measure_from_handle` | curved boundaries measured by handle | 139.2699 against a 100.0 vertex shoelace | + +Token figures come from the offline ratio estimator, not a tokenizer — they are +estimates and the report labels them so. `benchmarks/token_suite.py +--tokenizer anthropic` counts for real. + +Two tasks are renamed from the release plan, and the renames are the point. +`tool_discovery_bilingual` lost its "bilingual": a Turkish query normalizer was +written and then removed, because this repository is English throughout and the +Turkish was inferred from the author's chat language rather than required by +the product. `measurement_massprops` became `measure_from_handle`: no centroid +or moment-of-inertia tool ships. ```bash python -m benchmarks.render_matrix_chart @@ -115,10 +157,31 @@ python -m benchmarks.render_matrix_chart | Workload | Wall time | Throughput / result | |---|---:|---| -| Create 2,000 lines (individual calls) | 0.64 s | ~3,100 entities/s | -| 10,000 lines: build + DXF export + reopen | 13.9 s | ~700 entities/s end-to-end | -| Region query over 10,000 entities | 3.4 s | 2,500 matched | -| Premium pass (layers + part + dims + full critique) | 0.32 s | 0 issues | +| Create 2,000 lines (individual calls) | 0.32 s | ~6,300 entities/s | +| 10,000 lines: build + DXF export + reopen | 2.16 s | ~4,600 entities/s end-to-end | +| Region query over 10,000 entities | 0.89 s | 2,500 matched | +| Premium pass (layers + part + dims + full critique) | 0.18 s | 0 issues | + +These are roughly 2–6× the numbers the v1.4 README printed, and **almost none +of that is this release**: the old report was recorded on CPython 3.14 and this +one on 3.11.15. Interpreter, not code. Re-measuring v1.4.0 in a worktree on the +*same* interpreter with the *same* suite file gives the honest comparison, and +it does not flatter us — median of three runs each: + +| Workload | v1.4.0 | v1.5.0 | Change | +|---|---:|---:|---| +| Create 2,000 lines | 245 ms | 315 ms | **28% slower** | +| 10,000-line roundtrip | 1,765 ms | 2,146 ms | **22% slower** | +| Region query over 10,000 | 1,090 ms | 1,039 ms | within run-to-run spread | +| Premium pass | 375 ms | 44 ms | faster, but the v1.4 spread (279–772 ms) is too wide to put a number on | + +The regression is fully attributed: setting `EZDXF_CALL_TIMEOUT=0` returns +v1.5.0 to **244.8 ms** and **1,679 ms** — v1.4.0's numbers exactly. The whole +cost is the per-call `asyncio.wait_for` this release wrapped around every +headless call, so that one hung ezdxf call can no longer wedge a server whose +document lock is a single `asyncio.Lock`. That is a deliberate trade of ~25% of +creation throughput for a deadlock that used to be unrecoverable, and the knob +to take it back is documented rather than hidden. Workloads call the same backend methods the MCP tools call, so server-side overhead is included. Self-measurement only — competitor servers would pay an @@ -133,21 +196,27 @@ python -m benchmarks.render_perf_chart ### 4 · Version A/B — every release re-proves correctness -`compare_versions.py` runs the same 21 deterministic, headless correctness +`compare_versions.py` runs the same 24 deterministic, headless correctness checks against the previous release tag and the current tree — each check in its own subprocess, so a hard crash counts as a miss instead of killing the run. -**v1.4.0 release gate** (baseline `v1.3.0`, report: -[`ab-v1.3.0-vs-v1.4.0.json`](benchmarks/results/published/ab-v1.3.0-vs-v1.4.0.json)): +**v1.5.0 release gate** (baseline `v1.4.0`, report: +[`ab-v1.4.0-vs-v1.5.0.json`](benchmarks/results/published/ab-v1.4.0-vs-v1.5.0.json)): | Version | Checks passing | Pass rate | Fixed | Regressed | |---|---:|---:|---:|---:| -| v1.3.0 (baseline) | 21 / 21 | 100 % | — | — | -| **v1.4.0** (this release) | **21 / 21** | **100 %** | 0 | **0** | +| v1.4.0 (baseline) | 21 / 24 | 87.5 % | — | — | +| **v1.5.0** (this release) | **24 / 24** | **100 %** | 3 | **0** | + +The three the baseline misses are *new capability, not repaired regressions*: +v1.4.0 has no `entity_measure` and no boundary tracing, so it misses them by +not having the method. They are in the suite because all three broke during +v1.5.0 development and nothing outside their own unit tests would have caught +it. The 21 pre-existing checks are **unchanged and all still pass** — the whole +of the discovery layer, the contract split, the layout/viewport family and Wave +A landed with **zero correctness regressions**. -v1.4.0 added CI, packaging, benchmarks, tool profiles, paper space, ISO 286 -fits and opt-in 3D solids **without breaking a single correctness check**. For contrast, the same suite caught the v1.0.0 → v1.1.0 jump: | Version | Checks passing | Pass rate | @@ -156,25 +225,40 @@ For contrast, the same suite caught the v1.0.0 → v1.1.0 jump: | v1.1.0 | 21 / 21 | 100 % | ```bash -python benchmarks/compare_versions.py v1.3.0 --json ab.json +python benchmarks/compare_versions.py v1.4.0 --json ab.json ``` Full rubric, caveats, method and boundaries for every lane: [`benchmarks/README.md`](benchmarks/README.md). -## What ships in v1.4 +## What ships in v1.5 | Area | Tools | |---|---| -| Drawing lifecycle | create, open, save, export DXF/PDF, audit, purge, undo/redo | +| Drawing lifecycle | create, open, save, export DXF/PDF, audit **(now repairs)**, purge, undo/redo | | Geometry | lines, arcs, polylines, splines, hatches, trim/extend/fillet/chamfer, handle-preserving edits | -| Annotation | ISO 129 toleranced dimensions, **ISO 286 fits** (`fit="H7"`), TABLE, MLEADER, GD&T frames + datums (ISO 1101) | +| Annotation | ISO 129 toleranced dimensions, ISO 286 fits (`fit="H7"`), TABLE, MLEADER, GD&T frames + datums (ISO 1101) | | Engineering generators | involute gears (front + section A-A), DIN 6885 keyed bores, ISO A3 titleblock | -| **Paper space** *(new)* | `layout_list/create/set_current`, scaled `viewport_create`, `drawing_export_pdf(layout=...)` | -| **3D solids** *(new, opt-in)* | `solid_box/cylinder/extrude/revolve/boolean` on live AutoCAD (`ENABLE_3D=true`) | +| **Discovery** *(new)* | `DISCOVERY_MODE=search` replaces the catalog with `search_tools` + `call_tool`, ranked over an AutoCAD command and synonym corpus — `FILLET`, `BPOLY`, `QSELECT`, `WBLOCK` score nothing against a stock index because the words are simply not in the data | +| **Batching** *(new)* | `cad_batch` runs a whole step list in one round trip; `fields=` projection trims result-heavy responses | +| **Paper space** *(new)* | full tab lifecycle (`layout_list/create/set_current/delete/rename/copy`), viewports (`viewport_create/list/set_scale/lock/delete`), `entity_change_space` (CHSPACE), `drawing_export_pdf(layout=...)` | +| **Selection** *(new)* | `selection_window` / `selection_polygon` / `selection_filter` — window vs crossing stated back to the caller, and a polygon tested against its own shape rather than its bounding box | +| **Boundaries** *(new)* | `boundary_trace` (BOUNDARY/BPOLY) and `boundary_from_entities` chain loose edges into one closed polyline, arcs kept as bulges | +| **Measurement** *(new)* | `analysis_measure_entity` measures what is *in* the drawing by handle — reading vertices back and shoelacing them loses 28.2% of the area on a semicircular edge | +| **Hatch depth** *(new)* | gradients, in-place edits, typed edge boundaries, island styles | +| **Annotation objects** *(new)* | WIPEOUT, REVCLOUD, MTEXT background masks, text find/replace | +| **3D solids** *(opt-in)* | `solid_box/cylinder/extrude/revolve/boolean` on live AutoCAD (`ENABLE_3D=true`) | | Quality loop | `drawing_preflight` → `drawing_plan` → `drawing_critique` → `drawing_refine` → `drawing_finalize` (0-100 score) | | Delivery | `drawing_deliver`: DXF/PDF/PNG + SHA-256 manifest + reopen-parity checks | -| Discovery | **tool profiles** *(new)*: `TOOL_PROFILE=lean` (~46 tools) / `core` / `full` for clients with tight tool caps | +| Profiles | `TOOL_PROFILE=lean` (~47 curated tools) for clients with tight tool caps; `core` was removed — the discovery layer replaced it | + +**Every coordinate in and out of a tool is WCS, on both engines.** DXF stores +CIRCLE / ARC / LWPOLYLINE / TEXT / INSERT geometry in a per-entity frame, and +before v1.5.0 the word `extrusion` did not appear anywhere in this codebase — +so a mirrored or rotated entity reported coordinates that were quietly wrong. +`backends/ocs.py` now translates at the backend boundary; an entity in a plane +tilted out of WCS XY reports `plane_normal` and omits the fields xy cannot +express, rather than answering with a number that looks fine. The tool surface evolves — ask `system_about` for the live grouped inventory and `system_capabilities` for per-backend support modes instead of copying @@ -196,7 +280,9 @@ flowchart LR ``` - `server.py` — FastMCP surface, lifespan, middleware, resources, prompts. -- `backends/base.py` — shared typed contract + capability model. +- `backends/contracts/` — the typed contract, one module per domain. +- `backends/capability.py` — capability model, typed refusals, `@capability`. +- `backends/base.py` — shared data models + the contract composition. - `backends/com_backend.py` — single-STA-thread COM executor with per-call timeouts. - `backends/ezdxf_backend.py` — `asyncio.to_thread`-wrapped DXF engine with @@ -214,7 +300,14 @@ flowchart LR | Cross-platform execution | — | ✓ | | Transactions and rollback | ✓ | ✓ | | Paper-space layouts + viewports | ✓ | ✓ | -| Viewport model-content rendering | ✓ | — | +| Viewport model-content rendering | ✓ | ✓ (no viewport borders) | +| CHSPACE (`entity_change_space`) | — (no ActiveX member) | ✓ | +| WIPEOUT / REVCLOUD | — (no ActiveX member) | ✓ | +| Boundary tracing (BPOLY) | — (no ActiveX member) | ✓ | +| Selection window / crossing / polygon | ✓ | ✓ | +| Entity area by handle | ActiveX `.Area` | Analytic, bulges included | +| HATCH filled area (islands subtracted) | AutoCAD's own number | Loops walked, `hatch_style` reported | +| REGION / 3DSOLID area | ✓ | — (ACIS is opaque to ezdxf) | | TABLE and MLEADER semantics | Native | Portable composite | | 3D solids (opt-in) | ✓ | — (no headless ACIS) | | Screenshots | AutoCAD window | Matplotlib render | @@ -270,25 +363,49 @@ explicitly enabled **and** a bearer token is configured. | Variable | Default | Purpose | |---|---|---| | `AUTOCAD_MCP_BACKEND` | `auto` | `auto`, `com`, or `ezdxf` | -| `TOOL_PROFILE` | `full` | `lean` (~46 curated tools), `core` (hides escape hatches), `full` | +| `CAD_PROGID` | `AutoCAD.Application` | COM ProgID the live backend attaches to (see below) | +| `TOOL_PROFILE` | `full` | `lean` (~47 curated tools) or `full` | +| `DISCOVERY_MODE` | `off` | `search` replaces the catalog with `search_tools` + `call_tool` | | `ENABLE_3D` | `false` | Expose the opt-in `solid_*` tools (COM backend) | | `LOG_LEVEL` | `INFO` | Python logging level | | `ALLOWED_PATHS` | empty | Comma-separated absolute paths the server may access | | `MAX_UNDO_STACK` | `5` | Maximum retained undo snapshots | | `MAX_DXF_BYTES` | `52428800` | Reject larger DXF input; `0` disables | | `MAX_LIST_LIMIT` | `5000` | Bound list/selection response sizes | -| `COM_CALL_TIMEOUT` | `60` | Per-call live AutoCAD timeout (seconds) | +| `COM_CALL_TIMEOUT` | `60` | Per-call live AutoCAD timeout (seconds); `0` disables | +| `EZDXF_CALL_TIMEOUT` | `120` | Per-call headless ezdxf timeout (seconds); `0` disables | | `DANGEROUS_COMMANDS_ENABLED` | `false` | Allow blocked commands/LISP; reported as unsafe mode | | `ALLOW_REMOTE_HTTP` | `false` | Permit a non-loopback HTTP bind | | `MCP_AUTH_TOKEN` | empty | Bearer token required for remote HTTP | +`CAD_PROGID` only changes which COM application the backend attaches to — every +tool beyond the connection itself is developed and tested against AutoCAD alone, +and AutoCAD-clone compatibility is unverified, so treat a non-default ProgID as +experimental. Candidates such as `BricscadApp.AcadApplication`, +`ZWCAD.Application` and `GstarCAD.Application` are listed in `.env.example` +commented out and marked UNVERIFIED: none has been tested against a seat. An +unrecognised ProgID fails loudly rather than falling back, because the fallback +path COM-launches the application and would start the very AutoCAD you said you +were not using. + ## Security model Tool input is treated as untrusted: -- every path passes traversal + allowed-root validation; -- dangerous AutoCAD commands and AutoLISP channels are blocked by default, - with regression tests for known bypass patterns; +- every path passes traversal + allowed-root validation, and non-local anchors + (`\\?\`, UNC, `\\localhost\C$`) are rejected in every spelling — the + forward-slash forms used to normalise past the pattern check and then compare + unequal to the system-directory list; +- `system_run_command` / `system_run_lisp` are free-text escape hatches into the + live AutoCAD command line. A **verb** denylist (the first token of each macro + segment, decorators stripped) refuses the obviously destructive ones by + default, and quoted AutoLISP string literals are treated as data so drawing + text is not mistaken for code. Treat this as a guardrail against an agent + accidentally issuing `ERASE ALL` — **not** as a boundary against a hostile + client. AutoCAD accepts hundreds of commands and any loaded ARX/LISP adds + more, so the list cannot be complete. The real boundaries are `ALLOWED_PATHS`, + not exposing the server over the network, and preferring the typed tools + (which validate every argument) over raw commands; - remote HTTP requires explicit opt-in plus a bearer token; - DXF size, response sizes, undo history and COM call duration are bounded; - audit middleware records tool timing and failures; @@ -300,18 +417,35 @@ rather than public issues. ## Development +`uv.lock` pins the whole transitive graph — runtime *and* dev tools — to the +set the suite was last run against, so this reproduces the CI environment +exactly rather than approximately: + ```bash -pip install -e ".[full]" -pip install pytest pytest-asyncio pytest-cov ruff build -python -m pytest # 474 tests -python -m ruff check . && python -m ruff format --check . -python -m build +uv sync --locked --all-extras +uv run pytest +uv run ruff check . && uv run ruff format --check . ``` -CI runs the same gates on Linux (3.11/3.12), Windows (mocked-COM suite), -plus package, Docker and MCP-registry-schema jobs. Releases are tag-driven: -`git tag vX.Y.Z && git push origin vX.Y.Z` builds, publishes to PyPI and cuts -the GitHub Release. See +Without uv, from the declared dependency ranges (needs pip ≥ 25.1 for +`--group`; resolves fresh, so it can differ from CI): + +```bash +pip install -e ".[full]" +pip install --group dev build +python -m pytest && python -m build +``` + +CI installs with `uv sync --locked`, which fails rather than re-resolving, so a +fresh run cannot silently pick up an untested upstream — run `uv lock` after +changing dependencies or the lint job will reject the drift. The gates run on +Linux (3.11/3.12), Windows (mocked-COM suite), plus package, Docker and +MCP-registry-schema jobs; the package job deliberately stays *unlocked*, +because it is the one that proves a plain `pip install autocad-mcp-pro` still +resolves. A scheduled `deps-floating` workflow resolves the ranges fresh and +re-runs the suite, so a locked CI does not become blindness to upstream drift. +Releases are tag-driven: `git tag vX.Y.Z && git push origin vX.Y.Z` builds, +publishes to PyPI and cuts the GitHub Release. See [`docs/RELEASE-DISTRIBUTION.md`](docs/RELEASE-DISTRIBUTION.md). Repository map: @@ -321,20 +455,37 @@ server.py FastMCP surface and orchestration config.py Environment-driven settings security.py Path, command, and AutoLISP guards version.py Canonical package version +uv.lock Exact dependency graph CI installs from backends/ COM and ezdxf implementations engineering/ Standards, generators, critique, fits, delivery benchmarks/ Runners, adapters, published reports, chart renderers tests/ Headless, mocked-COM, contract, and security tests ``` -## Roadmap (1.5) +## Roadmap (1.6) -Screenshot overlay + handle grounding, ezdxf redo (forward snapshots), COM -`block_create_from_entities`, ISO 286 transition/interference hole letters -(delta rule), titleblock on paper-space layouts. Features are published when +**Give back the 25% creation throughput** the per-call ezdxf timeout costs +(measured and attributed above) — arm it only for calls that can actually block, +or find a deadline cheaper than an `asyncio.wait_for` per call. ISO 286 +interference **shafts** r/s/t/u (they need sub-stepped table data nothing in the +module can derive, and guessing them would put wrong tolerances on a production +drawing — so 1.5 refuses them by name instead). HATCH boundary *geometry* in +`entity_get` — 1.5 measures a hatch's filled area but still does not hand back +its loops. REGION/3DSOLID area on the headless engine, which needs a modelling +kernel ezdxf does not have. An *allowlist* of permitted AutoLISP heads, +replacing the denylist — enumerating dangerous symbols does not terminate. +`ezdxf.recover` as a fallback on `drawing_open`. Features are published when their contracts and limitations are testable — not when they make a longer checklist. +Cut from 1.5 on evidence, not on time: Wave A shipped 13 of a planned 39 tools. +The 2D-boolean and REGION family went because `add_region()` produces a REGION +with zero ACIS bytes and the `greiner_hormann` substitute loses **28.2% of the +area** on a square with one semicircular edge; most of the planned measurement +family was already covered by the eight `analysis_*` tools that existed. The +full cut list and its measurements are in +[`CHANGELOG.md`](CHANGELOG.md). + ## Contributing Pull requests are welcome — especially reproducible competitor adapters, diff --git a/backends/base.py b/backends/base.py index 0d5a63a..0adbddc 100644 --- a/backends/base.py +++ b/backends/base.py @@ -1,22 +1,52 @@ -"""Abstract base class + shared data models for AutoCAD backends.""" +"""Abstract base class + shared data models for AutoCAD backends. + +The interface itself lives in :mod:`backends.contracts`, one module per domain; +this module owns the shared data models, the refusal vocabulary, and the +composition that ties the contracts together. Callers import +``AutoCADBackend`` from here exactly as before. +""" from __future__ import annotations import math -from abc import ABC, abstractmethod from dataclasses import dataclass, field -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING + +from backends.capability import ( # noqa: F401 — deliberate re-export, see below + UnsupportedCapabilityError, + _unsupported, +) +from backends.contracts import ( + AnalysisContract, + BlockContract, + CornerOpsContract, + DimensionContract, + DrawingContract, + EntityCreationContract, + EntityModificationContract, + EntityQueryContract, + GdtContract, + IdentityContract, + LayerContract, + LayoutContract, + LinetypeContract, + PremiumContract, + SettingsContract, + SolidContract, + SystemContract, + TransactionContract, + ViewContract, +) if TYPE_CHECKING: - from engineering.plan_spec import ( - CritiqueFocus, - DimStyle, - Issue, - LayerSetId, - PlanSpec, - SheetSize, - SnapType, - ) + pass + +# The refusal vocabulary lives in ``backends.capability`` — the contracts need +# it and this module imports the contracts, so defining it here would be a +# cycle. Re-exported so every existing +# ``from backends.base import UnsupportedCapabilityError`` keeps working and +# there is still exactly one class object in play. + # --------------------------------------------------------------------------- # Data models @@ -134,1742 +164,34 @@ def normalize_lineweight(lw: float | int | None) -> int | None: # --------------------------------------------------------------------------- -class AutoCADBackend(ABC): - """All backends implement this interface.""" - - # ── identity ──────────────────────────────────────────────────────────── - @property - @abstractmethod - def name(self) -> str: ... - - @property - @abstractmethod - def is_connected(self) -> bool: ... - - @abstractmethod - async def connect(self) -> None: ... - - @abstractmethod - async def disconnect(self) -> None: ... - - @abstractmethod - def capabilities(self) -> CapabilityMap: ... - - # ── drawing management ─────────────────────────────────────────────────── - @abstractmethod - async def drawing_info(self) -> DrawingInfo: ... - - @abstractmethod - async def drawing_new(self, template: str | None = None) -> dict: ... - - @abstractmethod - async def drawing_open(self, path: str) -> dict: ... - - @abstractmethod - async def drawing_save(self, path: str | None = None) -> dict: ... - - @abstractmethod - async def drawing_save_as(self, path: str, fmt: str = "dwg") -> dict: ... - - @abstractmethod - async def drawing_export_dxf(self, path: str) -> dict: ... - - @abstractmethod - async def drawing_export_pdf(self, path: str, layout: str | None = None) -> dict: ... - - @abstractmethod - async def drawing_purge(self) -> dict: ... - - # ── layouts / paper space ──────────────────────────────────────────────── - @abstractmethod - async def layout_list(self) -> dict: - """Return {ok, layouts: [names], current: name} incl. 'Model'.""" - ... - - @abstractmethod - async def layout_create(self, name: str) -> dict: - """Create a new paper-space layout.""" - ... - - @abstractmethod - async def layout_set_current(self, name: str) -> dict: - """Activate a layout tab ('Model' or a paper-space layout name).""" - ... - - @abstractmethod - async def viewport_create( - self, - layout: str, - center_x: float, - center_y: float, - width: float, - height: float, - view_center_x: float, - view_center_y: float, - scale: float = 1.0, - ) -> dict: - """Place a scaled model-space viewport on a paper-space layout. - - ``scale`` is paper:model (1.0 = 1:1, 0.5 = 1:2). The viewport shows - the model window centered at (view_center_x, view_center_y) with - view height = height / scale. - """ - ... - - # ── 3D solids (opt-in; native on live AutoCAD only) ───────────────────── - @abstractmethod - async def solid_box( - self, cx: float, cy: float, cz: float, length: float, width: float, height: float - ) -> dict: - """Create a 3D solid box centered at (cx, cy, cz).""" - ... - - @abstractmethod - async def solid_cylinder( - self, cx: float, cy: float, cz: float, radius: float, height: float - ) -> dict: - """Create a 3D solid cylinder centered at (cx, cy, cz).""" - ... - - @abstractmethod - async def solid_extrude( - self, profile_handle: str, height: float, taper_angle: float = 0.0 - ) -> dict: - """Extrude a closed profile (circle/closed polyline) into a solid.""" - ... - - @abstractmethod - async def solid_revolve( - self, - profile_handle: str, - axis_x1: float, - axis_y1: float, - axis_x2: float, - axis_y2: float, - angle: float = 360.0, - ) -> dict: - """Revolve a closed profile around an axis into a solid.""" - ... - - @abstractmethod - async def solid_boolean(self, target_handle: str, tool_handle: str, operation: str) -> dict: - """Boolean two solids: operation is union | subtract | intersect.""" - ... - - @abstractmethod - async def drawing_audit(self) -> dict: ... - - @abstractmethod - async def drawing_close(self, save: bool = True) -> dict: ... - - @abstractmethod - async def drawing_undo(self) -> dict: ... - - @abstractmethod - async def drawing_redo(self) -> dict: ... - - # ── entity creation ────────────────────────────────────────────────────── - @abstractmethod - async def entity_create_line( - self, - x1: float, - y1: float, - x2: float, - y2: float, - z1: float = 0.0, - z2: float = 0.0, - layer: str | None = None, - color: int | None = None, - linetype: str | None = None, - ) -> EntityInfo: ... - - @abstractmethod - async def entity_create_circle( - self, - cx: float, - cy: float, - radius: float, - layer: str | None = None, - color: int | None = None, - ) -> EntityInfo: ... - - @abstractmethod - async def entity_create_arc( - self, - cx: float, - cy: float, - radius: float, - start_angle: float, - end_angle: float, - layer: str | None = None, - color: int | None = None, - ) -> EntityInfo: ... - - @abstractmethod - async def entity_create_polyline( - self, - points: list[list[float]], - closed: bool = False, - layer: str | None = None, - color: int | None = None, - ) -> EntityInfo: ... - - @abstractmethod - async def entity_create_text( - self, - text: str, - x: float, - y: float, - height: float = 2.5, - rotation: float = 0.0, - layer: str | None = None, - color: int | None = None, - ) -> EntityInfo: ... - - @abstractmethod - async def entity_create_mtext( - self, - text: str, - x: float, - y: float, - width: float = 100.0, - height: float = 2.5, - rotation: float = 0.0, - layer: str | None = None, - color: int | None = None, - ) -> EntityInfo: ... - - @abstractmethod - async def entity_create_table( - self, - x: float, - y: float, - rows: list[list[str]], - headers: list[str] | None = None, - column_widths: list[float] | None = None, - row_height: float = 7.0, - text_height: float = 2.5, - title: str | None = None, - layer: str = "TEXT", - ) -> EntityInfo: ... - - @abstractmethod - async def leader_create_mleader( - self, - points: list[list[float]], - text: str, - text_height: float = 2.5, - landing_gap: float = 1.0, - arrow_size: float = 2.5, - layer: str = "DIM", - ) -> EntityInfo: ... - - @abstractmethod - async def entity_create_hatch( - self, - pattern: str, - boundary_points: list[list[float]], - scale: float = 1.0, - angle: float = 0.0, - layer: str | None = None, - color: int | None = None, - ) -> EntityInfo: ... - - @abstractmethod - async def entity_create_spline( - self, - fit_points: list[list[float]], - layer: str | None = None, - color: int | None = None, - ) -> EntityInfo: ... - - @abstractmethod - async def entity_create_ellipse( - self, - cx: float, - cy: float, - major_x: float, - major_y: float, - ratio: float = 0.5, - layer: str | None = None, - color: int | None = None, - ) -> EntityInfo: ... - - @abstractmethod - async def entity_create_point( - self, - x: float, - y: float, - layer: str | None = None, - color: int | None = None, - ) -> EntityInfo: ... - - @abstractmethod - async def entity_create_block_ref( - self, - name: str, - x: float, - y: float, - scale_x: float = 1.0, - scale_y: float = 1.0, - rotation: float = 0.0, - layer: str | None = None, - ) -> EntityInfo: ... - - # ── dimensions ─────────────────────────────────────────────────────────── - @abstractmethod - async def dimension_linear( - self, - x1: float, - y1: float, - x2: float, - y2: float, - dim_x: float, - dim_y: float, - rotation: float = 0.0, - layer: str | None = None, - tol_upper: float | None = None, - tol_lower: float | None = None, - tol_mode: str = "none", - text_override: str | None = None, - ) -> EntityInfo: ... - - @abstractmethod - async def dimension_aligned( - self, - x1: float, - y1: float, - x2: float, - y2: float, - dim_x: float, - dim_y: float, - layer: str | None = None, - ) -> EntityInfo: ... - - @abstractmethod - async def dimension_angular( - self, - vx: float, - vy: float, - x1: float, - y1: float, - x2: float, - y2: float, - tx: float, - ty: float, - layer: str | None = None, - ) -> EntityInfo: ... - - @abstractmethod - async def dimension_radius( - self, - cx: float, - cy: float, - chord_x: float, - chord_y: float, - leader_length: float = 10.0, - layer: str | None = None, - tol_upper: float | None = None, - tol_lower: float | None = None, - tol_mode: str = "none", - text_override: str | None = None, - ) -> EntityInfo: ... - - @abstractmethod - async def dimension_diameter( - self, - x1: float, - y1: float, - x2: float, - y2: float, - leader_length: float = 10.0, - layer: str | None = None, - tol_upper: float | None = None, - tol_lower: float | None = None, - tol_mode: str = "none", - text_override: str | None = None, - ) -> EntityInfo: ... - - # ── entity modification ────────────────────────────────────────────────── - @abstractmethod - async def entity_move(self, handle: str, dx: float, dy: float, dz: float = 0.0) -> dict: ... - - @abstractmethod - async def entity_copy( - self, handle: str, dx: float, dy: float, dz: float = 0.0 - ) -> EntityInfo: ... - - @abstractmethod - async def entity_rotate( - self, - handle: str, - base_x: float, - base_y: float, - angle_deg: float, - ) -> dict: ... - - @abstractmethod - async def entity_scale( - self, - handle: str, - base_x: float, - base_y: float, - factor: float, - ) -> dict: ... - - @abstractmethod - async def entity_mirror( - self, - handle: str, - x1: float, - y1: float, - x2: float, - y2: float, - delete_original: bool = False, - ) -> EntityInfo: ... - - @abstractmethod - async def entity_offset( - self, - handle: str, - distance: float, - side_x: float | None = None, - side_y: float | None = None, - ) -> EntityInfo: ... - - @abstractmethod - async def entity_delete(self, handle: str) -> dict: ... - - @abstractmethod - async def entity_array_rectangular( - self, - handle: str, - rows: int, - cols: int, - row_spacing: float, - col_spacing: float, - ) -> list[EntityInfo]: ... - - @abstractmethod - async def entity_array_polar( - self, - handle: str, - count: int, - fill_angle: float, - center_x: float, - center_y: float, - ) -> list[EntityInfo]: ... - - # ── corner operations (trim/extend/fillet/chamfer) ─────────────────────── - @abstractmethod - async def entity_trim( - self, - target_handle: str, - cutter_handle: str, - keep_x: float, - keep_y: float, - ) -> EntityInfo: - """Trim `target` against `cutter`; keep the segment containing - (keep_x, keep_y). Raises ToolError if no intersection.""" - ... - - @abstractmethod - async def entity_extend( - self, - target_handle: str, - boundary_handle: str, - end_x: float | None = None, - end_y: float | None = None, - ) -> EntityInfo: - """Extend `target` to meet `boundary`. If `end_x/y` is None, the - target endpoint nearest the boundary is auto-selected.""" - ... - - @abstractmethod - async def entity_fillet( - self, - handle1: str, - handle2: str, - radius: float, - trim: bool = True, - ) -> EntityInfo: - """Fillet two entities with the given radius. Returns the new ARC. - When `trim` is True (default), the source entities are shortened - to the tangent points (AutoCAD default behaviour).""" - ... - - @abstractmethod - async def entity_chamfer( - self, - handle1: str, - handle2: str, - dist1: float, - dist2: float | None = None, - trim: bool = True, - ) -> EntityInfo: - """Chamfer two entities. When `dist2` is None it defaults to `dist1` - (symmetric chamfer). Returns the new chamfer LINE.""" - ... - - # ── entity query/properties ─────────────────────────────────────────────── - @abstractmethod - async def entity_get(self, handle: str) -> EntityInfo: ... - - @abstractmethod - async def entity_set_properties( - self, - handle: str, - layer: str | None = None, - color: int | None = None, - linetype: str | None = None, - lineweight: float | None = None, - visible: bool | None = None, - ) -> dict: ... - - @abstractmethod - async def entity_edit_text( - self, - handle: str, - text: str | None = None, - height: float | None = None, - rotation: float | None = None, - ) -> EntityInfo: - """Edit an existing TEXT or MTEXT entity in place. Any argument left - None is unchanged. Returns the updated EntityInfo. Raises if the handle - is not a TEXT/MTEXT entity.""" - ... - - @abstractmethod - async def entity_edit_geometry( - self, - handle: str, - cx: float | None = None, - cy: float | None = None, - radius: float | None = None, - x1: float | None = None, - y1: float | None = None, - x2: float | None = None, - y2: float | None = None, - start_angle: float | None = None, - end_angle: float | None = None, - ) -> EntityInfo: - """Edit the defining geometry of an existing entity in place. - - CIRCLE: cx / cy / radius - - LINE: x1 / y1 (start), x2 / y2 (end) - - ARC: cx / cy / radius / start_angle / end_angle (degrees) - Any argument left None is unchanged. Raises for unsupported types.""" - ... - - @abstractmethod - async def entity_list( - self, - type_filter: str | None = None, - layer_filter: str | None = None, - limit: int = 200, - offset: int = 0, - ) -> list[EntityInfo]: ... - - # ── layer management ───────────────────────────────────────────────────── - @abstractmethod - async def layer_list(self) -> list[LayerInfo]: ... - - @abstractmethod - async def layer_create( - self, - name: str, - color: int = 7, - linetype: str = "Continuous", - lineweight: float = -3, - ) -> LayerInfo: ... - - @abstractmethod - async def layer_delete(self, name: str) -> dict: ... - - @abstractmethod - async def layer_set_current(self, name: str) -> dict: ... - - @abstractmethod - async def layer_modify( - self, - name: str, - color: int | None = None, - linetype: str | None = None, - lineweight: float | None = None, - ) -> LayerInfo: ... - - @abstractmethod - async def layer_freeze(self, name: str) -> dict: ... - - @abstractmethod - async def layer_thaw(self, name: str) -> dict: ... - - @abstractmethod - async def layer_lock(self, name: str) -> dict: ... - - @abstractmethod - async def layer_unlock(self, name: str) -> dict: ... - - @abstractmethod - async def layer_hide(self, name: str) -> dict: ... - - @abstractmethod - async def layer_show(self, name: str) -> dict: ... - - # ── linetype management ────────────────────────────────────────────────── - @abstractmethod - async def linetype_list(self) -> list[str]: ... - - @abstractmethod - async def linetype_load( - self, - name: str, - file: str | None = None, - ) -> dict: ... - - # ── block operations ───────────────────────────────────────────────────── - @abstractmethod - async def block_list(self) -> list[BlockInfo]: ... - - @abstractmethod - async def block_insert( - self, - name: str, - x: float, - y: float, - scale_x: float = 1.0, - scale_y: float = 1.0, - rotation: float = 0.0, - attributes: dict | None = None, - layer: str | None = None, - ) -> EntityInfo: ... - - @abstractmethod - async def block_explode(self, handle: str) -> dict: ... - - @abstractmethod - async def block_get_attributes(self, handle: str) -> dict: ... - - @abstractmethod - async def block_set_attributes(self, handle: str, attributes: dict) -> dict: ... - - @abstractmethod - async def block_create_from_entities( - self, - name: str, - handles: list[str], - base_x: float = 0.0, - base_y: float = 0.0, - ) -> dict: ... - - # ── analysis / query ───────────────────────────────────────────────────── - @abstractmethod - async def analysis_stats(self) -> dict: ... - - @abstractmethod - async def analysis_entities_in_region( - self, - x1: float, - y1: float, - x2: float, - y2: float, - ) -> list[EntityInfo]: ... - - @abstractmethod - async def analysis_measure_distance( - self, - x1: float, - y1: float, - x2: float, - y2: float, - ) -> float: ... - - @abstractmethod - async def analysis_measure_area(self, points: list[list[float]]) -> float: ... - - @abstractmethod - async def analysis_bounding_box(self) -> dict: ... - - @abstractmethod - async def analysis_select_by_layer(self, layer_name: str) -> list[EntityInfo]: ... - - @abstractmethod - async def analysis_select_by_type(self, entity_type: str) -> list[EntityInfo]: ... - - @abstractmethod - async def selection_get(self) -> dict: - """Read the live viewport's implied ("pickfirst") selection set. - - COM-only / meaningful only with live AutoCAD. Returns a dict:: - - { - "ok": bool, # True on COM even for an empty pick - "count": int, - "handles": list[str], # entity handles to act on - "entities": list[EntityInfo], # _dc()-converted by the server - "pickfirst": bool | None, # state of the PICKFIRST sysvar - "message" / "error": str, # optional guidance / failure reason - } - - The ezdxf headless backend has no viewport, so it returns ``ok=False`` - with an empty ``handles`` list (same shape, never raises). - """ - ... - - # ── view / screenshot ──────────────────────────────────────────────────── - @abstractmethod - async def view_zoom_extents(self) -> dict: ... - - @abstractmethod - async def view_zoom_window( - self, - x1: float, - y1: float, - x2: float, - y2: float, - ) -> dict: ... - - @abstractmethod - async def view_screenshot(self) -> bytes | None: ... - - # ── transactions ───────────────────────────────────────────────────────── - @abstractmethod - async def transaction_begin(self) -> dict: ... - - @abstractmethod - async def transaction_commit(self) -> dict: ... - - @abstractmethod - async def transaction_rollback(self) -> dict: ... - - # ── system ─────────────────────────────────────────────────────────────── - @abstractmethod - async def system_status(self) -> dict: ... - - @abstractmethod - async def system_get_variable(self, name: str) -> Any: ... - - @abstractmethod - async def system_set_variable(self, name: str, value: Any) -> dict: ... - - @abstractmethod - async def system_run_command(self, command: str) -> dict: ... - - @abstractmethod - async def system_run_lisp(self, expression: str) -> dict: ... - - # ── premium meta-tools (planning + critique + snap + construction) ────── - # These are CONCRETE and backend-agnostic: they orchestrate the abstract - # primitives above plus the shared EntityInfo.properties contract (start/ - # end/center/radius/start_angle/end_angle — populated identically by both - # backends), so the ezdxf and COM backends inherit identical premium - # behaviour. The only backend-specific primitive is `_create_xline` — XLINE - # has no cross-backend equivalent among the standard entity_create_* tools. - - _plan_spec: PlanSpec | None = None - _preflight_result: Any = None - - def _reset_document_state(self) -> None: - """Clear metadata that is valid only for the current drawing.""" - self._plan_spec = None - self._preflight_result = None - self._gdt_datums_defined = set() - self._gdt_datums_referenced = set() - - async def _ensure_document_state(self) -> None: - """Allow live backends to detect an out-of-band document switch.""" - return None - - async def drawing_preflight( - self, - intent: str, - requirements: dict | None = None, - sheet_size: SheetSize = "A3", - scale: float = 1.0, - layer_set_id: LayerSetId = "mech", - view_count: int = 1, - dim_style: DimStyle = "chain", - allow_assumptions: bool = False, - ): - from engineering.preflight import preflight_drawing - - await self._ensure_document_state() - - result = preflight_drawing( - intent, - requirements, - sheet_size, - scale, - layer_set_id, - view_count, - dim_style, - allow_assumptions=allow_assumptions, - ) - self._preflight_result = result - return result - - async def drawing_plan( - self, - intent: str, - sheet_size: SheetSize = "A3", - scale: float = 1.0, - layer_set_id: LayerSetId = "mech", - view_count: int = 1, - dim_style: DimStyle = "chain", - notes: list[str] | None = None, - requirements: dict | None = None, - spec_hash: str | None = None, - ) -> PlanSpec: - """Commit a drawing intent before any geometry is created. - Returns a PlanSpec and stores it on the backend; read it back with - `get_plan_spec()`. (N8: the previous claim that `drawing_critique` - replays this plan was untrue — critique runs geometric checks, not a - plan diff.)""" - from engineering.plan_spec import PlanSpec - - await self._ensure_document_state() - preflight_status = "skipped" - normalized_plan: dict[str, Any] | None = None - if spec_hash is not None: - from engineering.preflight import preflight_drawing - - preflight = getattr(self, "_preflight_result", None) - if preflight is None or preflight.spec_hash != spec_hash: - raise RuntimeError("drawing_plan: spec_hash does not match the latest preflight.") - if not preflight.ready: - raise RuntimeError( - "drawing_plan: preflight is not ready; resolve questions/conflicts first." - ) - candidate = preflight_drawing( - intent, - requirements - if requirements is not None - else preflight.normalized_spec["requirements"], - sheet_size, - scale, - layer_set_id, - view_count, - dim_style, - ) - if candidate.spec_hash != spec_hash: - raise RuntimeError( - "drawing_plan: supplied fields do not match the hashed preflight spec." - ) - normalized_plan = preflight.normalized_spec - preflight_status = "ready" - - if normalized_plan is not None: - intent = normalized_plan["intent"] - sheet_size = normalized_plan["sheet_size"] - scale = normalized_plan["scale"] - layer_set_id = normalized_plan["layer_set_id"] - view_count = normalized_plan["view_count"] - dim_style = normalized_plan["dim_style"] - requirements = normalized_plan["requirements"] - - plan = PlanSpec( - intent=str(intent), - sheet_size=sheet_size, - scale=float(scale), - layer_set_id=layer_set_id, - view_count=int(view_count), - dim_style=dim_style, - notes=list(notes) if notes else [], - requirements=dict(requirements or {}), - spec_hash=spec_hash, - preflight_status=preflight_status, - ) - self._plan_spec = plan - return plan - - async def drawing_critique( - self, - focus: list[CritiqueFocus] | None = None, - ) -> list[Issue]: - """Run premium-quality geometric checks; return zero issues before - `drawing_finalize`. `focus=None` runs all checks. (N8: this does NOT - replay the committed PlanSpec — it inspects the live drawing geometry.)""" - from engineering.critique import run_critique - - await self._ensure_document_state() - return await run_critique(self, focus) - - def get_plan_spec(self) -> dict | None: - """Return the PlanSpec committed by `drawing_plan` as a dict, or None - if no plan has been committed. (N8: makes the stored `_plan_spec` - actually readable instead of being write-only dead state.)""" - plan = getattr(self, "_plan_spec", None) - return plan.to_dict() if plan is not None else None - - async def point_from_snap( - self, - handle: str, - snap: SnapType, - ref_x: float | None = None, - ref_y: float | None = None, - ) -> tuple[float, float]: - """Return a deterministic snap point on `handle`. - snap ∈ {end, mid, center, quad, perp, near}. For `near/perp`, - `ref_x/y` is the reference point. Sources geometry from the shared - EntityInfo.properties contract — works on every backend.""" - ent = await self.entity_get(handle) - et = ent.type - p = ent.properties - ref = (float(ref_x), float(ref_y)) if ref_x is not None and ref_y is not None else None - - def _line_pts(): - s = p.get("start") - e = p.get("end") - if s is None or e is None: - raise RuntimeError(f"point_from_snap: entity {handle} ({et}) has no start/end.") - return (float(s[0]), float(s[1])), (float(e[0]), float(e[1])) - - def _circle_geom(): - c = p.get("center") - r = p.get("radius") - if c is None or r is None: - raise RuntimeError(f"point_from_snap: entity {handle} ({et}) has no center/radius.") - return float(c[0]), float(c[1]), float(r) - - def _arc_angles(): - # N7: use .get() + descriptive RuntimeError (matching _line_pts / - # _circle_geom) instead of bracket indexing that raises an opaque - # KeyError when start_angle/end_angle are missing. - sa = p.get("start_angle") - ea = p.get("end_angle") - if sa is None or ea is None: - raise RuntimeError( - f"point_from_snap: entity {handle} ({et}) has no start_angle/end_angle." - ) - return math.radians(float(sa)), math.radians(float(ea)) - - if snap == "end": - if et == "LINE": - s, e = _line_pts() - if ref is None: - return s - ds = (s[0] - ref[0]) ** 2 + (s[1] - ref[1]) ** 2 - de = (e[0] - ref[0]) ** 2 + (e[1] - ref[1]) ** 2 - return s if ds <= de else e - if et == "ARC": - cx, cy, r = _circle_geom() - sa, ea = _arc_angles() - sp = (cx + r * math.cos(sa), cy + r * math.sin(sa)) - ep = (cx + r * math.cos(ea), cy + r * math.sin(ea)) - if ref is None: - return sp - ds = (sp[0] - ref[0]) ** 2 + (sp[1] - ref[1]) ** 2 - de = (ep[0] - ref[0]) ** 2 + (ep[1] - ref[1]) ** 2 - return sp if ds <= de else ep - raise RuntimeError(f"point_from_snap: 'end' snap not supported on {et}") - - if snap == "mid": - if et == "LINE": - s, e = _line_pts() - return ((s[0] + e[0]) / 2.0, (s[1] + e[1]) / 2.0) - if et == "ARC": - cx, cy, r = _circle_geom() - sa, ea = _arc_angles() - if ea < sa: - ea += 2 * math.pi - ma = (sa + ea) / 2.0 - return (cx + r * math.cos(ma), cy + r * math.sin(ma)) - raise RuntimeError(f"point_from_snap: 'mid' snap not supported on {et}") - - if snap == "center": - if et in ("CIRCLE", "ARC", "ELLIPSE"): - c = p.get("center") - if c is None: - raise RuntimeError(f"point_from_snap: entity {handle} ({et}) has no center.") - return (float(c[0]), float(c[1])) - raise RuntimeError(f"point_from_snap: 'center' snap not supported on {et}") - - # NEW-snap-quad-arc / NEW-snap-near-arc: an ARC only covers part of the - # full circle, so quad/near candidates derived from the circle may fall - # outside the [start_angle, end_angle] sweep. Restrict (quad) / clamp - # (near) the projected angle to the sweep, with 2π wraparound. - def _arc_sweep(): - sa, ea = _arc_angles() - if ea < sa: - ea += 2 * math.pi - return sa, ea - - def _angle_in_sweep(theta: float, sa: float, ea: float) -> bool: - # Normalise theta into [sa, sa+2π) and test against [sa, ea]. - t = sa + ((theta - sa) % (2 * math.pi)) - return t <= ea + 1e-9 - - def _arc_endpoints(cx: float, cy: float, r: float, sa: float, ea: float): - return ( - (cx + r * math.cos(sa), cy + r * math.sin(sa)), - (cx + r * math.cos(ea), cy + r * math.sin(ea)), - ) - - if snap == "quad": - if et == "CIRCLE": - cx, cy, r = _circle_geom() - pts = [(cx + r, cy), (cx, cy + r), (cx - r, cy), (cx, cy - r)] - if ref is None: - return pts[0] - return min(pts, key=lambda q: (q[0] - ref[0]) ** 2 + (q[1] - ref[1]) ** 2) - if et == "ARC": - cx, cy, r = _circle_geom() - sa, ea = _arc_sweep() - quad_angles = (0.0, math.pi / 2.0, math.pi, 3 * math.pi / 2.0) - pts = [ - (cx + r * math.cos(a), cy + r * math.sin(a)) - for a in quad_angles - if _angle_in_sweep(a, sa, ea) - ] - if not pts: - # No quadrant lies on the sweep — fall back to arc endpoints. - pts = list(_arc_endpoints(cx, cy, r, sa, ea)) - if ref is None: - return pts[0] - return min(pts, key=lambda q: (q[0] - ref[0]) ** 2 + (q[1] - ref[1]) ** 2) - raise RuntimeError(f"point_from_snap: 'quad' snap not supported on {et}") - - if snap == "perp": - if ref is None: - raise RuntimeError("point_from_snap: 'perp' requires ref_x/ref_y.") - if et == "LINE": - s, e = _line_pts() - dx = e[0] - s[0] - dy = e[1] - s[1] - L2 = dx * dx + dy * dy - if L2 < 1e-18: - raise RuntimeError("point_from_snap: line has zero length.") - t = ((ref[0] - s[0]) * dx + (ref[1] - s[1]) * dy) / L2 - return (s[0] + t * dx, s[1] + t * dy) - raise RuntimeError(f"point_from_snap: 'perp' snap not supported on {et}") - - if snap == "near": - if ref is None: - raise RuntimeError("point_from_snap: 'near' requires ref_x/ref_y.") - if et == "LINE": - s, e = _line_pts() - dx = e[0] - s[0] - dy = e[1] - s[1] - L2 = dx * dx + dy * dy - if L2 < 1e-18: - return s - t = ((ref[0] - s[0]) * dx + (ref[1] - s[1]) * dy) / L2 - t = max(0.0, min(1.0, t)) - return (s[0] + t * dx, s[1] + t * dy) - if et == "CIRCLE": - cx, cy, r = _circle_geom() - vx, vy = ref[0] - cx, ref[1] - cy - L = math.hypot(vx, vy) - if L < 1e-18: - return (cx + r, cy) - return (cx + r * vx / L, cy + r * vy / L) - if et == "ARC": - cx, cy, r = _circle_geom() - sa, ea = _arc_sweep() - vx, vy = ref[0] - cx, ref[1] - cy - L = math.hypot(vx, vy) - if L < 1e-18: - # Degenerate ref at center: return the start endpoint. - return (cx + r * math.cos(sa), cy + r * math.sin(sa)) - theta = math.atan2(vy, vx) - if _angle_in_sweep(theta, sa, ea): - return (cx + r * math.cos(theta), cy + r * math.sin(theta)) - # Projected angle is off the sweep — fall back to the nearer endpoint. - sp, ep = _arc_endpoints(cx, cy, r, sa, ea) - ds = (sp[0] - ref[0]) ** 2 + (sp[1] - ref[1]) ** 2 - de = (ep[0] - ref[0]) ** 2 + (ep[1] - ref[1]) ** 2 - return sp if ds <= de else ep - raise RuntimeError(f"point_from_snap: 'near' snap not supported on {et}") - - if snap == "int": - raise RuntimeError( - "point_from_snap: 'int' (intersection) requires a 2nd entity; " - "compute via two endpoints. V2 will accept a 2nd handle." - ) - - raise RuntimeError( - f"point_from_snap: unknown snap type '{snap}'. " - "Use one of: end, mid, center, quad, perp, near." - ) - - async def point_intersection( - self, - handle1: str, - handle2: str, - ref_x: float | None = None, - ref_y: float | None = None, - ) -> tuple[float, float]: - """Return the intersection point of two geometry entities. - - Supports LINE-LINE, LINE-CIRCLE, and CIRCLE-CIRCLE cases. When - multiple intersections exist (e.g. two circles), ``ref_x``/``ref_y`` - selects the candidate nearest to that reference point. If omitted the - first candidate is returned. - - Raises RuntimeError when no real intersection exists or entity types - are unsupported. - """ - info1 = await self.entity_get(handle1) - info2 = await self.entity_get(handle2) - t1, t2 = info1.type.upper(), info2.type.upper() - props1, props2 = info1.properties, info2.properties - - candidates: list[tuple[float, float]] = [] - - if t1 == "LINE" and t2 == "LINE": - # Line-line intersection via parametric form - x1, y1 = props1["start"][0], props1["start"][1] - x2, y2 = props1["end"][0], props1["end"][1] - x3, y3 = props2["start"][0], props2["start"][1] - x4, y4 = props2["end"][0], props2["end"][1] - dx1, dy1 = x2 - x1, y2 - y1 - dx2, dy2 = x4 - x3, y4 - y3 - denom = dx1 * dy2 - dy1 * dx2 - if abs(denom) < 1e-12: - raise RuntimeError("point_intersection: lines are parallel or coincident.") - t = ((x3 - x1) * dy2 - (y3 - y1) * dx2) / denom - candidates = [(x1 + t * dx1, y1 + t * dy1)] - - elif {t1, t2} == {"LINE", "CIRCLE"}: - line_p = props1 if t1 == "LINE" else props2 - circ_p = props2 if t1 == "LINE" else props1 - lx1, ly1 = line_p["start"][0], line_p["start"][1] - lx2, ly2 = line_p["end"][0], line_p["end"][1] - cx, cy = circ_p["center"][0], circ_p["center"][1] - r = circ_p["radius"] - dx, dy = lx2 - lx1, ly2 - ly1 - fx, fy = lx1 - cx, ly1 - cy - a = dx * dx + dy * dy - b = 2.0 * (fx * dx + fy * dy) - c = fx * fx + fy * fy - r * r - disc = b * b - 4.0 * a * c - if disc < 0: - raise RuntimeError("point_intersection: line does not intersect circle.") - sq = math.sqrt(disc) - for sign in (1, -1): - t = (-b + sign * sq) / (2.0 * a) - candidates.append((lx1 + t * dx, ly1 + t * dy)) - - elif t1 == "CIRCLE" and t2 == "CIRCLE": - cx1, cy1, r1 = props1["center"][0], props1["center"][1], props1["radius"] - cx2, cy2, r2 = props2["center"][0], props2["center"][1], props2["radius"] - dx, dy = cx2 - cx1, cy2 - cy1 - d = math.hypot(dx, dy) - if d < 1e-12: - raise RuntimeError("point_intersection: circles are concentric.") - if d > r1 + r2 + 1e-9 or d < abs(r1 - r2) - 1e-9: - raise RuntimeError("point_intersection: circles do not intersect.") - a = (r1 * r1 - r2 * r2 + d * d) / (2.0 * d) - h_sq = r1 * r1 - a * a - h = math.sqrt(max(h_sq, 0.0)) - mx = cx1 + a * dx / d - my = cy1 + a * dy / d - candidates = [ - (mx + h * dy / d, my - h * dx / d), - (mx - h * dy / d, my + h * dx / d), - ] - - else: - raise RuntimeError( - f"point_intersection: unsupported entity combination {t1}+{t2}. " - "Supported: LINE-LINE, LINE-CIRCLE, CIRCLE-CIRCLE." - ) - - if len(candidates) == 1: - return candidates[0] - if ref_x is not None and ref_y is not None: - return min( - candidates, - key=lambda p: (p[0] - float(ref_x)) ** 2 + (p[1] - float(ref_y)) ** 2, - ) - return candidates[0] - - async def point_tangent( - self, - circle_handle: str, - from_x: float, - from_y: float, - ref_x: float | None = None, - ref_y: float | None = None, - ) -> tuple[float, float]: - """Return the tangent point on a circle from an external point. - - When two tangent points exist, ``ref_x``/``ref_y`` selects the - nearer one; without it, the first is returned (counter-clockwise from - the from-point—circle-center line). - - Raises RuntimeError if the point is inside the circle. - """ - info = await self.entity_get(circle_handle) - if info.type.upper() != "CIRCLE": - raise RuntimeError( - f"point_tangent: handle {circle_handle!r} is {info.type}, expected CIRCLE." - ) - cx, cy = info.properties["center"][0], info.properties["center"][1] - r = info.properties["radius"] - fx, fy = float(from_x), float(from_y) - dx, dy = cx - fx, cy - fy - d = math.hypot(dx, dy) - if d < r - 1e-9: - raise RuntimeError( - f"point_tangent: from-point ({fx}, {fy}) is inside the circle " - f"(center ({cx}, {cy}), radius {r})." - ) - if d < 1e-12: - raise RuntimeError("point_tangent: from-point coincides with circle center.") - # Tangent point T lies on the circle such that (T-F)⊥(T-C). - # Solving cos(θ - α) = -r/d where α = atan2(C-F) gives the two - # circle angles θ at which the tangent touches the perimeter. - alpha = math.atan2(dy, dx) # direction from F to C - # NEW-base-1: clamp the acos argument to [-1, 1] so a from-point in the - # narrow band [r-1e-9, r) (past the internal-point guard but with - # |-r/d| marginally > 1 from float error) degenerates gracefully to the - # tangent-at-perimeter case instead of raising a raw ValueError - # ("math domain error"). Genuine internal points (d < r) are already - # rejected by the RuntimeError guard above. - acos_neg = math.acos(max(-1.0, min(1.0, -r / d))) # (π/2, π] for external pt - candidates: list[tuple[float, float]] = [ - (cx + r * math.cos(alpha + acos_neg), cy + r * math.sin(alpha + acos_neg)), - (cx + r * math.cos(alpha - acos_neg), cy + r * math.sin(alpha - acos_neg)), - ] - if ref_x is not None and ref_y is not None: - return min( - candidates, - key=lambda p: (p[0] - float(ref_x)) ** 2 + (p[1] - float(ref_y)) ** 2, - ) - return candidates[0] - - @abstractmethod - async def _create_xline( - self, - x: float, - y: float, - dx: float, - dy: float, - layer: str, - ) -> EntityInfo: - """Create an infinite construction line (XLINE) through (x, y) with - unit direction (dx, dy) on `layer`. Backend-specific: XLINE has no - cross-backend entity_create_* equivalent.""" - ... - - async def _ensure_layer(self, name: str, color: int = 7) -> None: - """Idempotently make sure `name` exists (no-op if already present).""" - try: - existing = {lyr.name for lyr in await self.layer_list()} - except Exception: - existing = set() - if name not in existing: - try: - await self.layer_create(name, color=color) - except Exception: - pass # already created / racy create — harmless - - def _active_layer_set_id(self) -> str: - """Best-effort active layer set: an explicit `drawing_apply_iso_layers` - wins, else the committed PlanSpec, else 'mech' (the default bootstrap).""" - explicit = getattr(self, "_active_layer_set", None) - if explicit: - return explicit - plan = getattr(self, "_plan_spec", None) - if plan is not None: - return getattr(plan, "layer_set_id", "mech") - return "mech" - - def _role_layer(self, role: str) -> str: - """Resolve the layer name for a role ('dim' | 'construction') in the - active layer set, so dims/scaffolding land on the right layer for - iso13567 (M-DIMEN-T-N / M-CONST-E-N) not just mech/pid (DIM/CONSTRUCTION).""" - from engineering.layers import resolve_role_layer - - return resolve_role_layer(self._active_layer_set_id(), role) - - async def construction_xline( - self, - x: float, - y: float, - angle_deg: float, - layer: str | None = None, - ) -> EntityInfo: - """Create an infinite construction line (XLINE) on the active layer set's - construction layer (auto-created with color 250 = lightest if missing).""" - layer = layer or self._role_layer("construction") - await self._ensure_layer(layer, color=250) - ang = math.radians(float(angle_deg)) - return await self._create_xline( - float(x), - float(y), - math.cos(ang), - math.sin(ang), - layer, - ) - - async def construction_clear( - self, - layer: str | None = None, - ) -> dict: - """Delete every entity on the active layer set's construction layer. - Must be called before `drawing_finalize`.""" - layer = layer or self._role_layer("construction") - try: - ents = await self.entity_list(layer_filter=layer, limit=5000) - except Exception: - ents = [] - deleted = 0 - for ent in ents: - try: - await self.entity_delete(ent.handle) - deleted += 1 - except Exception: - continue - return {"ok": True, "layer": layer, "deleted": deleted} - - async def drawing_apply_iso_layers( - self, - standard: LayerSetId = "mech", - ) -> dict: - """Bootstrap a full ISO-conformant layer set with correct colors - and lineweights. Idempotent.""" - from engineering.layers import apply_layer_set - - result = await apply_layer_set(self, standard) - self._active_layer_set = standard - return {"ok": True, "standard": standard, "layers": result} - - async def dimension_auto( - self, - handles: list[str], - style: DimStyle = "chain", - offset: float = 10.0, - layer: str | None = None, - ) -> list[EntityInfo]: - """Generate ISO 129 dimensions across `handles` in the chosen style - (chain | baseline | ordinate). `offset` is the dimension-line - distance from the reference geometry (mm). `layer` defaults to the - active layer set's dimension layer (DIM, or M-DIMEN-T-N for iso13567).""" - if not handles: - return [] - dim_layer = layer or self._role_layer("dim") - if style not in ("chain", "baseline", "ordinate"): - raise RuntimeError( - f"dimension_auto: unknown style '{style}'. Use 'chain', 'baseline', or 'ordinate'." - ) - segs: list[tuple[float, float, float, float]] = [] - for h in handles: - ent = await self.entity_get(h) - if ent.type != "LINE": - raise RuntimeError( - f"dimension_auto V1: only LINE entities supported (handle {h} is {ent.type})." - ) - s = ent.properties.get("start") - e = ent.properties.get("end") - if s is None or e is None: - raise RuntimeError(f"dimension_auto: line {h} missing start/end.") - segs.append((float(s[0]), float(s[1]), float(e[0]), float(e[1]))) - - off = float(offset) - results: list[EntityInfo] = [] - - if style == "chain": - # Each line gets its own linear (rotated) dimension, offset clear of - # the geometry along its own perpendicular. - for sx, sy, ex, ey in segs: - mx = (sx + ex) / 2.0 - my = (sy + ey) / 2.0 - dx, dy = ex - sx, ey - sy - L = math.hypot(dx, dy) or 1.0 - nx, ny = -dy / L, dx / L - dim = await self.dimension_linear( - sx, - sy, - ex, - ey, - mx + nx * off, - my + ny * off, - rotation=math.degrees(math.atan2(dy, dx)), - layer=dim_layer, - ) - results.append(dim) - - elif style == "baseline": - sx0, sy0, ex0, ey0 = segs[0] - dx, dy = ex0 - sx0, ey0 - sy0 - L0 = math.hypot(dx, dy) or 1.0 - ux, uy = dx / L0, dy / L0 - base_rot = math.degrees(math.atan2(dy, dx)) - for i, (_sx, _sy, ex, ey) in enumerate(segs): - dim = await self.dimension_linear( - sx0, - sy0, - ex, - ey, - sx0 - uy * (off + i * off), - sy0 + ux * (off + i * off), - # Dimension line must run parallel to the (possibly rotated) - # baseline, not horizontal. - rotation=base_rot, - layer=dim_layer, - ) - results.append(dim) - - else: # ordinate — stacked X/Y distances; each feature offset so dims don't overlap - for i, (sx, sy, ex, ey) in enumerate(segs): - rung = off + i * off # stagger per feature to avoid coincident dims - if abs(ex - sx) > 1e-9: - dim = await self.dimension_linear( - sx, - sy, - ex, - sy, - sx + (ex - sx) / 2.0, - max(sy, ey) + rung, - layer=dim_layer, - ) - results.append(dim) - if abs(ey - sy) > 1e-9: - dim = await self.dimension_linear( - ex, - sy, - ex, - ey, - max(sx, ex) + rung, - (sy + ey) / 2.0, - rotation=90.0, - layer=dim_layer, - ) - results.append(dim) - - return results - - async def entity_select_smart( - self, - predicate: dict, - ) -> list[EntityInfo]: - """Semantic entity selection. Predicate keys (all optional, AND-ed): - - type: "LINE" | "CIRCLE" | ... - - layer: layer name - - near: [x, y, radius] — entity reference point within circle - - length_range: [min, max] — applies to LINE/ARC - - color: ACI int - """ - if not isinstance(predicate, dict): - raise RuntimeError("entity_select_smart: predicate must be a dict.") - ptype = predicate.get("type") - player = predicate.get("layer") - pnear = predicate.get("near") # [x, y, radius] - plen = predicate.get("length_range") # [min, max] - pcolor = predicate.get("color") - - ents = await self.entity_list( - type_filter=ptype.upper() if isinstance(ptype, str) else None, - layer_filter=player if isinstance(player, str) else None, - limit=5000, - ) - - def _ok(e: EntityInfo) -> bool: - if pcolor is not None and int(e.color) != int(pcolor): - return False - if pnear is not None: - try: - nx, ny, nr = float(pnear[0]), float(pnear[1]), float(pnear[2]) - except Exception: - return False - pt = ( - e.properties.get("start") - or e.properties.get("center") - or e.properties.get("insertion") - or e.properties.get("insertion_point") - ) - if pt is None: - return False - if (float(pt[0]) - nx) ** 2 + (float(pt[1]) - ny) ** 2 > nr**2: - return False - if plen is not None and e.type in ("LINE", "ARC"): - try: - lmin, lmax = float(plen[0]), float(plen[1]) - except Exception: - return False - length = e.properties.get("length") - if length is None or not (lmin <= float(length) <= lmax): - return False - return True - - return [e for e in ents if _ok(e)] - - # ── GD&T (ISO 1101 / ASME Y14.5) ───────────────────────────────────────── - # Composed from LINE + TEXT primitives so the exact same frame renders on - # both COM and ezdxf (ezdxf's native TOLERANCE entity renders blank through - # the matplotlib frontend). Deterministic layout math lives in engineering/ - # gdt.py; datum letters are tracked so the `gdt` critique focus can verify - # every referenced datum is actually established on the part. - - async def _place_boxed_text( - self, - text: str, - cx: float, - cy: float, - text_h: float, - layer: str, - ) -> str: - """Create a TEXT entity roughly centred on (cx, cy). Returns its handle. - entity_create_text has no alignment arg, so approximate the centring with - the same width heuristic the title block uses.""" - approx_w = len(text) * text_h * 0.7 - t = await self.entity_create_text( - text, - cx - approx_w / 2.0, - cy - text_h / 2.0, - height=text_h, - layer=layer, - ) - return t.handle - - async def draw_feature_control_frame( - self, - symbol: str, - tolerance: str | float, - x: float, - y: float, - datums: list[str] | None = None, - height: float = 5.0, - diameter: bool = False, - modifier: str | None = None, - layer: str | None = None, - ) -> dict: - """Draw an ISO 1101 feature control frame with bottom-left corner at (x, y). - - symbol: one of the 14 geometric characteristics (straightness, flatness, - circularity, cylindricity, profile_line, profile_surface, angularity, - perpendicularity, parallelism, position, concentricity, symmetry, - circular_runout, total_runout). `datums` is the ordered datum reference - list (e.g. ["A", "B"]); `diameter=True` prefixes ⌀ for a cylindrical zone; - `modifier` ∈ {M, L, S} appends the material-condition symbol. - - Orientation/location/runout characteristics require at least one datum - (raises otherwise). The referenced datums are recorded for the `gdt` - critique focus. - """ - from engineering.gdt import fcf_compartments, fcf_layout - - comps = fcf_compartments( - symbol, - tolerance, - datums, - diameter=diameter, - modifier=modifier, - ) - layer = layer or self._role_layer("dim") - await self._ensure_layer(layer, color=2) - lay = fcf_layout(comps, float(x), float(y), float(height)) - handles: list[str] = [] - - box = lay["box"] - box_poly = await self.entity_create_polyline( - [[bx, by] for bx, by in box], - closed=False, - layer=layer, - ) - handles.append(box_poly.handle) - for (sx, sy), (ex, ey) in lay["dividers"]: - ln = await self.entity_create_line(sx, sy, ex, ey, layer=layer) - handles.append(ln.handle) - th = lay["text_height"] - for text, tcx, tcy in lay["labels"]: - handles.append(await self._place_boxed_text(text, tcx, tcy, th, layer)) - - refs = getattr(self, "_gdt_datums_referenced", None) - if refs is None: - refs = set() - self._gdt_datums_referenced = refs - for d in datums or []: - d = str(d).strip().upper() - if d: - refs.add(d) - - return { - "ok": True, - "symbol": symbol, - "compartments": comps, - "handles": handles, - "width": lay["width"], - "height": lay["height"], - "layer": layer, - } - - async def draw_datum_feature( - self, - letter: str, - x: float, - y: float, - size: float = 5.0, - layer: str | None = None, - ) -> dict: - """Place an ISO 1101 datum feature symbol (filled triangle + boxed letter) - with its apex at (x, y). Records the datum letter so a feature control - frame that references it passes the `gdt` critique focus.""" - from engineering.gdt import datum_triangle, fcf_layout - - letter = str(letter).strip().upper() - if not letter: - raise RuntimeError("draw_datum_feature: a datum letter is required.") - layer = layer or self._role_layer("dim") - await self._ensure_layer(layer, color=2) - size = float(size) - handles: list[str] = [] - - tri = datum_triangle(float(x), float(y), size, down=True) - tri_poly = await self.entity_create_polyline( - [[px, py] for px, py in tri], - closed=True, - layer=layer, - ) - handles.append(tri_poly.handle) - - box_h = size * 1.4 - lay = fcf_layout([letter], float(x) - box_h / 2.0, float(y) - size - box_h, box_h) - box_poly = await self.entity_create_polyline( - [[bx, by] for bx, by in lay["box"]], - closed=False, - layer=layer, - ) - handles.append(box_poly.handle) - for text, tcx, tcy in lay["labels"]: - handles.append(await self._place_boxed_text(text, tcx, tcy, lay["text_height"], layer)) - - defined = getattr(self, "_gdt_datums_defined", None) - if defined is None: - defined = set() - self._gdt_datums_defined = defined - defined.add(letter) - - return {"ok": True, "datum": letter, "handles": handles, "layer": layer} - - # ── drawing settings (friendly system-variable facade) ─────────────────── - # A user-facing wrapper over system_get_variable / system_set_variable that - # maps memorable names ("units", "dimscale", …) to AutoCAD system variables. - # Concrete + backend-agnostic: both engines accept the bare sysvar name, so - # the same call reads/writes on live COM and headless ezdxf alike. - - async def drawing_settings(self, settings: dict | None = None) -> dict: - """Read (no args) or apply (with args) common drawing settings. - - With ``settings=None`` returns a snapshot of every known setting. With a - dict, applies each provided key and returns ``{applied, errors, current}``. - Friendly keys: units, linear_precision, angular_precision, ltscale, - dimscale, text_size, point_mode, point_size, osmode, fillet_radius. - `units` accepts mm/cm/m/inch/feet (mapped to the INSUNITS code).""" - if not settings: - snapshot: dict = {} - for key, (var, kind) in _SETTING_MAP.items(): - try: - raw = await self.system_get_variable(var) - except Exception as exc: - snapshot[key] = {"error": str(exc)} - continue - snapshot[key] = _decode_setting(key, kind, raw) - return {"ok": True, "settings": snapshot} - - applied: dict = {} - errors: dict = {} - for key, value in settings.items(): - spec = _SETTING_MAP.get(key) - if spec is None: - errors[key] = f"unknown setting (valid: {sorted(_SETTING_MAP)})" - continue - var, kind = spec - try: - encoded = _encode_setting(key, kind, value) - await self.system_set_variable(var, encoded) - applied[key] = value - except Exception as exc: - errors[key] = str(exc) - - result = {"ok": not errors, "applied": applied} - if errors: - result["errors"] = errors - return result - - -# --------------------------------------------------------------------------- -# Helpers -# --------------------------------------------------------------------------- - - -# Friendly setting name -> (AutoCAD system variable, value kind). -_SETTING_MAP: dict[str, tuple[str, str]] = { - "units": ("INSUNITS", "units"), - "linear_precision": ("LUPREC", "int"), - "angular_precision": ("AUPREC", "int"), - "ltscale": ("LTSCALE", "float"), - "dimscale": ("DIMSCALE", "float"), - "text_size": ("TEXTSIZE", "float"), - "point_mode": ("PDMODE", "int"), - "point_size": ("PDSIZE", "float"), - "osmode": ("OSMODE", "int"), - "fillet_radius": ("FILLETRAD", "float"), -} - -# INSUNITS code table (AutoCAD $INSUNITS): friendly name <-> integer code. -_UNIT_TO_CODE: dict[str, int] = { - "unitless": 0, - "inch": 1, - "inches": 1, - "in": 1, - "feet": 2, - "ft": 2, - "foot": 2, - "mm": 4, - "millimeter": 4, - "millimeters": 4, - "cm": 5, - "centimeter": 5, - "centimeters": 5, - "m": 6, - "meter": 6, - "meters": 6, -} -_CODE_TO_UNIT: dict[int, str] = {0: "unitless", 1: "inch", 2: "feet", 4: "mm", 5: "cm", 6: "m"} - - -def _encode_setting(key: str, kind: str, value: Any) -> Any: - """Coerce a friendly value into the raw system-variable value.""" - if kind == "units": - if isinstance(value, (int, float)) and not isinstance(value, bool): - return int(value) - code = _UNIT_TO_CODE.get(str(value).strip().lower()) - if code is None: - raise ValueError( - f"units: unknown value {value!r}. Use one of " - f"{sorted(set(_UNIT_TO_CODE))} or an INSUNITS integer." - ) - return code - if kind == "int": - return int(float(value)) - if kind == "float": - return float(value) - return value - - -def _decode_setting(key: str, kind: str, raw: Any) -> Any: - """Present a raw system-variable value in friendly form.""" - if raw is None: - return None - if kind == "units": - try: - code = int(raw) - except (TypeError, ValueError): - return raw - return {"code": code, "name": _CODE_TO_UNIT.get(code, "unknown")} - if kind == "int": - try: - return int(raw) - except (TypeError, ValueError): - return raw - if kind == "float": - try: - return float(raw) - except (TypeError, ValueError): - return raw - return raw +class AutoCADBackend( + IdentityContract, + DrawingContract, + LayoutContract, + SolidContract, + EntityCreationContract, + DimensionContract, + EntityModificationContract, + CornerOpsContract, + EntityQueryContract, + LayerContract, + LinetypeContract, + BlockContract, + AnalysisContract, + ViewContract, + TransactionContract, + SystemContract, + PremiumContract, + GdtContract, + SettingsContract, +): + """All backends implement this interface. + + The methods live in :mod:`backends.contracts`, one module per domain. + This class is the composition and the import surface: everything that + used to do ``from backends.base import AutoCADBackend`` still works, and + ``isinstance`` against it is unchanged. + """ def shoelace_area(points: list[list[float]]) -> float: diff --git a/backends/capability.py b/backends/capability.py new file mode 100644 index 0000000..5da2bc3 --- /dev/null +++ b/backends/capability.py @@ -0,0 +1,134 @@ +"""Optional contract methods: declared once, refused loudly, gated by a test. + +The backend contract is large (112 abstract methods before this file existed, +and the plan adds ~39 more). Every one of them is an `@abstractmethod`, which +means adding a single method to the contract makes **both** backends fail at +instantiation until both are written — and two test modules construct +`ComBackend` at import time, so the whole suite goes red on a half-finished +edit. That is a real cost paid on every contract change. + +The obvious fix is to give new methods a default body. The obvious default is +wrong: returning ``{"ok": False, "capability": ...}`` from a method nobody +implemented produces exactly the quiet "this didn't work and nothing told you" +that the v1.5.0 honesty pass spent a milestone deleting. A default that lies +quietly is worse than a build break. + +So `@capability` gives a default that **raises** +`UnsupportedCapabilityError` — the same typed refusal the deliberate +boundaries raise, carrying the same `capability` key, surfaced by the same +`CapabilityRefusalMiddleware`, and indistinguishable to a client from a +hand-written refusal. Three properties follow: + +* An unimplemented method refuses instead of pretending. It cannot silently + succeed and it cannot silently do nothing. +* The refusal is *declared*: `test_capability.py` fails if a key used here is + missing from either backend's `CapabilityMap`, so `system_capabilities` can + never disagree with what a call actually does. This is the part that keeps + the escape hatch honest — without it, `@capability` would just be a way to + ship undeclared holes. +* Overriding is ordinary subclassing. A backend that implements the method + simply defines it; nothing needs to be un-registered. + +`@abstractmethod` remains the default for anything both backends must have. +Reach for `@capability` only when a method is genuinely optional — an engine +boundary (no ACIS kernel headlessly) rather than an unfinished chore. +""" + +from __future__ import annotations + +import functools +from collections.abc import Callable +from typing import Any + +__all__ = [ + "UnsupportedCapabilityError", + "capability", + "capability_keys", + "is_capability_default", +] + + +# --------------------------------------------------------------------------- +# The refusal vocabulary +# +# This lives here rather than in ``backends.base`` because the contracts import +# the decorator and ``base`` imports the contracts — putting the exception in +# ``base`` makes that a cycle. ``backends.base`` re-exports both names, so +# ``from backends.base import UnsupportedCapabilityError`` is unchanged for the +# rest of the repo. +# +# There is exactly one authoring rule: a backend that cannot do a thing RAISES +# ``UnsupportedCapabilityError(capability, message)``, where ``capability`` is a +# key of ``capabilities().features`` so a client can pre-check the same boundary +# through ``system_capabilities`` instead of parsing English. Tools never catch +# it and never hand-build refusal payloads — one middleware turns it into the +# wire payload, and ``cad_batch`` turns it into a step row. One class object, +# defined once: two look-alike types would silently break every ``isinstance`` +# classifier. +# +# The ``{"ok": False, "error": ...}`` returned-dict shape stays legal only for +# soft failures that are not capability boundaries ("Layout not found", "No +# active transaction"). +# --------------------------------------------------------------------------- + + +def _unsupported(error: str, capability_key: str) -> dict: + """Build a backend's machine-readable "this backend can't" payload.""" + return {"ok": False, "error": error, "capability": capability_key} + + +class UnsupportedCapabilityError(RuntimeError): + """Raised when a call needs a capability this backend does not have. + + Stays a plain ``RuntimeError``: rebasing it on ``FastMCPError`` was measured + and changes nothing a client can observe, because the refusal is carried by + ``ToolResult(structured_content=..., is_error=True)`` rather than by the + exception class. ``to_dict()`` keeps the ``{ok, error, capability}`` payload + attached to the raise so the boundary survives the ``__cause__`` chain. + """ + + def __init__(self, capability: str, error: str): + super().__init__(error) + self.capability = capability + + def to_dict(self) -> dict: + return _unsupported(str(self), self.capability) + + +#: ``{capability_key: (method_name, ...)}`` for every method declared optional. +#: Populated at import by the decorator and read by the parity gate. +_REGISTRY: dict[str, set[str]] = {} + + +def capability(key: str, *, reason: str = "") -> Callable[[Callable], Callable]: + """Mark a contract method optional, defaulting to a typed refusal. + + Args: + key: capability name; must appear in **both** backends' capability maps. + reason: appended to the refusal message. Say what the caller can do + instead, not merely that they cannot do this. + """ + + def decorate(func: Callable) -> Callable: + @functools.wraps(func) + async def default(self, *args: Any, **kwargs: Any): + message = f"{func.__name__}: not supported by the {self.name} backend" + raise UnsupportedCapabilityError(key, f"{message}. {reason}".rstrip()) + + default.__capability__ = key + default.__is_capability_default__ = True + _REGISTRY.setdefault(key, set()).add(func.__name__) + return default + + return decorate + + +def capability_keys() -> dict[str, set[str]]: + """Every optional-capability key and the methods declared under it.""" + return {key: set(names) for key, names in _REGISTRY.items()} + + +def is_capability_default(owner: type, method_name: str) -> bool: + """True when ``owner`` inherited the refusing default rather than implementing it.""" + attribute = getattr(owner, method_name, None) + return bool(getattr(attribute, "__is_capability_default__", False)) diff --git a/backends/com_backend.py b/backends/com_backend.py index 7526f1b..cffd363 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -14,10 +14,13 @@ import sys import time import uuid from concurrent.futures import ThreadPoolExecutor +from pathlib import Path from typing import Any import config +from engineering.measure import is_self_intersecting, polygon_area_perimeter +from . import ocs from .base import ( AutoCADBackend, BlockInfo, @@ -26,6 +29,7 @@ from .base import ( EntityInfo, FeatureCapability, LayerInfo, + UnsupportedCapabilityError, deg2rad, normalize_lineweight, rad2deg, @@ -123,19 +127,30 @@ def _solid_3d_capability() -> FeatureCapability: def _acad_app(): - """Return (or lazily create) the AutoCAD Application COM object. - Must only be called from the COM executor thread.""" + """Return (or lazily create) the CAD Application COM object. + + Must only be called from the COM executor thread. + + The ProgID comes from ``CAD_PROGID`` (default ``AutoCAD.Application``) and + is honoured on *both* paths deliberately. ``Dispatch`` is not a passive + probe — it COM-launches the application and makes it visible — so honouring + the setting on ``GetActiveObject`` and then falling back to AutoCAD would + start the very application the operator said they were not using. + """ if "app" not in _COM_STATE: + progid = config.settings.cad_progid try: - _COM_STATE["app"] = win32com.client.GetActiveObject("AutoCAD.Application") + _COM_STATE["app"] = win32com.client.GetActiveObject(progid) except Exception as exc: - log.debug("GetActiveObject failed, trying Dispatch: %s", exc) + log.debug("GetActiveObject(%r) failed, trying Dispatch: %s", progid, exc) try: - _COM_STATE["app"] = win32com.client.Dispatch("AutoCAD.Application") + _COM_STATE["app"] = win32com.client.Dispatch(progid) _COM_STATE["app"].Visible = True except Exception as exc: raise RuntimeError( - f"Cannot connect to AutoCAD: {exc}. Make sure AutoCAD is installed and running." + f"Cannot connect to CAD application {progid!r}: {exc}. Make sure it is " + f"installed and running. (Set CAD_PROGID to change it; default " + f"{config.DEFAULT_CAD_PROGID!r}.)" ) from exc return _COM_STATE["app"] @@ -149,8 +164,20 @@ def _acad_doc(): def _msp(): - """Return ModelSpace of active document.""" - return _acad_doc().ModelSpace + """The space new geometry goes into — the active layout's block. + + This returned ``ModelSpace`` unconditionally, so ``layout_set_current`` + reported success and everything drawn afterwards still landed in model + space. ``ActiveLayout.Block`` is the same object as ``ModelSpace`` while the + Model tab is active, so the model-space path is unchanged; on a paper layout + it is the sheet, which is where a title block belongs. + """ + doc = _acad_doc() + try: + return doc.ActiveLayout.Block + except Exception as exc: # never strand a write over a layout probe + log.debug("ActiveLayout.Block unavailable (%s); using ModelSpace", exc) + return doc.ModelSpace _BUILTIN_LINETYPES = {"continuous", "bylayer", "byblock"} @@ -252,6 +279,33 @@ def _apply_dim_tolerance(dim, tol_upper, tol_lower, tol_mode="none", text_overri log.debug("dim tolerance apply failed (version-dependent COM props): %s", exc) +#: ActiveX object names that bound no area at all. Not a capability gap — no +#: engine can give a LINE an area — so these raise a plain error. +_COM_NO_AREA_TYPES = frozenset( + { + "AcDbLine", + "AcDbText", + "AcDbMText", + "AcDbPoint", + "AcDbBlockReference", + "AcDbRotatedDimension", + "AcDbAlignedDimension", + "AcDbLeader", + "AcDb3dPolyline", + "AcDbRay", + "AcDbXline", + } +) + + +def _com_bulge(entity, index: int) -> float: + """GetBulge is polyline-only and absent on some hosts; 0.0 means straight.""" + try: + return float(entity.GetBulge(index)) + except Exception: + return 0.0 + + def _entity_info(entity) -> EntityInfo: """Convert a COM entity object to EntityInfo dataclass.""" try: @@ -291,6 +345,20 @@ def _entity_info(entity) -> EntityInfo: elif obj_name in ("AcDbLWPolyline", "AcDb2dPolyline"): coords = list(entity.Coordinates) pts = [[coords[i], coords[i + 1]] for i in range(0, len(coords), 2)] + # ActiveX hands back WCS for every point property on this backend + # *except* this one: "LightweightPolyline object: the variant is an + # array of 2D points in OCS." Scoped to AcDbLWPolyline deliberately — + # AcDb2dPolyline's Coordinates may be 3D triples, and changing its + # stride on a doc reading alone, with no way to verify against live + # AutoCAD from here, risks breaking a path that works today. + if obj_name == "AcDbLWPolyline": + try: + normal = tuple(entity.Normal) + if not ocs.is_wcs_frame(normal): + elevation = float(getattr(entity, "Elevation", 0.0)) + pts = [ocs.to_wcs_2d(normal, p[0], p[1], elevation) for p in pts] + except Exception as exc: + log.debug("OCS normalisation of polyline coordinates failed: %s", exc) props["points"] = pts props["closed"] = bool(entity.Closed) props["length"] = entity.Length @@ -459,9 +527,41 @@ class ComBackend(AutoCADBackend): features={ "drawing_2d": FeatureCapability(True, "native"), "dxf": FeatureCapability(True, "native"), + "dwg": FeatureCapability(True, "native"), "pdf": FeatureCapability(True, "native"), "png": FeatureCapability(True, "native"), "transactions": FeatureCapability(True, "undo_mark"), + "audit_detail": FeatureCapability( + False, reason="audit_result_not_readable_over_com" + ), + "undo_history": FeatureCapability(True, "autocad_native"), + "chspace": FeatureCapability(False, reason="unverified_against_live_autocad"), + "revcloud": FeatureCapability(False, reason="no_activex_member_command_only"), + "wipeout": FeatureCapability( + False, reason="addwipeout_absent_verified_autocad_2026" + ), + "mtext_background_color": FeatureCapability( + False, reason="backgroundfillcolor_absent_verified_autocad_2026" + ), + "boundary_trace": FeatureCapability(False, reason="no_activex_member_command_only"), + "hatch_edge_paths": FeatureCapability( + False, reason="activex_appends_loops_as_objects_not_typed_edges" + ), + "handle_overlay": FeatureCapability( + False, reason="window_capture_has_no_render_to_label" + ), + "measure_area_acis": FeatureCapability(True, "native"), + "ocs_normalized": FeatureCapability( + True, + "activex_wcs", + reason=( + "activex_returns_wcs_natively;lwpolyline_coordinates_translated_locally;" + "2d_polyline_coordinates_not_verified_against_live_autocad" + ), + ), + "ocs_tilted_plane": FeatureCapability( + False, reason="2d_xy_cannot_address_a_tilted_plane" + ), "table": FeatureCapability(True, "native"), "mleader": FeatureCapability(True, "native"), "preflight": FeatureCapability(True, "shared"), @@ -482,7 +582,15 @@ class ComBackend(AutoCADBackend): # is not open yet — it will be found as soon as the user opens it. self._executor = ThreadPoolExecutor(max_workers=1, initializer=_com_init) self._connected = True - log.info("COM backend ready (will connect to AutoCAD on first tool call)") + progid = config.settings.cad_progid + log.info("COM backend ready (will connect to %s on first tool call)", progid) + if progid != config.DEFAULT_CAD_PROGID: + log.warning( + "CAD_PROGID=%r: only %s is developed and tested against. The connection may " + "succeed while individual tools fail on ActiveX differences.", + progid, + config.DEFAULT_CAD_PROGID, + ) async def disconnect(self) -> None: if self._executor: @@ -525,6 +633,13 @@ class ComBackend(AutoCADBackend): Without the timeout, an unresponsive AutoCAD (modal dialog, long Regen, crashed COM bridge) would block the single-thread STA executor forever and freeze every subsequent tool call. + + Only a *real* deadline overrun may rebuild the apartment: on 3.11+ + ``TimeoutError`` is ``asyncio.TimeoutError`` (and an ``OSError`` + subclass), so a wrapped call that raises one itself reaches the handler + looking identical to an expired deadline. ``future.cancelled()`` tells + them apart — ``wait_for`` cancels the future it is waiting on, an + ordinary failure completes it — mirroring ``EzdxfBackend._async``. """ if self._executor is None: raise RuntimeError("ComBackend not connected. Call connect() first.") @@ -536,6 +651,13 @@ class ComBackend(AutoCADBackend): return await asyncio.wait_for(future, timeout=timeout) return await future except TimeoutError as e: + if not future.cancelled(): + # The call raised TimeoutError instead of overrunning the + # deadline (a dead network share, a re-raised socket timeout — + # and with timeout<=0 there is no deadline at all). That is an + # ordinary failure: let it through unchanged rather than + # blaming AutoCAD and discarding a live STA connection. + raise log.error("COM call timed out after %.1fs; rebuilding executor", timeout) # The worker thread is still blocked inside SendCommand and cannot be # cancelled. Abandon it (shutdown(wait=False)) and start a fresh @@ -715,6 +837,138 @@ class ComBackend(AutoCADBackend): return await self._run(_sync) + @staticmethod + def _layout_name(raw: str) -> str: + return str(raw or "").strip() + + @staticmethod + def _layout_names(doc) -> list[str]: + return [doc.Layouts.Item(i).Name for i in range(doc.Layouts.Count)] + + def _find_layout(self, doc, raw: str) -> str | None: + """Resolve a layout name case-insensitively, as AutoCAD itself does. + + A plain ``name in names`` test is case-sensitive in Python and would + both refuse an existing tab typed in another case and let a duplicate + past into ``Layouts.Add``. + """ + name = self._layout_name(raw) + if not name: + return None + lowered = name.lower() + return next((n for n in self._layout_names(doc) if n.lower() == lowered), None) + + async def layout_delete(self, name: str) -> dict: + def _sync(): + # VERIFIED against a live AutoCAD 2026 (2026-08-05). + doc = _acad_doc() + if not self._layout_name(name): + return {"ok": False, "error": "Layout name must not be blank"} + target = self._find_layout(doc, name) + if target is None: + return {"ok": False, "error": f"Layout not found: {name}"} + if target == "Model": + return {"ok": False, "error": "Model space cannot be deleted"} + names = self._layout_names(doc) + if len([n for n in names if n != "Model"]) <= 1: + return { + "ok": False, + "error": ( + f"Cannot delete {target}: a drawing must keep at least one " + "paper-space layout" + ), + } + + layout = doc.Layouts.Item(target) + destroyed = layout.Block.Count + if doc.ActiveLayout.Name == target: + doc.ActiveLayout = doc.Layouts.Item("Model") + layout.Delete() + return { + "ok": True, + "deleted": target, + "entities_destroyed": destroyed, + "current": doc.ActiveLayout.Name, + } + + return await self._run(_sync) + + async def layout_rename(self, old_name: str, new_name: str) -> dict: + def _sync(): + # VERIFIED against a live AutoCAD 2026 (2026-08-05). + doc = _acad_doc() + new = self._layout_name(new_name) + if not self._layout_name(old_name) or not new: + return {"ok": False, "error": "Layout names must not be blank"} + old = self._find_layout(doc, old_name) + if old is None: + return {"ok": False, "error": f"Layout not found: {old_name}"} + if old == "Model" or new.lower() == "model": + return {"ok": False, "error": "Model space cannot be renamed or shadowed"} + clash = self._find_layout(doc, new) + if clash is not None and clash != old: + return {"ok": False, "error": f"Layout already exists: {clash}"} + if any(ch in new for ch in '<>/\\":;?*|,=`'): + return {"ok": False, "error": f"Invalid layout name: {new!r}"} + + doc.Layouts.Item(old).Name = new + return {"ok": True, "old": old, "new": new, "current": doc.ActiveLayout.Name} + + return await self._run(_sync) + + async def layout_copy(self, source: str, new_name: str) -> dict: + def _sync(): + # VERIFIED against a live AutoCAD 2026 (2026-08-05). AutoCAD's own + # LAYOUT Copy option is command-line only, so this reproduces it + # with CopyFrom + CopyObjects. + doc = _acad_doc() + dst_name = self._layout_name(new_name) + if not self._layout_name(source) or not dst_name: + return {"ok": False, "error": "Layout names must not be blank"} + src_name = self._find_layout(doc, source) + if src_name is None: + return {"ok": False, "error": f"Layout not found: {source}"} + if src_name == "Model": + return { + "ok": False, + "error": "Model space cannot be copied to a sheet; use viewport_create", + } + if dst_name.lower() == "model": + return {"ok": False, "error": "Model space cannot be overwritten"} + clash = self._find_layout(doc, dst_name) + if clash is not None: + return {"ok": False, "error": f"Layout already exists: {clash}"} + + src = doc.Layouts.Item(src_name) + dst = doc.Layouts.Add(dst_name) + dst.CopyFrom(src) # page setup / plot configuration + + items = [src.Block.Item(i) for i in range(src.Block.Count)] + copied = 0 + skipped: list[str] = [] + if items: + objects = win32com.client.VARIANT(pythoncom.VT_ARRAY | pythoncom.VT_DISPATCH, items) + try: + doc.CopyObjects(objects, dst.Block) + copied = len(items) + except Exception as exc: + log.warning("layout_copy: CopyObjects failed (%s)", exc) + skipped = sorted({obj.ObjectName for obj in items}) + + return { + "ok": True, + "source": src_name, + "layout": dst_name, + "entities_copied": copied, + "skipped": skipped, + # AutoCAD remaps associativity inside CopyObjects itself, so + # there is nothing for this backend to re-point by hand. + "associativity_remapped": 0, + "associativity_dropped": 0, + } + + return await self._run(_sync) + async def viewport_create( self, layout: str, @@ -748,10 +1002,13 @@ class ComBackend(AutoCADBackend): viewport.CustomScale = float(scale) except Exception: # some verticals expose StandardScale only pass - try: - viewport.ViewCenter = _av([float(view_center_x), float(view_center_y)]) - except Exception: - pass + # `Target`, not `ViewCenter`: AcadPViewport has no ViewCenter + # member at all. Verified against AutoCAD 2026 — the old call + # raised every single time and the bare `except: pass` around + # it swallowed that, so view_center_x/y were silently ignored + # and every COM viewport looked at the model origin instead of + # where the caller asked. The failure is reported now. + viewport.Target = _apoint(view_center_x, view_center_y) return { "ok": True, "handle": viewport.Handle, @@ -765,6 +1022,684 @@ class ComBackend(AutoCADBackend): return await self._run(_sync) + # ── selection filters (M8 / F1) ────────────────────────────────────────── + # + # VERIFIED against a live AutoCAD 2026 (2026-08-05). + + _SELECTION_MODES = ("window", "crossing") + + def _selection_mode(self, mode: str) -> tuple[str | None, dict | None]: + normalised = str(mode or "").strip().lower() + if normalised not in self._SELECTION_MODES: + return None, { + "ok": False, + "error": ( + f"Unknown mode {mode!r}; valid modes are {', '.join(self._SELECTION_MODES)}" + ), + } + return normalised, None + + @staticmethod + def _com_bbox(entity): + lo, hi = entity.GetBoundingBox() + return (float(lo[0]), float(lo[1])), (float(hi[0]), float(hi[1])) + + def _com_select(self, predicate, entity_type: str, layer: str) -> dict: + doc = _acad_doc() + wanted_type = str(entity_type or "").strip().upper() + wanted_layer = str(layer or "").strip() + handles = [] + for i in range(doc.ModelSpace.Count): + entity = doc.ModelSpace.Item(i) + try: + lo, hi = self._com_bbox(entity) + except Exception: + continue + if not predicate(lo, hi): + continue + if wanted_type and self._dxftype_of(entity) != wanted_type: + continue + if wanted_layer and entity.Layer != wanted_layer: + continue + handles.append(entity.Handle) + return handles + + @staticmethod + def _dxftype_of(entity) -> str: + name = entity.ObjectName + return name[4:].upper() if name.startswith("AcDb") else name.upper() + + async def selection_window( + self, x1, y1, x2, y2, mode: str = "window", entity_type: str = "", layer: str = "" + ) -> dict: + def _sync(): + resolved, refusal = self._selection_mode(mode) + if refusal: + return refusal + lo_x, hi_x = sorted((float(x1), float(x2))) + lo_y, hi_y = sorted((float(y1), float(y2))) + if lo_x == hi_x or lo_y == hi_y: + return { + "ok": False, + "error": ( + "The selection box has zero area; give two opposite corners that " + "differ in both x and y" + ), + } + + def _inside(lo, hi): + return lo_x <= lo[0] and lo_y <= lo[1] and hi[0] <= hi_x and hi[1] <= hi_y + + def _overlaps(lo, hi): + return not (hi[0] < lo_x or lo[0] > hi_x or hi[1] < lo_y or lo[1] > hi_y) + + handles = self._com_select( + _inside if resolved == "window" else _overlaps, entity_type, layer + ) + return {"ok": True, "handles": handles, "count": len(handles), "mode": resolved} + + return await self._run(_sync) + + async def selection_polygon( + self, points, mode: str = "window", entity_type: str = "", layer: str = "" + ) -> dict: + def _sync(): + resolved, refusal = self._selection_mode(mode) + if refusal: + return refusal + vertices = self._plane_points(points) + if vertices is None: + return {"ok": False, "error": "points must be a list of [x, y] pairs"} + if len(vertices) < 3: + return { + "ok": False, + "error": f"A selection polygon needs at least 3 points; got {len(vertices)}", + } + + def _in_polygon(px, py) -> bool: + inside = False + count = len(vertices) + for i in range(count): + ax, ay = vertices[i] + bx, by = vertices[(i + 1) % count] + if (ay > py) != (by > py) and px < (bx - ax) * (py - ay) / (by - ay) + ax: + inside = not inside + return inside + + def _inside(lo, hi): + corners = ((lo[0], lo[1]), (hi[0], lo[1]), (hi[0], hi[1]), (lo[0], hi[1])) + return all(_in_polygon(*corner) for corner in corners) + + def _overlaps(lo, hi): + corners = ((lo[0], lo[1]), (hi[0], lo[1]), (hi[0], hi[1]), (lo[0], hi[1])) + return any(_in_polygon(*corner) for corner in corners) + + handles = self._com_select( + _inside if resolved == "window" else _overlaps, entity_type, layer + ) + return {"ok": True, "handles": handles, "count": len(handles), "mode": resolved} + + return await self._run(_sync) + + async def selection_filter( + self, + entity_type: str = "", + layer: str = "", + color: int | None = None, + linetype: str = "", + min_area: float | None = None, + ) -> dict: + def _sync(): + if min_area is not None and float(min_area) < 0: + return {"ok": False, "error": "min_area cannot be negative; no area is negative"} + doc = _acad_doc() + wanted_type = str(entity_type or "").strip().upper() + wanted_layer = str(layer or "").strip() + wanted_linetype = str(linetype or "").strip() + filtered_by = [ + name + for name, active in ( + ("entity_type", bool(wanted_type)), + ("layer", bool(wanted_layer)), + ("color", color is not None), + ("linetype", bool(wanted_linetype)), + ("min_area", min_area is not None), + ) + if active + ] + + handles = [] + for i in range(doc.ModelSpace.Count): + entity = doc.ModelSpace.Item(i) + if wanted_type and self._dxftype_of(entity) != wanted_type: + continue + if wanted_layer and entity.Layer != wanted_layer: + continue + if color is not None and int(entity.Color) != int(color): + continue + if wanted_linetype and entity.Linetype != wanted_linetype: + continue + if min_area is not None: + try: + area = float(entity.Area) + except Exception: + continue # no area at all is not "zero area" + if area < float(min_area): + continue + handles.append(entity.Handle) + return { + "ok": True, + "handles": handles, + "count": len(handles), + "filtered_by": filtered_by, + } + + return await self._run(_sync) + + # ── hatch depth (M8 / F4) ──────────────────────────────────────────────── + + _HATCH_STYLES = ("normal", "outer", "ignore") + + def _resolve_com_hatch(self, doc, handle): + try: + entity = doc.HandleToObject(str(handle).strip().upper()) + except Exception: + raise RuntimeError(f"Entity with handle '{handle}' not found.") from None + if entity.ObjectName != "AcDbHatch": + return None, { + "ok": False, + "error": f"Handle {handle} is a {self._dxftype_of(entity)}, not a HATCH", + } + return entity, None + + async def hatch_set_gradient( + self, + handle, + color1, + color2, + rotation: float = 0.0, + centered: float = 0.0, + one_color: bool = False, + tint: float = 0.0, + name: str = "LINEAR", + ) -> dict: + def _sync(): + # VERIFIED against a live AutoCAD 2026 (2026-08-05). + doc = _acad_doc() + hatch, refusal = self._resolve_com_hatch(doc, handle) + if refusal: + return refusal + try: + rgb1 = tuple(int(c) for c in color1) + rgb2 = tuple(int(c) for c in color2) + except (TypeError, ValueError): + return {"ok": False, "error": "color1 and color2 must be [r, g, b] triples"} + if len(rgb1) != 3 or len(rgb2) != 3: + return {"ok": False, "error": "color1 and color2 must be [r, g, b] triples"} + + hatch.HatchObjectType = 1 # gradient + hatch.GradientName = str(name) + hatch.GradientAngle = math.radians(float(rotation)) + hatch.GradientCentered = bool(centered) + first, second = doc.Application.GetInterfaceObject("AutoCAD.AcCmColor.25"), None + first.SetRGB(*rgb1) + hatch.GradientColor1 = first + second = doc.Application.GetInterfaceObject("AutoCAD.AcCmColor.25") + second.SetRGB(*rgb2) + hatch.GradientColor2 = second + return { + "ok": True, + "handle": hatch.Handle, + "gradient": { + "color1": list(rgb1), + "color2": list(rgb2), + "rotation": float(rotation), + "centered": float(centered), + "one_color": bool(one_color), + "tint": float(tint), + "name": str(name), + }, + } + + return await self._run(_sync) + + async def hatch_edit( + self, + handle, + pattern: str = "", + scale: float | None = None, + angle: float | None = None, + color: int | None = None, + style: str = "", + ) -> dict: + def _sync(): + doc = _acad_doc() + hatch, refusal = self._resolve_com_hatch(doc, handle) + if refusal: + return refusal + if scale is not None and float(scale) <= 0: + return {"ok": False, "error": "scale must be > 0"} + wanted_style = str(style).strip().lower() + if wanted_style and wanted_style not in self._HATCH_STYLES: + return { + "ok": False, + "error": ( + f"Unknown style {style!r}; valid styles are {', '.join(self._HATCH_STYLES)}" + ), + } + + def _snapshot(): + return { + "pattern": hatch.PatternName, + "scale": float(hatch.PatternScale), + "angle": float(hatch.PatternAngle), + "color": int(hatch.Color), + "style": int(hatch.HatchStyle), + } + + before = _snapshot() + if pattern: + hatch.SetPattern(1, str(pattern)) # acHatchPatternTypePredefined + if scale is not None: + hatch.PatternScale = float(scale) + if angle is not None: + hatch.PatternAngle = math.radians(float(angle)) + if color is not None: + hatch.Color = int(color) + if wanted_style: + hatch.HatchStyle = self._HATCH_STYLES.index(wanted_style) + hatch.Evaluate() + + after = _snapshot() + return { + "ok": True, + "handle": hatch.Handle, + "changed": [key for key in before if before[key] != after[key]], + } + + return await self._run(_sync) + + async def hatch_add_boundary(self, handle, edges) -> dict: + """Refused on the live backend: ActiveX takes boundary loops as whole + objects (AppendOuterLoop / AppendInnerLoop), not as typed edges, so the + curved-edge fidelity this tool exists for cannot be expressed.""" + raise UnsupportedCapabilityError( + "hatch_edge_paths", + "hatch_add_boundary: ActiveX appends boundary loops as existing objects rather " + "than typed edges, so an arc edge cannot be given directly. Draw the boundary " + "entities and use entity_create_hatch, or switch to the headless backend " + "(AUTOCAD_MCP_BACKEND=ezdxf).", + ) + + async def analysis_list_properties(self, handle: str) -> dict: + def _sync(): + # VERIFIED against a live AutoCAD 2026 (2026-08-05). ActiveX has no + # generic attribute dump, so this reads the documented members per + # object type. + doc = _acad_doc() + try: + entity = doc.HandleToObject(str(handle).strip().upper()) + except Exception: + raise RuntimeError(f"Entity with handle '{handle}' not found.") from None + + dump: dict = {} + for member in ( + "Layer", + "Color", + "Linetype", + "LinetypeScale", + "Lineweight", + "Thickness", + "Visible", + "Normal", + "Radius", + "Center", + "StartPoint", + "EndPoint", + "InsertionPoint", + "TextString", + "Height", + "Rotation", + "ObliqueAngle", + "StyleName", + "Name", + "XScaleFactor", + "YScaleFactor", + "Area", + "Length", + ): + try: + raw = getattr(entity, member) + except Exception: + continue + key = member[0].lower() + member[1:] + if isinstance(raw, tuple): + dump[key] = [float(v) for v in raw] + elif isinstance(raw, (int, float, str, bool)): + dump[key] = raw + + info = _entity_info(entity) + return { + "ok": True, + "handle": info.handle, + "type": info.type, + "layer": info.layer, + "properties": info.properties, + "dxf_attributes": dump, + } + + return await self._run(_sync) + + # ── annotation objects (M8 / F15) ──────────────────────────────────────── + # + # VERIFIED against a live AutoCAD 2026 (2026-08-05). Two members turned out + # not to exist and are now typed refusals rather than code: AddWipeout and + # AcDbMText.BackgroundFillColor. + + #: Same scope as the headless backend, and DIMENSION is out for the same + #: reason: its TextOverride is the '<>' placeholder, not the measurement. + _TEXT_BEARING_OBJECTS = ("AcDbText", "AcDbMText", "AcDbAttribute", "AcDbAttributeDefinition") + + @staticmethod + def _plane_points(points) -> list[tuple[float, float]] | None: + try: + return [(float(p[0]), float(p[1])) for p in points] + except (TypeError, ValueError, IndexError): + return None + + # ``entity_create_wipeout`` is deliberately not implemented here. Verified + # against AutoCAD 2026: ``ModelSpace.AddWipeout`` does not exist — + # ``AttributeError: .AddWipeout``. ActiveX has no wipeout + # constructor; WIPEOUT is a command. The contract declares this + # @capability("wipeout") and the live backend inherits the typed refusal. + + # ``entity_create_revcloud`` is deliberately not implemented here: ActiveX + # exposes no revision-cloud member, and driving the REVCLOUD command blind + # against a live drawing cannot be verified from this machine. The contract + # declares it @capability("revcloud"), so this backend inherits the typed + # refusal. + + async def text_set_background( + self, handle, enabled: bool = True, color: int | None = None, scale: float = 1.5 + ) -> dict: + def _sync(): + # VERIFIED against a live AutoCAD 2026 (2026-08-05): BackgroundFill + # is settable, BackgroundFillColor does not exist at all. + doc = _acad_doc() + try: + entity = doc.HandleToObject(str(handle).strip().upper()) + except Exception: + return {"ok": False, "error": f"Entity handle not found: {handle}"} + if entity.ObjectName != "AcDbMText": + return { + "ok": False, + "error": ( + f"Handle {handle} is {entity.ObjectName}; only MTEXT carries a " + "background fill." + ), + } + if not enabled: + entity.BackgroundFill = False + return {"ok": True, "handle": entity.Handle, "enabled": False} + factor = float(scale) + if factor < 1.0: + return { + "ok": False, + "error": ( + f"scale must be >= 1.0 (got {factor:g}); a box smaller than its " + "text is a stripe through the text, not a mask" + ), + } + if color is not None: + # Verified against AutoCAD 2026: AcDbMText has no + # BackgroundFillColor member at all — it raises AttributeError + # on read *and* on write, with an int and with an AcCmColor + # object alike. Enabling the fill without the requested colour + # and reporting success would be exactly the "did something + # else and said nothing" this release exists to remove. + raise UnsupportedCapabilityError( + "mtext_background_color", + "text_set_background: ActiveX exposes no BackgroundFillColor member on " + "MTEXT, so the requested colour cannot be applied. Omit `color` to switch " + "the mask on in the drawing's background colour, or use the headless " + "backend (AUTOCAD_MCP_BACKEND=ezdxf) to set it.", + ) + entity.BackgroundFill = True + return { + "ok": True, + "handle": entity.Handle, + "enabled": True, + "color": None, + "scale": factor, + "note": "ActiveX cannot set the mask colour; the drawing background is used", + } + + return await self._run(_sync) + + async def text_find_replace( + self, + find: str, + replace: str, + layer: str | None = None, + match_case: bool = True, + dry_run: bool = False, + ) -> dict: + def _sync(): + # VERIFIED against a live AutoCAD 2026 (2026-08-05). + import re + + needle = str(find) + if not needle: + return { + "ok": False, + "error": ( + "find must not be empty: an empty pattern matches between every " + "character and would shred the text" + ), + } + doc = _acad_doc() + pattern = re.compile(re.escape(needle), 0 if match_case else re.IGNORECASE) + + changed: list[dict] = [] + for i in range(doc.ModelSpace.Count): + entity = doc.ModelSpace.Item(i) + if entity.ObjectName not in self._TEXT_BEARING_OBJECTS: + continue + if layer and entity.Layer != layer: + continue + current = entity.TextString + if not current or not pattern.search(current): + continue + updated = pattern.sub(replace, current) + if not dry_run: + entity.TextString = updated + changed.append( + { + "handle": entity.Handle, + "type": entity.ObjectName, + "before": current, + "after": updated, + } + ) + + return { + "ok": True, + "replaced": len(changed), + "entities": changed, + "searched_types": list(self._TEXT_BEARING_OBJECTS), + "note": ( + "DIMENSION text is not searched: its TextOverride holds the '<>' " + "placeholder rather than the measured value." + ), + "dry_run": bool(dry_run), + } + + return await self._run(_sync) + + # ── viewports ──────────────────────────────────────────────────────────── + # + # VERIFIED against a live AutoCAD 2026 (2026-08-05). This block is why the + # verification was worth running: the code read `ViewCenter`, which is not + # a member of AcadPViewport, and viewport_list died on it on the first + # call. viewport_create had the same line wrapped in `except: pass`, so it + # had been silently ignoring view_center_x/y since v1.4. + + @staticmethod + def _com_viewports(layout): + block = layout.Block + return [ + block.Item(i) for i in range(block.Count) if block.Item(i).ObjectName == "AcDbViewport" + ] + + def _resolve_com_viewport(self, doc, handle: str): + """``(viewport, layout_name, None)`` or ``(None, None, refusal)``.""" + key = str(handle or "").strip().upper() + if not key: + return None, None, {"ok": False, "error": "Viewport handle must not be blank"} + try: + entity = doc.HandleToObject(key) + except Exception: + return None, None, {"ok": False, "error": f"Viewport handle not found: {handle}"} + if entity.ObjectName != "AcDbViewport": + return ( + None, + None, + { + "ok": False, + "error": f"Handle {key} is a {entity.ObjectName}, not a viewport", + }, + ) + layout_name = None + try: + for i in range(doc.Layouts.Count): + layout = doc.Layouts.Item(i) + if any(vp.Handle == key for vp in self._com_viewports(layout)): + layout_name = layout.Name + break + except Exception as exc: # pragma: no cover - live COM only + log.debug("could not locate the layout owning viewport %s: %s", key, exc) + return entity, layout_name, None + + @staticmethod + def _com_viewport_row(viewport, layout_name: str) -> dict: + center = viewport.Center + # The model point the viewport looks at is `Target`; there is no + # `ViewCenter` member on AcadPViewport (verified against AutoCAD 2026). + view_center = viewport.Target + height = float(viewport.Height) + try: + scale = float(viewport.CustomScale) + except Exception: + scale = None + return { + "handle": viewport.Handle, + "layout": layout_name, + "center": [float(center[0]), float(center[1])], + "width": float(viewport.Width), + "height": height, + "view_center": [float(view_center[0]), float(view_center[1])], + "view_height": (height / scale if scale else None), + "scale": scale, + "locked": bool(viewport.DisplayLocked), + "status": int(bool(viewport.ViewportOn)), + "id": None, # AutoCAD does not expose the DXF viewport id via ActiveX + # Unknown, not False: ActiveX has no main-viewport predicate, and + # claiming "definitely not the main viewport" is a stronger + # statement than this backend can make. + "is_main": None, + } + + async def viewport_list(self, layout: str | None = None) -> dict: + def _sync(): + doc = _acad_doc() + names = [doc.Layouts.Item(i).Name for i in range(doc.Layouts.Count)] + if layout is None: + targets = [n for n in names if n != "Model"] + else: + name = self._layout_name(layout) + if not name: + return {"ok": False, "error": "Layout name must not be blank"} + if name == "Model": + return {"ok": False, "error": "Model space has no paper-space viewports"} + if name not in names: + return {"ok": False, "error": f"Layout not found: {name}"} + targets = [name] + + rows = [ + self._com_viewport_row(vp, name) + for name in targets + for vp in self._com_viewports(doc.Layouts.Item(name)) + ] + return { + "ok": True, + "viewports": rows, + "count": len(rows), + "note": "ActiveX exposes no main-viewport predicate, so is_main is null", + } + + return await self._run(_sync) + + async def viewport_set_scale(self, handle: str, scale: float) -> dict: + def _sync(): + if float(scale) <= 0: + return {"ok": False, "error": "scale must be > 0 (paper:model, e.g. 0.5 for 1:2)"} + doc = _acad_doc() + viewport, _, refusal = self._resolve_com_viewport(doc, handle) + if refusal: + return refusal + viewport.CustomScale = float(scale) + return { + "ok": True, + "handle": viewport.Handle, + "scale": float(viewport.CustomScale), + "view_height": float(viewport.Height) / float(viewport.CustomScale), + "note": "geometric scale only; annotative text and dimensions do not resize", + } + + return await self._run(_sync) + + async def viewport_lock(self, handle: str, locked: bool = True) -> dict: + def _sync(): + doc = _acad_doc() + viewport, _, refusal = self._resolve_com_viewport(doc, handle) + if refusal: + return refusal + viewport.DisplayLocked = bool(locked) + return { + "ok": True, + "handle": viewport.Handle, + "locked": bool(viewport.DisplayLocked), + } + + return await self._run(_sync) + + async def viewport_delete(self, handle: str, force: bool = False) -> dict: + def _sync(): + doc = _acad_doc() + viewport, layout_name, refusal = self._resolve_com_viewport(doc, handle) + if refusal: + return refusal + key = viewport.Handle + viewport.Delete() + return { + "ok": True, + "handle": key, + "layout": layout_name, + # Unknown rather than False: ActiveX has no main-viewport + # predicate, and AutoCAD owns the tab's view state itself, so + # there is no pointer for this backend to repair either. + "was_main": None, + "viewport_handle_repaired": False, + } + + return await self._run(_sync) + + # ``entity_change_space`` is deliberately *not* implemented here. The + # contract declares it ``@capability("chspace")``, so this backend inherits + # a typed refusal carrying that key rather than an unverified + # CopyObjects-and-delete that would destroy and recreate geometry in the + # operator's open drawing when it goes wrong. + # ── 3D solids (native ActiveX; gated behind ENABLE_3D at the tool layer) ─ async def solid_box( @@ -865,12 +1800,67 @@ class ComBackend(AutoCADBackend): return await self._run(_sync) async def drawing_audit(self) -> dict: + """Run AutoCAD's AUDIT with fixing enabled, and admit what we cannot see. + + The ``Y`` answers AUDIT's "Fix any errors detected?" prompt, so this + backend does repair — it just gets nothing machine-readable back from + ``SendCommand``. The counts are therefore reported as ``None`` rather + than ``0``: zero would mean "nothing was wrong", which we do not know. + ``AUDITCTL`` is restored afterwards; it is the user's setting, and + leaving it at 1 silently makes AutoCAD write an ``.adt`` log beside + every drawing they audit from then on. + """ + def _sync(): doc = _acad_doc() app = _acad_app() + try: + previous = app.GetVariable("AUDITCTL") + except Exception: # not every AutoCAD-compatible host exposes it + previous = None app.SetVariable("AUDITCTL", 1) - doc.SendCommand("_AUDIT Y\n") - return {"ok": True, "message": "Audit completed"} + try: + doc.SendCommand("_AUDIT Y\n") + finally: + if previous is not None: + try: + app.SetVariable("AUDITCTL", previous) + except Exception: + log.debug("could not restore AUDITCTL", exc_info=True) + + # Only report a log we can actually see. SendCommand queues, so at + # this point AUDIT has very likely not run yet and the .adt does not + # exist — handing back a path to a missing file would be a small + # version of the same lie as claiming the repair count. + log_path = None + try: + full = doc.FullName # empty on a drawing that was never saved + if full: + candidate = Path(full).with_suffix(".adt") + if candidate.exists(): + log_path = str(candidate) + except Exception: + log.debug("could not derive the .adt log path", exc_info=True) + + return { + "ok": True, + "repaired": None, + "fixes": [], + "fix_count": None, + "errors": [], + "error_count": None, + "detail": "unavailable", + "capability": "audit_detail", + "message": ( + "AUDIT was dispatched to AutoCAD with fixing enabled. SendCommand " + "queues the command and returns immediately, so this call cannot " + "confirm that it ran, let alone what it changed: the repair and " + "error counts are unknown, not zero. AutoCAD writes an .adt log " + "beside the drawing when AUDITCTL is on — that is the only place " + "the detail exists." + ), + "log_path": log_path, + } return await self._run(_sync) @@ -1730,6 +2720,33 @@ class ComBackend(AutoCADBackend): return await self._run(_sync) + async def entity_count(self, type_filter=None, layer_filter=None) -> int: + # Same filter arms as entity_list, and the same per-entity skip-on-error + # policy, so the count and the page always describe one set. What it + # skips is `_entity_info(ent)` — the property/extents extraction, which + # is several COM round trips per entity against the two cheap attribute + # reads kept here. + def _sync(): + mspace = _msp() + count = 0 + for i in range(mspace.Count): + try: + ent = mspace.Item(i) + if ( + type_filter + and type_filter.upper() != ent.ObjectName.replace("AcDb", "").upper() + ): + continue + if layer_filter and layer_filter.lower() != ent.Layer.lower(): + continue + count += 1 + except Exception as exc: + log.debug("entity_count: skip entity at index %d: %s", i, exc) + continue + return count + + return await self._run(_sync) + # ── layer management ────────────────────────────────────────────────────── async def layer_list(self) -> list[LayerInfo]: @@ -2032,11 +3049,49 @@ class ComBackend(AutoCADBackend): base_x=0.0, base_y=0.0, ) -> dict: - return { - "ok": False, - "error": "block_create_from_entities not supported in COM backend. " - "Use system_run_command with _BLOCK instead.", - } + """Build a block definition from entities already in the drawing. + + This used to refuse and point at ``system_run_command`` with ``_BLOCK``, + pushing callers through the free-text escape hatch for something ActiveX + does directly: ``Blocks.Add`` creates the definition and + ``Document.CopyObjects`` puts entities into it. The originals stay in + model space, matching the headless backend rather than AutoCAD's BLOCK + command (which consumes them). + """ + + def _sync(): + doc = _acad_doc() + objects, skipped = [], [] + for handle in handles: + try: + objects.append(doc.HandleToObject(str(handle))) + except Exception as exc: + log.debug("resolving %s for block %s: %s", handle, name, exc) + skipped.append(str(handle)) + # Resolve before creating, so a call where every handle was a typo + # does not leave an empty definition behind. + if not objects: + raise RuntimeError( + f"block_create_from_entities: none of the handles resolved " + f"({', '.join(skipped) or 'no handles given'}), so there is nothing " + f"to put in block {name!r}. No definition was created." + ) + + block = doc.Blocks.Add(_apoint(float(base_x), float(base_y), 0.0), str(name)) + doc.CopyObjects( + win32com.client.VARIANT(pythoncom.VT_ARRAY | pythoncom.VT_DISPATCH, objects), + block, + ) + _regen() + return { + "ok": True, + "name": name, + "entity_count": len(objects), + "skipped": skipped, + "backend": "com", + } + + return await self._run(_sync) # ── analysis / query ────────────────────────────────────────────────────── @@ -2098,6 +3153,77 @@ class ComBackend(AutoCADBackend): async def analysis_measure_area(self, points) -> float: return shoelace_area(points) + async def entity_measure(self, handle, flatten_tolerance: float = 0.001) -> dict: + """Ask AutoCAD, which already knows. + + This backend has had a document, a ``HandleToObject`` lookup and an + ActiveX ``Area`` property all along, and still answered area questions + with a shoelace over coordinates the caller typed in. ``Area`` is exact + here for every boundary type including REGION and 3DSOLID, which is + precisely the gap the headless engine refuses on. + """ + + def _sync(): + ent = _acad_doc().HandleToObject(str(handle)) + obj_name = getattr(ent, "ObjectName", "") + if obj_name in _COM_NO_AREA_TYPES: + raise RuntimeError( + f"entity_measure: handle {handle} is {obj_name}, which does not " + "bound an area. Measurable types: LWPOLYLINE, POLYLINE (2D), " + "CIRCLE, ELLIPSE, SPLINE, HATCH, REGION, 3DSOLID." + ) + area = perimeter = None + try: + area = float(ent.Area) + except Exception as exc: + log.debug("ActiveX .Area unavailable for %s: %s", obj_name, exc) + try: + perimeter = float(ent.Length) + except Exception as exc: + log.debug("ActiveX .Length unavailable for %s: %s", obj_name, exc) + + method = "activex_area" + closed = True + self_intersecting = None + if area is None or perimeter is None: + # Rebuild from the polyline's own vertices and run the SAME + # shared maths the headless engine uses, so a missing ActiveX + # property can never make this backend the less accurate one. + method = "activex_fallback_analytic" + coords = list(getattr(ent, "Coordinates", ()) or ()) + vertices = [ + (float(coords[i]), float(coords[i + 1]), _com_bulge(ent, i // 2)) + for i in range(0, len(coords) - 1, 2) + ] + if not vertices: + raise RuntimeError( + f"entity_measure: AutoCAD reported neither an area nor usable " + f"vertices for handle {handle} ({obj_name})." + ) + closed = bool(getattr(ent, "Closed", True)) + fallback_area, fallback_perimeter = polygon_area_perimeter(vertices, closed) + area = fallback_area if area is None else area + perimeter = fallback_perimeter if perimeter is None else perimeter + self_intersecting = is_self_intersecting([(v[0], v[1]) for v in vertices]) + + return { + "handle": str(handle), + "type": obj_name, + "area": round(float(area), 6), + "perimeter": round(float(perimeter), 6), + "closed": closed, + "assumed_closed": not closed, + "method": method, + "exact": True, + "flatten_tolerance": None, + "backend": "com", + "self_intersecting": self_intersecting, + "perimeter_exact": True, + "loop_count": 1, + } + + return await self._run(_sync) + async def analysis_bounding_box(self) -> dict: def _sync(): doc = _acad_doc() @@ -2233,7 +3359,30 @@ class ComBackend(AutoCADBackend): return await self._run(_sync) - async def view_screenshot(self) -> bytes | None: + async def view_screenshot_grounded( + self, + overlay_handles: bool = True, + max_labels: int = 40, + ) -> dict: + """Not available live: this backend captures the AutoCAD window itself. + + Grounding needs labels drawn into the render, and there is no render + here to draw into — the pixels come from the application's own window. + Annotating them would mean writing entities into the user's live drawing + and deleting them again, which is a mutation nobody asked for. + """ + raise UnsupportedCapabilityError( + "handle_overlay", + "view_screenshot_grounded: the live backend captures the AutoCAD " + "window rather than rendering the drawing, so there is no render to " + "label. Annotating the live drawing to produce the overlay would " + "mean creating and deleting entities in the user's document. Use " + "entity_list for handles alongside the plain screenshot, or switch " + "to the headless backend (AUTOCAD_MCP_BACKEND=ezdxf) for a grounded " + "render.", + ) + + async def view_screenshot(self, overlay_handles: bool = False) -> bytes | None: def _sync(): hwnd = _find_autocad_hwnd() if hwnd is None: @@ -2298,6 +3447,10 @@ class ComBackend(AutoCADBackend): return { "backend": "com", "connected": True, + # Which application this actually is. Without it a + # GstarCAD/ZWCAD operator reads an "autocad_version" key + # naming a product they do not own. + "cad_progid": config.settings.cad_progid, "autocad_version": version, "open_documents": doc_count, "active_document": active_doc, @@ -2317,7 +3470,12 @@ class ComBackend(AutoCADBackend): } except Exception as exc: log.debug("system_status: _acad_app check failed: %s", exc) - return {"backend": "com", "connected": False, "error": str(exc)} + return { + "backend": "com", + "connected": False, + "cad_progid": config.settings.cad_progid, + "error": str(exc), + } return await self._run(_sync) diff --git a/backends/contracts/__init__.py b/backends/contracts/__init__.py new file mode 100644 index 0000000..fb04be8 --- /dev/null +++ b/backends/contracts/__init__.py @@ -0,0 +1,51 @@ +"""Per-domain slices of the backend contract. + +One 1854-line ABC with 112 abstract methods made every contract change a +whole-file edit, and made a half-finished change break both backends at +instantiation at once. The seams here are the section markers that were +already in the file, so the split moved code without re-deciding anything. +""" + +from __future__ import annotations + +from backends.contracts.analysis import AnalysisContract +from backends.contracts.blocks import BlockContract +from backends.contracts.corner_ops import CornerOpsContract +from backends.contracts.dimensions import DimensionContract +from backends.contracts.drawing import DrawingContract +from backends.contracts.entity_creation import EntityCreationContract +from backends.contracts.entity_modification import EntityModificationContract +from backends.contracts.entity_query import EntityQueryContract +from backends.contracts.gdt import GdtContract +from backends.contracts.identity import IdentityContract +from backends.contracts.layers import LayerContract +from backends.contracts.layouts import LayoutContract +from backends.contracts.linetypes import LinetypeContract +from backends.contracts.premium import PremiumContract +from backends.contracts.settings import SettingsContract +from backends.contracts.solids import SolidContract +from backends.contracts.system import SystemContract +from backends.contracts.transactions import TransactionContract +from backends.contracts.view import ViewContract + +__all__ = [ + "AnalysisContract", + "BlockContract", + "CornerOpsContract", + "DimensionContract", + "DrawingContract", + "EntityCreationContract", + "EntityModificationContract", + "EntityQueryContract", + "GdtContract", + "IdentityContract", + "LayerContract", + "LayoutContract", + "LinetypeContract", + "PremiumContract", + "SettingsContract", + "SolidContract", + "SystemContract", + "TransactionContract", + "ViewContract", +] diff --git a/backends/contracts/analysis.py b/backends/contracts/analysis.py new file mode 100644 index 0000000..ace3f52 --- /dev/null +++ b/backends/contracts/analysis.py @@ -0,0 +1,164 @@ +"""Measurement and analysis. + +Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7). +``AutoCADBackend`` composes every contract in this package, so importing +``backends.base.AutoCADBackend`` is unchanged for callers. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +from backends.capability import capability + +if TYPE_CHECKING: + from backends.base import EntityInfo + + +class AnalysisContract(ABC): + # ── boundary tracing (M8 / F3) ─────────────────────────────────────────── + + @capability( # noqa: B027 — @capability supplies the body + "boundary_trace", + reason=( + "ActiveX exposes no BOUNDARY member; the live path would have to drive the " + "-BOUNDARY command blind, which has not been verified against a live AutoCAD. " + "Use the headless backend (AUTOCAD_MCP_BACKEND=ezdxf), or run BOUNDARY in " + "AutoCAD directly." + ), + ) + async def boundary_trace( + self, x: float, y: float, layer: str | None = None, tolerance: float = 1e-9 + ) -> dict: + """AutoCAD's BOUNDARY/BPOLY: trace the closed loop enclosing a seed point. + + Returns ``{ok, handle, vertices, area, closed, source_handles}``. + ``vertices`` is a count, ``area`` is unsigned. + + Implementations must not enumerate every loop in the drawing: + ``edgeminer.find_all_loops`` is O(n!) and was measured at 63.9 s for 40 + edges and a timeout at 60. The seed point picks a starting edge and one + loop is walked from it. + + A seed with no enclosing loop is refused. Returning an empty boundary + would be a boundary that bounds nothing, reported as success. + """ + ... + + @capability( # noqa: B027 — @capability supplies the body + "boundary_trace", + reason=( + "Chaining arbitrary entities into a loop needs the same edge graph BOUNDARY " + "does, which ActiveX does not expose. Use the headless backend " + "(AUTOCAD_MCP_BACKEND=ezdxf)." + ), + ) + async def boundary_from_entities(self, handles: list[str], tolerance: float = 1e-9) -> dict: + """Chain the given entities into one closed polyline. + + Returns ``{ok, handle, vertices, area, closed}``. Refuses a chain that + does not close, naming the coordinates of the endpoint that dangles — + "these do not form a loop" is not actionable without the gap. + """ + ... + + @abstractmethod + async def analysis_list_properties(self, handle: str) -> dict: + """AutoCAD's LIST: the full DXF attribute set for one handle. + + Returns ``{ok, handle, type, layer, properties, dxf_attributes}``. + ``properties`` mirrors what ``entity_get`` reports; ``dxf_attributes`` + is the raw attribute set that ``entity_get`` deliberately does not + carry. + + Two things the raw dump cannot be handed over as-is. It holds ``Vec3`` + values, so it is not JSON-serialisable and would die at the wire + boundary; and its coordinates are stored in the entity's own frame, so + for anything with an extrusion they would contradict every other + coordinate this server reports. Both are converted here, and + ``extrusion`` stays in the dump so the original frame is still visible. + """ + ... + + @abstractmethod + async def analysis_stats(self) -> dict: ... + + @abstractmethod + async def analysis_entities_in_region( + self, + x1: float, + y1: float, + x2: float, + y2: float, + ) -> list[EntityInfo]: ... + + @abstractmethod + async def analysis_measure_distance( + self, + x1: float, + y1: float, + x2: float, + y2: float, + ) -> float: ... + + @abstractmethod + async def analysis_measure_area(self, points: list[list[float]]) -> float: ... + + @abstractmethod + async def entity_measure(self, handle: str, flatten_tolerance: float = 0.001) -> dict: + """Area and perimeter of ONE existing entity, addressed by handle. + + Returns ``{"handle", "type", "area", "perimeter", "closed", + "assumed_closed", "method", "exact", "flatten_tolerance", "backend", + "self_intersecting", "perimeter_exact", "loop_count"}``. + + A HATCH is measured as the area it *fills* — outer loops minus the + islands inside them — and the headless engine adds ``hatch_style`` + (``normal``/``outer``/``ignore``) so the number can be read against the + drawing's own island setting. The live backend does not carry that key: + it asks AutoCAD for ``.Area``, which has already applied the style, so + there is no house rule left to disclose. + + ``exact`` and + ``flatten_tolerance`` are the accuracy disclosure: when one engine can + answer in closed form and the other has to flatten, the payload says so + rather than leaving two numbers to quietly disagree. + + Two different refusals, and the difference matters. A type that bounds no + area at all (LINE, TEXT, a 3D polyline) raises a plain ``RuntimeError``: + no engine can measure it, so tagging a capability would falsely suggest + switching backends helps. A type this *engine* cannot evaluate (ACIS + solids headlessly) raises ``UnsupportedCapabilityError``, which names the + engine that can. + """ + ... + + @abstractmethod + async def analysis_bounding_box(self) -> dict: ... + + @abstractmethod + async def analysis_select_by_layer(self, layer_name: str) -> list[EntityInfo]: ... + + @abstractmethod + async def analysis_select_by_type(self, entity_type: str) -> list[EntityInfo]: ... + + @abstractmethod + async def selection_get(self) -> dict: + """Read the live viewport's implied ("pickfirst") selection set. + + COM-only / meaningful only with live AutoCAD. Returns a dict:: + + { + "ok": bool, # True on COM even for an empty pick + "count": int, + "handles": list[str], # entity handles to act on + "entities": list[EntityInfo], # _dc()-converted by the server + "pickfirst": bool | None, # state of the PICKFIRST sysvar + "message" / "error": str, # optional guidance / failure reason + } + + The ezdxf headless backend has no viewport, so it returns ``ok=False`` + with an empty ``handles`` list (same shape, never raises). + """ + ... diff --git a/backends/contracts/blocks.py b/backends/contracts/blocks.py new file mode 100644 index 0000000..36c7396 --- /dev/null +++ b/backends/contracts/blocks.py @@ -0,0 +1,50 @@ +"""Blocks and block references. + +Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7). +``AutoCADBackend`` composes every contract in this package, so importing +``backends.base.AutoCADBackend`` is unchanged for callers. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from backends.base import BlockInfo, EntityInfo + + +class BlockContract(ABC): + @abstractmethod + async def block_list(self) -> list[BlockInfo]: ... + + @abstractmethod + async def block_insert( + self, + name: str, + x: float, + y: float, + scale_x: float = 1.0, + scale_y: float = 1.0, + rotation: float = 0.0, + attributes: dict | None = None, + layer: str | None = None, + ) -> EntityInfo: ... + + @abstractmethod + async def block_explode(self, handle: str) -> dict: ... + + @abstractmethod + async def block_get_attributes(self, handle: str) -> dict: ... + + @abstractmethod + async def block_set_attributes(self, handle: str, attributes: dict) -> dict: ... + + @abstractmethod + async def block_create_from_entities( + self, + name: str, + handles: list[str], + base_x: float = 0.0, + base_y: float = 0.0, + ) -> dict: ... diff --git a/backends/contracts/corner_ops.py b/backends/contracts/corner_ops.py new file mode 100644 index 0000000..35ed08f --- /dev/null +++ b/backends/contracts/corner_ops.py @@ -0,0 +1,66 @@ +"""Trim, extend, fillet, chamfer. + +Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7). +``AutoCADBackend`` composes every contract in this package, so importing +``backends.base.AutoCADBackend`` is unchanged for callers. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from backends.base import EntityInfo + + +class CornerOpsContract(ABC): + @abstractmethod + async def entity_trim( + self, + target_handle: str, + cutter_handle: str, + keep_x: float, + keep_y: float, + ) -> EntityInfo: + """Trim `target` against `cutter`; keep the segment containing + (keep_x, keep_y). Raises ToolError if no intersection.""" + ... + + @abstractmethod + async def entity_extend( + self, + target_handle: str, + boundary_handle: str, + end_x: float | None = None, + end_y: float | None = None, + ) -> EntityInfo: + """Extend `target` to meet `boundary`. If `end_x/y` is None, the + target endpoint nearest the boundary is auto-selected.""" + ... + + @abstractmethod + async def entity_fillet( + self, + handle1: str, + handle2: str, + radius: float, + trim: bool = True, + ) -> EntityInfo: + """Fillet two entities with the given radius. Returns the new ARC. + When `trim` is True (default), the source entities are shortened + to the tangent points (AutoCAD default behaviour).""" + ... + + @abstractmethod + async def entity_chamfer( + self, + handle1: str, + handle2: str, + dist1: float, + dist2: float | None = None, + trim: bool = True, + ) -> EntityInfo: + """Chamfer two entities. When `dist2` is None it defaults to `dist1` + (symmetric chamfer). Returns the new chamfer LINE.""" + ... diff --git a/backends/contracts/dimensions.py b/backends/contracts/dimensions.py new file mode 100644 index 0000000..3549ac7 --- /dev/null +++ b/backends/contracts/dimensions.py @@ -0,0 +1,89 @@ +"""ISO 129 dimensions. + +Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7). +``AutoCADBackend`` composes every contract in this package, so importing +``backends.base.AutoCADBackend`` is unchanged for callers. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from backends.base import EntityInfo + + +class DimensionContract(ABC): + @abstractmethod + async def dimension_linear( + self, + x1: float, + y1: float, + x2: float, + y2: float, + dim_x: float, + dim_y: float, + rotation: float = 0.0, + layer: str | None = None, + tol_upper: float | None = None, + tol_lower: float | None = None, + tol_mode: str = "none", + text_override: str | None = None, + ) -> EntityInfo: ... + + @abstractmethod + async def dimension_aligned( + self, + x1: float, + y1: float, + x2: float, + y2: float, + dim_x: float, + dim_y: float, + layer: str | None = None, + ) -> EntityInfo: ... + + @abstractmethod + async def dimension_angular( + self, + vx: float, + vy: float, + x1: float, + y1: float, + x2: float, + y2: float, + tx: float, + ty: float, + layer: str | None = None, + ) -> EntityInfo: ... + + @abstractmethod + async def dimension_radius( + self, + cx: float, + cy: float, + chord_x: float, + chord_y: float, + leader_length: float = 10.0, + layer: str | None = None, + tol_upper: float | None = None, + tol_lower: float | None = None, + tol_mode: str = "none", + text_override: str | None = None, + ) -> EntityInfo: ... + + @abstractmethod + async def dimension_diameter( + self, + x1: float, + y1: float, + x2: float, + y2: float, + leader_length: float = 10.0, + layer: str | None = None, + tol_upper: float | None = None, + tol_lower: float | None = None, + tol_mode: str = "none", + text_override: str | None = None, + ) -> EntityInfo: ... diff --git a/backends/contracts/drawing.py b/backends/contracts/drawing.py new file mode 100644 index 0000000..65efdb4 --- /dev/null +++ b/backends/contracts/drawing.py @@ -0,0 +1,40 @@ +"""Document lifecycle. + +Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7). +``AutoCADBackend`` composes every contract in this package, so importing +``backends.base.AutoCADBackend`` is unchanged for callers. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from backends.base import DrawingInfo + + +class DrawingContract(ABC): + @abstractmethod + async def drawing_info(self) -> DrawingInfo: ... + + @abstractmethod + async def drawing_new(self, template: str | None = None) -> dict: ... + + @abstractmethod + async def drawing_open(self, path: str) -> dict: ... + + @abstractmethod + async def drawing_save(self, path: str | None = None) -> dict: ... + + @abstractmethod + async def drawing_save_as(self, path: str, fmt: str = "dwg") -> dict: ... + + @abstractmethod + async def drawing_export_dxf(self, path: str) -> dict: ... + + @abstractmethod + async def drawing_export_pdf(self, path: str, layout: str | None = None) -> dict: ... + + @abstractmethod + async def drawing_purge(self) -> dict: ... diff --git a/backends/contracts/entity_creation.py b/backends/contracts/entity_creation.py new file mode 100644 index 0000000..27235f8 --- /dev/null +++ b/backends/contracts/entity_creation.py @@ -0,0 +1,255 @@ +"""Creating geometry. + +Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7). +``AutoCADBackend`` composes every contract in this package, so importing +``backends.base.AutoCADBackend`` is unchanged for callers. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +from backends.capability import capability + +if TYPE_CHECKING: + from backends.base import EntityInfo + + +class EntityCreationContract(ABC): + @abstractmethod + async def entity_create_line( + self, + x1: float, + y1: float, + x2: float, + y2: float, + z1: float = 0.0, + z2: float = 0.0, + layer: str | None = None, + color: int | None = None, + linetype: str | None = None, + ) -> EntityInfo: ... + + @abstractmethod + async def entity_create_circle( + self, + cx: float, + cy: float, + radius: float, + layer: str | None = None, + color: int | None = None, + ) -> EntityInfo: ... + + @abstractmethod + async def entity_create_arc( + self, + cx: float, + cy: float, + radius: float, + start_angle: float, + end_angle: float, + layer: str | None = None, + color: int | None = None, + ) -> EntityInfo: ... + + @abstractmethod + async def entity_create_polyline( + self, + points: list[list[float]], + closed: bool = False, + layer: str | None = None, + color: int | None = None, + ) -> EntityInfo: ... + + @abstractmethod + async def entity_create_text( + self, + text: str, + x: float, + y: float, + height: float = 2.5, + rotation: float = 0.0, + layer: str | None = None, + color: int | None = None, + ) -> EntityInfo: ... + + @abstractmethod + async def entity_create_mtext( + self, + text: str, + x: float, + y: float, + width: float = 100.0, + height: float = 2.5, + rotation: float = 0.0, + layer: str | None = None, + color: int | None = None, + ) -> EntityInfo: ... + + @abstractmethod + async def entity_create_table( + self, + x: float, + y: float, + rows: list[list[str]], + headers: list[str] | None = None, + column_widths: list[float] | None = None, + row_height: float = 7.0, + text_height: float = 2.5, + title: str | None = None, + layer: str = "TEXT", + ) -> EntityInfo: ... + + @abstractmethod + async def leader_create_mleader( + self, + points: list[list[float]], + text: str, + text_height: float = 2.5, + landing_gap: float = 1.0, + arrow_size: float = 2.5, + layer: str = "DIM", + ) -> EntityInfo: ... + + @abstractmethod + async def entity_create_hatch( + self, + pattern: str, + boundary_points: list[list[float]], + scale: float = 1.0, + angle: float = 0.0, + layer: str | None = None, + color: int | None = None, + ) -> EntityInfo: ... + + @abstractmethod + async def entity_create_spline( + self, + fit_points: list[list[float]], + layer: str | None = None, + color: int | None = None, + ) -> EntityInfo: ... + + @abstractmethod + async def entity_create_ellipse( + self, + cx: float, + cy: float, + major_x: float, + major_y: float, + ratio: float = 0.5, + layer: str | None = None, + color: int | None = None, + ) -> EntityInfo: ... + + @abstractmethod + async def entity_create_point( + self, + x: float, + y: float, + layer: str | None = None, + color: int | None = None, + ) -> EntityInfo: ... + + @abstractmethod + async def entity_create_block_ref( + self, + name: str, + x: float, + y: float, + scale_x: float = 1.0, + scale_y: float = 1.0, + rotation: float = 0.0, + layer: str | None = None, + ) -> EntityInfo: ... + + # ── hatch depth (M8 / F4) ──────────────────────────────────────────────── + + @abstractmethod + async def hatch_set_gradient( + self, + handle: str, + color1: list[int], + color2: list[int], + rotation: float = 0.0, + centered: float = 0.0, + one_color: bool = False, + tint: float = 0.0, + name: str = "LINEAR", + ) -> dict: + """Replace a hatch's fill with a two-colour gradient. ``{ok, handle, gradient}``.""" + ... + + @abstractmethod + async def hatch_edit( + self, + handle: str, + pattern: str = "", + scale: float | None = None, + angle: float | None = None, + color: int | None = None, + style: str = "", + ) -> dict: + """Edit an existing hatch in place. Returns ``{ok, handle, changed}``. + + ``changed`` names exactly the attributes that moved: an omitted + parameter must leave its attribute alone, because a partial edit that + quietly resets the rest is data loss. ``style`` is the island style — + ``normal``, ``outer`` or ``ignore``. + """ + ... + + @abstractmethod + async def hatch_add_boundary(self, handle: str, edges: list[dict]) -> dict: + """Add one boundary path built from typed edges. + + Returns ``{ok, handle, path_count, edge_types}``. Edges are + ``{"type": "line"|"arc"|"ellipse", ...}``. Typed edges exist because a + boundary API that only accepts vertex lists silently straightens every + curve it is given. + """ + ... + + # ── annotation objects (M8 / F15) ──────────────────────────────────────── + + @capability( # noqa: B027 — @capability supplies the body + "wipeout", + reason=( + "ActiveX has no wipeout constructor — ModelSpace.AddWipeout does not exist " + "(verified against AutoCAD 2026); WIPEOUT is a command only. Use the headless " + "backend (AUTOCAD_MCP_BACKEND=ezdxf), or run WIPEOUT in AutoCAD directly." + ), + ) + async def entity_create_wipeout( + self, points: list[list[float]], layer: str | None = None + ) -> dict: + """Mask the drawing behind a closed polygon. Returns ``{ok, handle, points}``. + + Refuses fewer than three points: a zero-area mask masks nothing while + reporting success. + """ + ... + + @capability( # noqa: B027 — @capability supplies the body + "revcloud", + reason=( + "ActiveX exposes no revision-cloud member and driving the REVCLOUD command " + "blind against a live drawing is unverified. Use the headless backend " + "(AUTOCAD_MCP_BACKEND=ezdxf), or run REVCLOUD in AutoCAD directly." + ), + ) + async def entity_create_revcloud( + self, + points: list[list[float]], + segment_length: float, + layer: str | None = None, + closed: bool = True, + ) -> dict: + """Draw a revision cloud along a path. Returns ``{ok, handle, segments}``. + + A revision cloud *is* a polyline whose every segment carries an arc + bulge; a segment length longer than the path yields no arcs at all, so + that case is refused rather than returned as a rectangle. + """ + ... diff --git a/backends/contracts/entity_modification.py b/backends/contracts/entity_modification.py new file mode 100644 index 0000000..1a43522 --- /dev/null +++ b/backends/contracts/entity_modification.py @@ -0,0 +1,125 @@ +"""Moving, copying and editing geometry. + +Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7). +``AutoCADBackend`` composes every contract in this package, so importing +``backends.base.AutoCADBackend`` is unchanged for callers. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from backends.base import EntityInfo + + +class EntityModificationContract(ABC): + @abstractmethod + async def entity_move(self, handle: str, dx: float, dy: float, dz: float = 0.0) -> dict: ... + + @abstractmethod + async def entity_copy( + self, handle: str, dx: float, dy: float, dz: float = 0.0 + ) -> EntityInfo: ... + + @abstractmethod + async def entity_rotate( + self, + handle: str, + base_x: float, + base_y: float, + angle_deg: float, + ) -> dict: ... + + @abstractmethod + async def entity_scale( + self, + handle: str, + base_x: float, + base_y: float, + factor: float, + ) -> dict: ... + + @abstractmethod + async def entity_mirror( + self, + handle: str, + x1: float, + y1: float, + x2: float, + y2: float, + delete_original: bool = False, + ) -> EntityInfo: ... + + @abstractmethod + async def entity_offset( + self, + handle: str, + distance: float, + side_x: float | None = None, + side_y: float | None = None, + ) -> EntityInfo: ... + + @abstractmethod + async def entity_delete(self, handle: str) -> dict: ... + + @abstractmethod + async def entity_array_rectangular( + self, + handle: str, + rows: int, + cols: int, + row_spacing: float, + col_spacing: float, + ) -> list[EntityInfo]: ... + + @abstractmethod + async def entity_array_polar( + self, + handle: str, + count: int, + fill_angle: float, + center_x: float, + center_y: float, + ) -> list[EntityInfo]: ... + + # ── text annotation (M8 / F15) ─────────────────────────────────────────── + + @abstractmethod + async def text_set_background( + self, + handle: str, + enabled: bool = True, + color: int | None = None, + scale: float = 1.5, + ) -> dict: + """Put an opaque background box behind an MTEXT. ``{ok, handle, enabled}``. + + MTEXT only — TEXT has no background-fill attribute, so setting one on + it would report success and change nothing. ``scale`` is a multiple of + the text box and must be >= 1: a box smaller than its text is a stripe + through the text, not a mask. + """ + ... + + @abstractmethod + async def text_find_replace( + self, + find: str, + replace: str, + layer: str | None = None, + match_case: bool = True, + dry_run: bool = False, + ) -> dict: + """Replace text across the drawing. + + Returns ``{ok, replaced, entities, searched_types, note, dry_run}``. + ``searched_types`` is reported rather than assumed: "no matches" and + "that type was never searched" are different answers, and an editor + that silently skips block attributes is the classic version of this + bug. DIMENSION text is deliberately out of scope — its ``text`` field + holds the ``<>`` override placeholder rather than the measurement, so + editing it would either do nothing or break the association. + """ + ... diff --git a/backends/contracts/entity_query.py b/backends/contracts/entity_query.py new file mode 100644 index 0000000..a43f82e --- /dev/null +++ b/backends/contracts/entity_query.py @@ -0,0 +1,158 @@ +"""Reading entities back. + +Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7). +``AutoCADBackend`` composes every contract in this package, so importing +``backends.base.AutoCADBackend`` is unchanged for callers. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from backends.base import EntityInfo + + +class EntityQueryContract(ABC): + # ── selection filters (M8 / F1) ────────────────────────────────────────── + + @abstractmethod + async def selection_window( + self, + x1: float, + y1: float, + x2: float, + y2: float, + mode: str = "window", + entity_type: str = "", + layer: str = "", + ) -> dict: + """AutoCAD's ssget window/crossing. ``{ok, handles, count, mode}``. + + ``window`` returns only entities wholly inside the box; ``crossing`` + also returns the ones that straddle its edge. Getting those backwards + is silent and wrong, so ``mode`` is reported back and defaults to the + narrower one. A zero-area box is refused rather than answered with an + empty list, which would read as "nothing there". + + Selection is by *drawn* position: an entity in a mirrored frame is + found where ``entity_get`` reports it, not where the file stores it. + """ + ... + + @abstractmethod + async def selection_polygon( + self, + points: list[list[float]], + mode: str = "window", + entity_type: str = "", + layer: str = "", + ) -> dict: + """Window/crossing selection against an arbitrary polygon. + + A polygon is not its bounding box — the difference is the whole point + of the tool, so the implementation must test against the shape itself. + """ + ... + + @abstractmethod + async def selection_filter( + self, + entity_type: str = "", + layer: str = "", + color: int | None = None, + linetype: str = "", + min_area: float | None = None, + ) -> dict: + """Property filter. Returns ``{ok, handles, count, filtered_by}``. + + Named parameters rather than a query string, deliberately: ezdxf's + query language answers an unknown attribute with an empty result, so a + typo is indistinguishable from "no matches". Here a typo is a + ``TypeError`` at the boundary, and ``filtered_by`` tells the caller + which filters actually ran — "I filtered by layer and found none" and + "I never filtered by layer" are different answers. + """ + ... + + @abstractmethod + async def entity_get(self, handle: str) -> EntityInfo: ... + + @abstractmethod + async def entity_set_properties( + self, + handle: str, + layer: str | None = None, + color: int | None = None, + linetype: str | None = None, + lineweight: float | None = None, + visible: bool | None = None, + ) -> dict: ... + + @abstractmethod + async def entity_edit_text( + self, + handle: str, + text: str | None = None, + height: float | None = None, + rotation: float | None = None, + ) -> EntityInfo: + """Edit an existing TEXT or MTEXT entity in place. Any argument left + None is unchanged. Returns the updated EntityInfo. Raises if the handle + is not a TEXT/MTEXT entity.""" + ... + + @abstractmethod + async def entity_edit_geometry( + self, + handle: str, + cx: float | None = None, + cy: float | None = None, + radius: float | None = None, + x1: float | None = None, + y1: float | None = None, + x2: float | None = None, + y2: float | None = None, + start_angle: float | None = None, + end_angle: float | None = None, + ) -> EntityInfo: + """Edit the defining geometry of an existing entity in place. + - CIRCLE: cx / cy / radius + - LINE: x1 / y1 (start), x2 / y2 (end) + - ARC: cx / cy / radius / start_angle / end_angle (degrees) + Any argument left None is unchanged. Raises for unsupported types.""" + ... + + @abstractmethod + async def entity_list( + self, + type_filter: str | None = None, + layer_filter: str | None = None, + limit: int = 200, + offset: int = 0, + ) -> list[EntityInfo]: ... + + @abstractmethod + async def entity_count( + self, + type_filter: str | None = None, + layer_filter: str | None = None, + ) -> int: + """How many entities ``entity_list`` would match, ignoring limit/offset. + + Exists so a *paged* listing can report an honest total without paying + for the rows it is not going to return. ``entity_list`` stops building + ``EntityInfo`` objects the moment it has ``limit`` of them, so it can + never tell a caller whether anything followed the page — and a + truncated list read as complete is a correctness bug, not a token one. + + Two obligations, both load-bearing: + + * apply **exactly** the filter semantics of ``entity_list`` (same + case-insensitive type/layer comparison), or ``truncated`` would be + computed against a different set than the one that was paged; + * do not build ``EntityInfo`` rows. A count implemented by listing + would cost more than the listing it is meant to make cheap. + """ + ... diff --git a/backends/contracts/gdt.py b/backends/contracts/gdt.py new file mode 100644 index 0000000..1287d7e --- /dev/null +++ b/backends/contracts/gdt.py @@ -0,0 +1,157 @@ +"""Geometric tolerancing. + +Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7). +``AutoCADBackend`` composes every contract in this package, so importing +``backends.base.AutoCADBackend`` is unchanged for callers. +""" + +from __future__ import annotations + + +class GdtContract: + # Composed from LINE + TEXT primitives so the exact same frame renders on + # both COM and ezdxf (ezdxf's native TOLERANCE entity renders blank through + # the matplotlib frontend). Deterministic layout math lives in engineering/ + # gdt.py; datum letters are tracked so the `gdt` critique focus can verify + # every referenced datum is actually established on the part. + + async def _place_boxed_text( + self, + text: str, + cx: float, + cy: float, + text_h: float, + layer: str, + ) -> str: + """Create a TEXT entity roughly centred on (cx, cy). Returns its handle. + entity_create_text has no alignment arg, so approximate the centring with + the same width heuristic the title block uses.""" + approx_w = len(text) * text_h * 0.7 + t = await self.entity_create_text( + text, + cx - approx_w / 2.0, + cy - text_h / 2.0, + height=text_h, + layer=layer, + ) + return t.handle + + async def draw_feature_control_frame( + self, + symbol: str, + tolerance: str | float, + x: float, + y: float, + datums: list[str] | None = None, + height: float = 5.0, + diameter: bool = False, + modifier: str | None = None, + layer: str | None = None, + ) -> dict: + """Draw an ISO 1101 feature control frame with bottom-left corner at (x, y). + + symbol: one of the 14 geometric characteristics (straightness, flatness, + circularity, cylindricity, profile_line, profile_surface, angularity, + perpendicularity, parallelism, position, concentricity, symmetry, + circular_runout, total_runout). `datums` is the ordered datum reference + list (e.g. ["A", "B"]); `diameter=True` prefixes ⌀ for a cylindrical zone; + `modifier` ∈ {M, L, S} appends the material-condition symbol. + + Orientation/location/runout characteristics require at least one datum + (raises otherwise). The referenced datums are recorded for the `gdt` + critique focus. + """ + from engineering.gdt import fcf_compartments, fcf_layout + + comps = fcf_compartments( + symbol, + tolerance, + datums, + diameter=diameter, + modifier=modifier, + ) + layer = layer or self._role_layer("dim") + await self._ensure_layer(layer, color=2) + lay = fcf_layout(comps, float(x), float(y), float(height)) + handles: list[str] = [] + + box = lay["box"] + box_poly = await self.entity_create_polyline( + [[bx, by] for bx, by in box], + closed=False, + layer=layer, + ) + handles.append(box_poly.handle) + for (sx, sy), (ex, ey) in lay["dividers"]: + ln = await self.entity_create_line(sx, sy, ex, ey, layer=layer) + handles.append(ln.handle) + th = lay["text_height"] + for text, tcx, tcy in lay["labels"]: + handles.append(await self._place_boxed_text(text, tcx, tcy, th, layer)) + + refs = getattr(self, "_gdt_datums_referenced", None) + if refs is None: + refs = set() + self._gdt_datums_referenced = refs + for d in datums or []: + d = str(d).strip().upper() + if d: + refs.add(d) + + return { + "ok": True, + "symbol": symbol, + "compartments": comps, + "handles": handles, + "width": lay["width"], + "height": lay["height"], + "layer": layer, + } + + async def draw_datum_feature( + self, + letter: str, + x: float, + y: float, + size: float = 5.0, + layer: str | None = None, + ) -> dict: + """Place an ISO 1101 datum feature symbol (filled triangle + boxed letter) + with its apex at (x, y). Records the datum letter so a feature control + frame that references it passes the `gdt` critique focus.""" + from engineering.gdt import datum_triangle, fcf_layout + + letter = str(letter).strip().upper() + if not letter: + raise RuntimeError("draw_datum_feature: a datum letter is required.") + layer = layer or self._role_layer("dim") + await self._ensure_layer(layer, color=2) + size = float(size) + handles: list[str] = [] + + tri = datum_triangle(float(x), float(y), size, down=True) + tri_poly = await self.entity_create_polyline( + [[px, py] for px, py in tri], + closed=True, + layer=layer, + ) + handles.append(tri_poly.handle) + + box_h = size * 1.4 + lay = fcf_layout([letter], float(x) - box_h / 2.0, float(y) - size - box_h, box_h) + box_poly = await self.entity_create_polyline( + [[bx, by] for bx, by in lay["box"]], + closed=False, + layer=layer, + ) + handles.append(box_poly.handle) + for text, tcx, tcy in lay["labels"]: + handles.append(await self._place_boxed_text(text, tcx, tcy, lay["text_height"], layer)) + + defined = getattr(self, "_gdt_datums_defined", None) + if defined is None: + defined = set() + self._gdt_datums_defined = defined + defined.add(letter) + + return {"ok": True, "datum": letter, "handles": handles, "layer": layer} diff --git a/backends/contracts/identity.py b/backends/contracts/identity.py new file mode 100644 index 0000000..89b5c4d --- /dev/null +++ b/backends/contracts/identity.py @@ -0,0 +1,33 @@ +"""Connecting and describing the backend. + +Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7). +``AutoCADBackend`` composes every contract in this package, so importing +``backends.base.AutoCADBackend`` is unchanged for callers. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from backends.base import CapabilityMap + + +class IdentityContract(ABC): + @property + @abstractmethod + def name(self) -> str: ... + + @property + @abstractmethod + def is_connected(self) -> bool: ... + + @abstractmethod + async def connect(self) -> None: ... + + @abstractmethod + async def disconnect(self) -> None: ... + + @abstractmethod + def capabilities(self) -> CapabilityMap: ... diff --git a/backends/contracts/layers.py b/backends/contracts/layers.py new file mode 100644 index 0000000..308f1c5 --- /dev/null +++ b/backends/contracts/layers.py @@ -0,0 +1,61 @@ +"""Layers. + +Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7). +``AutoCADBackend`` composes every contract in this package, so importing +``backends.base.AutoCADBackend`` is unchanged for callers. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from backends.base import LayerInfo + + +class LayerContract(ABC): + @abstractmethod + async def layer_list(self) -> list[LayerInfo]: ... + + @abstractmethod + async def layer_create( + self, + name: str, + color: int = 7, + linetype: str = "Continuous", + lineweight: float = -3, + ) -> LayerInfo: ... + + @abstractmethod + async def layer_delete(self, name: str) -> dict: ... + + @abstractmethod + async def layer_set_current(self, name: str) -> dict: ... + + @abstractmethod + async def layer_modify( + self, + name: str, + color: int | None = None, + linetype: str | None = None, + lineweight: float | None = None, + ) -> LayerInfo: ... + + @abstractmethod + async def layer_freeze(self, name: str) -> dict: ... + + @abstractmethod + async def layer_thaw(self, name: str) -> dict: ... + + @abstractmethod + async def layer_lock(self, name: str) -> dict: ... + + @abstractmethod + async def layer_unlock(self, name: str) -> dict: ... + + @abstractmethod + async def layer_hide(self, name: str) -> dict: ... + + @abstractmethod + async def layer_show(self, name: str) -> dict: ... diff --git a/backends/contracts/layouts.py b/backends/contracts/layouts.py new file mode 100644 index 0000000..1b8b017 --- /dev/null +++ b/backends/contracts/layouts.py @@ -0,0 +1,170 @@ +"""Layouts, viewports and change-space. + +Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7). +``AutoCADBackend`` composes every contract in this package, so importing +``backends.base.AutoCADBackend`` is unchanged for callers. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + +from backends.capability import capability + + +class LayoutContract(ABC): + @abstractmethod + async def layout_list(self) -> dict: + """Return {ok, layouts: [names], current: name} incl. 'Model'.""" + ... + + @abstractmethod + async def layout_create(self, name: str) -> dict: + """Create a new paper-space layout.""" + ... + + @abstractmethod + async def layout_set_current(self, name: str) -> dict: + """Activate a layout tab ('Model' or a paper-space layout name).""" + ... + + @abstractmethod + async def layout_delete(self, name: str) -> dict: + """Delete a paper-space layout and everything drawn on it. + + Returns ``{ok, deleted, entities_destroyed, current}``. ``current`` is + the space geometry goes into afterwards, which the implementation must + keep consistent with ``layout_list()`` — deleting the active tab + otherwise leaves the reported current layout, the DXF tile mode and the + space entities actually land in disagreeing with each other. + + Refuses ``'Model'``, a blank name (which ezdxf resolves to the *first* + paper-space layout), a name that does not exist, and the last remaining + paper-space layout. + """ + ... + + @abstractmethod + async def layout_rename(self, old_name: str, new_name: str) -> dict: + """Rename a paper-space layout, keeping its entities and their handles. + + Returns ``{ok, old, new, current}``. Refuses ``'Model'`` on either side, + a blank or otherwise invalid new name, and a name already in use. + """ + ... + + @abstractmethod + async def layout_copy(self, source: str, new_name: str) -> dict: + """Copy a paper-space layout, its page setup and its geometry. + + Returns ``{ok, source, layout, entities_copied, skipped, + associativity_remapped, associativity_dropped}``. ``skipped`` names the + DXF types that could not be cloned — a copy missing geometry must never + report a bare success. Associative boundaries (hatches) are re-pointed + at the cloned entities where possible and cleared otherwise, because a + boundary still referencing the source layout dangles as soon as that + layout is deleted and no auditor reports it. + """ + ... + + @abstractmethod + async def viewport_create( + self, + layout: str, + center_x: float, + center_y: float, + width: float, + height: float, + view_center_x: float, + view_center_y: float, + scale: float = 1.0, + ) -> dict: + """Place a scaled model-space viewport on a paper-space layout. + + ``scale`` is paper:model (1.0 = 1:1, 0.5 = 1:2). The viewport shows + the model window centered at (view_center_x, view_center_y) with + view height = height / scale. + """ + ... + + @abstractmethod + async def viewport_list(self, layout: str | None = None) -> dict: + """List the viewports on one paper-space layout, or on all of them. + + Returns ``{ok, viewports: [...], count}``. Each row carries ``handle``, + ``layout``, ``center``, ``width``, ``height``, ``view_center``, + ``view_height``, ``scale``, ``locked``, ``status``, ``id`` and + ``is_main``. + + The layout's own main viewport is reported with ``is_main: True`` rather + than filtered out — it is what remains after deleting every drafting + viewport, and a caller who cannot see it cannot explain that. ``scale`` + and ``locked`` are ``None`` on documents that cannot store them (R12), + never a fabricated number. Viewports are addressed by handle; ``id`` is + metadata only. + """ + ... + + @abstractmethod + async def viewport_set_scale(self, handle: str, scale: float) -> dict: + """Set a viewport's paper:model scale. Returns ``{ok, handle, scale, + view_height}`` with ``scale`` read back from the entity. + + Refuses the main viewport (whose view height is the tab's own pan/zoom + state, not a drafting scale) and documents that cannot persist it. + Geometric scale only: annotative text and dimensions do not resize. + """ + ... + + @abstractmethod + async def viewport_lock(self, handle: str, locked: bool = True) -> dict: + """Lock or unlock a viewport's display scale. Returns ``{ok, handle, + locked}`` with ``locked`` read back from the entity.""" + ... + + # B027 (empty method, no @abstractmethod) is exactly what this pattern + # looks like from the outside: the decorator replaces the `...` body with + # the refusing default, so the source really is empty on purpose. + @capability( # noqa: B027 + "chspace", + reason=( + "ActiveX exposes no change-space member, so a live backend would have to recreate " + "geometry through CopyObjects or drive the CHSPACE command blind — neither has been " + "verified against a live AutoCAD. Use the headless backend " + "(AUTOCAD_MCP_BACKEND=ezdxf), or run CHSPACE in AutoCAD directly." + ), + ) + async def entity_change_space( + self, + handles: list[str], + viewport_handle: str, + direction: str = "to_paper", + freeze_dimensions: bool = False, + ) -> dict: + """Move entities between model space and a sheet, *through* a viewport. + + Returns ``{ok, moved, refused, scale, viewport}``. Each ``moved`` row + carries ``handle``, ``from``, ``to``, ``inside_viewport`` and + ``on_sheet``; each ``refused`` row carries ``handle`` and ``reason``. + + The geometric transform is the whole point: a move without it leaves a + 100 mm feature as 100 mm of paper inside a 1:2 viewport, produces a file + that audits clean, and is indistinguishable from success. Backends that + cannot apply the viewport transform must refuse rather than move. + + Refuses per entity for dimensions (unless ``freeze_dimensions`` bakes + the pre-transform measurement into the text first), ACIS bodies, tables + and proxies, viewports, and entities already in the target space; and + refuses the whole call for a twisted or non-top-view viewport. + """ + ... + + @abstractmethod + async def viewport_delete(self, handle: str, force: bool = False) -> dict: + """Delete a viewport. Returns ``{ok, handle, layout, was_main}``. + + The layout's main viewport needs ``force=True`` and the implementation + must repair the layout's current-viewport pointer afterwards; a dangling + pointer is invisible to every headless audit and shows up only in CAD. + """ + ... diff --git a/backends/contracts/linetypes.py b/backends/contracts/linetypes.py new file mode 100644 index 0000000..bc37e62 --- /dev/null +++ b/backends/contracts/linetypes.py @@ -0,0 +1,22 @@ +"""Linetypes. + +Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7). +``AutoCADBackend`` composes every contract in this package, so importing +``backends.base.AutoCADBackend`` is unchanged for callers. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class LinetypeContract(ABC): + @abstractmethod + async def linetype_list(self) -> list[str]: ... + + @abstractmethod + async def linetype_load( + self, + name: str, + file: str | None = None, + ) -> dict: ... diff --git a/backends/contracts/premium.py b/backends/contracts/premium.py new file mode 100644 index 0000000..f31afc8 --- /dev/null +++ b/backends/contracts/premium.py @@ -0,0 +1,763 @@ +"""Planning, critique, snapping and construction geometry. + +Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7). +``AutoCADBackend`` composes every contract in this package, so importing +``backends.base.AutoCADBackend`` is unchanged for callers. +""" + +from __future__ import annotations + +import math +from abc import ABC, abstractmethod +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from backends.base import EntityInfo + from engineering.plan_spec import ( + CritiqueFocus, + DimStyle, + Issue, + LayerSetId, + PlanSpec, + SheetSize, + SnapType, + ) + + +class PremiumContract(ABC): + # These are CONCRETE and backend-agnostic: they orchestrate the abstract + # primitives above plus the shared EntityInfo.properties contract (start/ + # end/center/radius/start_angle/end_angle — populated identically by both + # backends), so the ezdxf and COM backends inherit identical premium + # behaviour. The only backend-specific primitive is `_create_xline` — XLINE + # has no cross-backend equivalent among the standard entity_create_* tools. + + _plan_spec: PlanSpec | None = None + _preflight_result: Any = None + + def _reset_document_state(self) -> None: + """Clear metadata that is valid only for the current drawing.""" + self._plan_spec = None + self._preflight_result = None + self._gdt_datums_defined = set() + self._gdt_datums_referenced = set() + + async def _ensure_document_state(self) -> None: + """Allow live backends to detect an out-of-band document switch.""" + return None + + async def drawing_preflight( + self, + intent: str, + requirements: dict | None = None, + sheet_size: SheetSize = "A3", + scale: float = 1.0, + layer_set_id: LayerSetId = "mech", + view_count: int = 1, + dim_style: DimStyle = "chain", + allow_assumptions: bool = False, + ): + from engineering.preflight import preflight_drawing + + await self._ensure_document_state() + + result = preflight_drawing( + intent, + requirements, + sheet_size, + scale, + layer_set_id, + view_count, + dim_style, + allow_assumptions=allow_assumptions, + ) + self._preflight_result = result + return result + + async def drawing_plan( + self, + intent: str, + sheet_size: SheetSize = "A3", + scale: float = 1.0, + layer_set_id: LayerSetId = "mech", + view_count: int = 1, + dim_style: DimStyle = "chain", + notes: list[str] | None = None, + requirements: dict | None = None, + spec_hash: str | None = None, + ) -> PlanSpec: + """Commit a drawing intent before any geometry is created. + Returns a PlanSpec and stores it on the backend; read it back with + `get_plan_spec()`. (N8: the previous claim that `drawing_critique` + replays this plan was untrue — critique runs geometric checks, not a + plan diff.)""" + from engineering.plan_spec import PlanSpec + + await self._ensure_document_state() + preflight_status = "skipped" + normalized_plan: dict[str, Any] | None = None + if spec_hash is not None: + from engineering.preflight import preflight_drawing + + preflight = getattr(self, "_preflight_result", None) + if preflight is None or preflight.spec_hash != spec_hash: + raise RuntimeError("drawing_plan: spec_hash does not match the latest preflight.") + if not preflight.ready: + raise RuntimeError( + "drawing_plan: preflight is not ready; resolve questions/conflicts first." + ) + candidate = preflight_drawing( + intent, + requirements + if requirements is not None + else preflight.normalized_spec["requirements"], + sheet_size, + scale, + layer_set_id, + view_count, + dim_style, + ) + if candidate.spec_hash != spec_hash: + raise RuntimeError( + "drawing_plan: supplied fields do not match the hashed preflight spec." + ) + normalized_plan = preflight.normalized_spec + preflight_status = "ready" + + if normalized_plan is not None: + intent = normalized_plan["intent"] + sheet_size = normalized_plan["sheet_size"] + scale = normalized_plan["scale"] + layer_set_id = normalized_plan["layer_set_id"] + view_count = normalized_plan["view_count"] + dim_style = normalized_plan["dim_style"] + requirements = normalized_plan["requirements"] + + plan = PlanSpec( + intent=str(intent), + sheet_size=sheet_size, + scale=float(scale), + layer_set_id=layer_set_id, + view_count=int(view_count), + dim_style=dim_style, + notes=list(notes) if notes else [], + requirements=dict(requirements or {}), + spec_hash=spec_hash, + preflight_status=preflight_status, + ) + self._plan_spec = plan + return plan + + async def drawing_critique( + self, + focus: list[CritiqueFocus] | None = None, + ) -> list[Issue]: + """Run premium-quality geometric checks; return zero issues before + `drawing_finalize`. `focus=None` runs all checks. (N8: this does NOT + replay the committed PlanSpec — it inspects the live drawing geometry.)""" + from engineering.critique import run_critique + + await self._ensure_document_state() + return await run_critique(self, focus) + + def get_plan_spec(self) -> dict | None: + """Return the PlanSpec committed by `drawing_plan` as a dict, or None + if no plan has been committed. (N8: makes the stored `_plan_spec` + actually readable instead of being write-only dead state.)""" + plan = getattr(self, "_plan_spec", None) + return plan.to_dict() if plan is not None else None + + async def point_from_snap( + self, + handle: str, + snap: SnapType, + ref_x: float | None = None, + ref_y: float | None = None, + ) -> tuple[float, float]: + """Return a deterministic snap point on `handle`. + snap ∈ {end, mid, center, quad, perp, near}. For `near/perp`, + `ref_x/y` is the reference point. Sources geometry from the shared + EntityInfo.properties contract — works on every backend.""" + ent = await self.entity_get(handle) + et = ent.type + p = ent.properties + ref = (float(ref_x), float(ref_y)) if ref_x is not None and ref_y is not None else None + + def _line_pts(): + s = p.get("start") + e = p.get("end") + if s is None or e is None: + raise RuntimeError(f"point_from_snap: entity {handle} ({et}) has no start/end.") + return (float(s[0]), float(s[1])), (float(e[0]), float(e[1])) + + def _circle_geom(): + c = p.get("center") + r = p.get("radius") + if c is None or r is None: + raise RuntimeError(f"point_from_snap: entity {handle} ({et}) has no center/radius.") + return float(c[0]), float(c[1]), float(r) + + def _arc_angles(): + # N7: use .get() + descriptive RuntimeError (matching _line_pts / + # _circle_geom) instead of bracket indexing that raises an opaque + # KeyError when start_angle/end_angle are missing. + sa = p.get("start_angle") + ea = p.get("end_angle") + if sa is None or ea is None: + raise RuntimeError( + f"point_from_snap: entity {handle} ({et}) has no start_angle/end_angle." + ) + return math.radians(float(sa)), math.radians(float(ea)) + + if snap == "end": + if et == "LINE": + s, e = _line_pts() + if ref is None: + return s + ds = (s[0] - ref[0]) ** 2 + (s[1] - ref[1]) ** 2 + de = (e[0] - ref[0]) ** 2 + (e[1] - ref[1]) ** 2 + return s if ds <= de else e + if et == "ARC": + cx, cy, r = _circle_geom() + sa, ea = _arc_angles() + sp = (cx + r * math.cos(sa), cy + r * math.sin(sa)) + ep = (cx + r * math.cos(ea), cy + r * math.sin(ea)) + if ref is None: + return sp + ds = (sp[0] - ref[0]) ** 2 + (sp[1] - ref[1]) ** 2 + de = (ep[0] - ref[0]) ** 2 + (ep[1] - ref[1]) ** 2 + return sp if ds <= de else ep + raise RuntimeError(f"point_from_snap: 'end' snap not supported on {et}") + + if snap == "mid": + if et == "LINE": + s, e = _line_pts() + return ((s[0] + e[0]) / 2.0, (s[1] + e[1]) / 2.0) + if et == "ARC": + cx, cy, r = _circle_geom() + sa, ea = _arc_angles() + if ea < sa: + ea += 2 * math.pi + ma = (sa + ea) / 2.0 + return (cx + r * math.cos(ma), cy + r * math.sin(ma)) + raise RuntimeError(f"point_from_snap: 'mid' snap not supported on {et}") + + if snap == "center": + if et in ("CIRCLE", "ARC", "ELLIPSE"): + c = p.get("center") + if c is None: + raise RuntimeError(f"point_from_snap: entity {handle} ({et}) has no center.") + return (float(c[0]), float(c[1])) + raise RuntimeError(f"point_from_snap: 'center' snap not supported on {et}") + + # NEW-snap-quad-arc / NEW-snap-near-arc: an ARC only covers part of the + # full circle, so quad/near candidates derived from the circle may fall + # outside the [start_angle, end_angle] sweep. Restrict (quad) / clamp + # (near) the projected angle to the sweep, with 2π wraparound. + def _arc_sweep(): + sa, ea = _arc_angles() + if ea < sa: + ea += 2 * math.pi + return sa, ea + + def _angle_in_sweep(theta: float, sa: float, ea: float) -> bool: + # Normalise theta into [sa, sa+2π) and test against [sa, ea]. + t = sa + ((theta - sa) % (2 * math.pi)) + return t <= ea + 1e-9 + + def _arc_endpoints(cx: float, cy: float, r: float, sa: float, ea: float): + return ( + (cx + r * math.cos(sa), cy + r * math.sin(sa)), + (cx + r * math.cos(ea), cy + r * math.sin(ea)), + ) + + if snap == "quad": + if et == "CIRCLE": + cx, cy, r = _circle_geom() + pts = [(cx + r, cy), (cx, cy + r), (cx - r, cy), (cx, cy - r)] + if ref is None: + return pts[0] + return min(pts, key=lambda q: (q[0] - ref[0]) ** 2 + (q[1] - ref[1]) ** 2) + if et == "ARC": + cx, cy, r = _circle_geom() + sa, ea = _arc_sweep() + quad_angles = (0.0, math.pi / 2.0, math.pi, 3 * math.pi / 2.0) + pts = [ + (cx + r * math.cos(a), cy + r * math.sin(a)) + for a in quad_angles + if _angle_in_sweep(a, sa, ea) + ] + if not pts: + # No quadrant lies on the sweep — fall back to arc endpoints. + pts = list(_arc_endpoints(cx, cy, r, sa, ea)) + if ref is None: + return pts[0] + return min(pts, key=lambda q: (q[0] - ref[0]) ** 2 + (q[1] - ref[1]) ** 2) + raise RuntimeError(f"point_from_snap: 'quad' snap not supported on {et}") + + if snap == "perp": + if ref is None: + raise RuntimeError("point_from_snap: 'perp' requires ref_x/ref_y.") + if et == "LINE": + s, e = _line_pts() + dx = e[0] - s[0] + dy = e[1] - s[1] + L2 = dx * dx + dy * dy + if L2 < 1e-18: + raise RuntimeError("point_from_snap: line has zero length.") + t = ((ref[0] - s[0]) * dx + (ref[1] - s[1]) * dy) / L2 + return (s[0] + t * dx, s[1] + t * dy) + raise RuntimeError(f"point_from_snap: 'perp' snap not supported on {et}") + + if snap == "near": + if ref is None: + raise RuntimeError("point_from_snap: 'near' requires ref_x/ref_y.") + if et == "LINE": + s, e = _line_pts() + dx = e[0] - s[0] + dy = e[1] - s[1] + L2 = dx * dx + dy * dy + if L2 < 1e-18: + return s + t = ((ref[0] - s[0]) * dx + (ref[1] - s[1]) * dy) / L2 + t = max(0.0, min(1.0, t)) + return (s[0] + t * dx, s[1] + t * dy) + if et == "CIRCLE": + cx, cy, r = _circle_geom() + vx, vy = ref[0] - cx, ref[1] - cy + L = math.hypot(vx, vy) + if L < 1e-18: + return (cx + r, cy) + return (cx + r * vx / L, cy + r * vy / L) + if et == "ARC": + cx, cy, r = _circle_geom() + sa, ea = _arc_sweep() + vx, vy = ref[0] - cx, ref[1] - cy + L = math.hypot(vx, vy) + if L < 1e-18: + # Degenerate ref at center: return the start endpoint. + return (cx + r * math.cos(sa), cy + r * math.sin(sa)) + theta = math.atan2(vy, vx) + if _angle_in_sweep(theta, sa, ea): + return (cx + r * math.cos(theta), cy + r * math.sin(theta)) + # Projected angle is off the sweep — fall back to the nearer endpoint. + sp, ep = _arc_endpoints(cx, cy, r, sa, ea) + ds = (sp[0] - ref[0]) ** 2 + (sp[1] - ref[1]) ** 2 + de = (ep[0] - ref[0]) ** 2 + (ep[1] - ref[1]) ** 2 + return sp if ds <= de else ep + raise RuntimeError(f"point_from_snap: 'near' snap not supported on {et}") + + if snap == "int": + raise RuntimeError( + "point_from_snap: 'int' (intersection) requires a 2nd entity; " + "compute via two endpoints. V2 will accept a 2nd handle." + ) + + raise RuntimeError( + f"point_from_snap: unknown snap type '{snap}'. " + "Use one of: end, mid, center, quad, perp, near." + ) + + async def point_intersection( + self, + handle1: str, + handle2: str, + ref_x: float | None = None, + ref_y: float | None = None, + ) -> tuple[float, float]: + """Return the intersection point of two geometry entities. + + Supports LINE-LINE, LINE-CIRCLE, and CIRCLE-CIRCLE cases. When + multiple intersections exist (e.g. two circles), ``ref_x``/``ref_y`` + selects the candidate nearest to that reference point. If omitted the + first candidate is returned. + + Raises RuntimeError when no real intersection exists or entity types + are unsupported. + """ + info1 = await self.entity_get(handle1) + info2 = await self.entity_get(handle2) + t1, t2 = info1.type.upper(), info2.type.upper() + props1, props2 = info1.properties, info2.properties + + candidates: list[tuple[float, float]] = [] + + if t1 == "LINE" and t2 == "LINE": + # Line-line intersection via parametric form + x1, y1 = props1["start"][0], props1["start"][1] + x2, y2 = props1["end"][0], props1["end"][1] + x3, y3 = props2["start"][0], props2["start"][1] + x4, y4 = props2["end"][0], props2["end"][1] + dx1, dy1 = x2 - x1, y2 - y1 + dx2, dy2 = x4 - x3, y4 - y3 + denom = dx1 * dy2 - dy1 * dx2 + if abs(denom) < 1e-12: + raise RuntimeError("point_intersection: lines are parallel or coincident.") + t = ((x3 - x1) * dy2 - (y3 - y1) * dx2) / denom + candidates = [(x1 + t * dx1, y1 + t * dy1)] + + elif {t1, t2} == {"LINE", "CIRCLE"}: + line_p = props1 if t1 == "LINE" else props2 + circ_p = props2 if t1 == "LINE" else props1 + lx1, ly1 = line_p["start"][0], line_p["start"][1] + lx2, ly2 = line_p["end"][0], line_p["end"][1] + cx, cy = circ_p["center"][0], circ_p["center"][1] + r = circ_p["radius"] + dx, dy = lx2 - lx1, ly2 - ly1 + fx, fy = lx1 - cx, ly1 - cy + a = dx * dx + dy * dy + b = 2.0 * (fx * dx + fy * dy) + c = fx * fx + fy * fy - r * r + disc = b * b - 4.0 * a * c + if disc < 0: + raise RuntimeError("point_intersection: line does not intersect circle.") + sq = math.sqrt(disc) + for sign in (1, -1): + t = (-b + sign * sq) / (2.0 * a) + candidates.append((lx1 + t * dx, ly1 + t * dy)) + + elif t1 == "CIRCLE" and t2 == "CIRCLE": + cx1, cy1, r1 = props1["center"][0], props1["center"][1], props1["radius"] + cx2, cy2, r2 = props2["center"][0], props2["center"][1], props2["radius"] + dx, dy = cx2 - cx1, cy2 - cy1 + d = math.hypot(dx, dy) + if d < 1e-12: + raise RuntimeError("point_intersection: circles are concentric.") + if d > r1 + r2 + 1e-9 or d < abs(r1 - r2) - 1e-9: + raise RuntimeError("point_intersection: circles do not intersect.") + a = (r1 * r1 - r2 * r2 + d * d) / (2.0 * d) + h_sq = r1 * r1 - a * a + h = math.sqrt(max(h_sq, 0.0)) + mx = cx1 + a * dx / d + my = cy1 + a * dy / d + candidates = [ + (mx + h * dy / d, my - h * dx / d), + (mx - h * dy / d, my + h * dx / d), + ] + + else: + raise RuntimeError( + f"point_intersection: unsupported entity combination {t1}+{t2}. " + "Supported: LINE-LINE, LINE-CIRCLE, CIRCLE-CIRCLE." + ) + + if len(candidates) == 1: + return candidates[0] + if ref_x is not None and ref_y is not None: + return min( + candidates, + key=lambda p: (p[0] - float(ref_x)) ** 2 + (p[1] - float(ref_y)) ** 2, + ) + return candidates[0] + + async def point_tangent( + self, + circle_handle: str, + from_x: float, + from_y: float, + ref_x: float | None = None, + ref_y: float | None = None, + ) -> tuple[float, float]: + """Return the tangent point on a circle from an external point. + + When two tangent points exist, ``ref_x``/``ref_y`` selects the + nearer one; without it, the first is returned (counter-clockwise from + the from-point—circle-center line). + + Raises RuntimeError if the point is inside the circle. + """ + info = await self.entity_get(circle_handle) + if info.type.upper() != "CIRCLE": + raise RuntimeError( + f"point_tangent: handle {circle_handle!r} is {info.type}, expected CIRCLE." + ) + cx, cy = info.properties["center"][0], info.properties["center"][1] + r = info.properties["radius"] + fx, fy = float(from_x), float(from_y) + dx, dy = cx - fx, cy - fy + d = math.hypot(dx, dy) + if d < r - 1e-9: + raise RuntimeError( + f"point_tangent: from-point ({fx}, {fy}) is inside the circle " + f"(center ({cx}, {cy}), radius {r})." + ) + if d < 1e-12: + raise RuntimeError("point_tangent: from-point coincides with circle center.") + # Tangent point T lies on the circle such that (T-F)⊥(T-C). + # Solving cos(θ - α) = -r/d where α = atan2(C-F) gives the two + # circle angles θ at which the tangent touches the perimeter. + alpha = math.atan2(dy, dx) # direction from F to C + # NEW-base-1: clamp the acos argument to [-1, 1] so a from-point in the + # narrow band [r-1e-9, r) (past the internal-point guard but with + # |-r/d| marginally > 1 from float error) degenerates gracefully to the + # tangent-at-perimeter case instead of raising a raw ValueError + # ("math domain error"). Genuine internal points (d < r) are already + # rejected by the RuntimeError guard above. + acos_neg = math.acos(max(-1.0, min(1.0, -r / d))) # (π/2, π] for external pt + candidates: list[tuple[float, float]] = [ + (cx + r * math.cos(alpha + acos_neg), cy + r * math.sin(alpha + acos_neg)), + (cx + r * math.cos(alpha - acos_neg), cy + r * math.sin(alpha - acos_neg)), + ] + if ref_x is not None and ref_y is not None: + return min( + candidates, + key=lambda p: (p[0] - float(ref_x)) ** 2 + (p[1] - float(ref_y)) ** 2, + ) + return candidates[0] + + @abstractmethod + async def _create_xline( + self, + x: float, + y: float, + dx: float, + dy: float, + layer: str, + ) -> EntityInfo: + """Create an infinite construction line (XLINE) through (x, y) with + unit direction (dx, dy) on `layer`. Backend-specific: XLINE has no + cross-backend entity_create_* equivalent.""" + ... + + async def _ensure_layer(self, name: str, color: int = 7) -> None: + """Idempotently make sure `name` exists (no-op if already present).""" + try: + existing = {lyr.name for lyr in await self.layer_list()} + except Exception: + existing = set() + if name not in existing: + try: + await self.layer_create(name, color=color) + except Exception: + pass # already created / racy create — harmless + + def _active_layer_set_id(self) -> str: + """Best-effort active layer set: an explicit `drawing_apply_iso_layers` + wins, else the committed PlanSpec, else 'mech' (the default bootstrap).""" + explicit = getattr(self, "_active_layer_set", None) + if explicit: + return explicit + plan = getattr(self, "_plan_spec", None) + if plan is not None: + return getattr(plan, "layer_set_id", "mech") + return "mech" + + def _role_layer(self, role: str) -> str: + """Resolve the layer name for a role ('dim' | 'construction') in the + active layer set, so dims/scaffolding land on the right layer for + iso13567 (M-DIMEN-T-N / M-CONST-E-N) not just mech/pid (DIM/CONSTRUCTION).""" + from engineering.layers import resolve_role_layer + + return resolve_role_layer(self._active_layer_set_id(), role) + + async def construction_xline( + self, + x: float, + y: float, + angle_deg: float, + layer: str | None = None, + ) -> EntityInfo: + """Create an infinite construction line (XLINE) on the active layer set's + construction layer (auto-created with color 250 = lightest if missing).""" + layer = layer or self._role_layer("construction") + await self._ensure_layer(layer, color=250) + ang = math.radians(float(angle_deg)) + return await self._create_xline( + float(x), + float(y), + math.cos(ang), + math.sin(ang), + layer, + ) + + async def construction_clear( + self, + layer: str | None = None, + ) -> dict: + """Delete every entity on the active layer set's construction layer. + Must be called before `drawing_finalize`.""" + layer = layer or self._role_layer("construction") + try: + ents = await self.entity_list(layer_filter=layer, limit=5000) + except Exception: + ents = [] + deleted = 0 + for ent in ents: + try: + await self.entity_delete(ent.handle) + deleted += 1 + except Exception: + continue + return {"ok": True, "layer": layer, "deleted": deleted} + + async def drawing_apply_iso_layers( + self, + standard: LayerSetId = "mech", + ) -> dict: + """Bootstrap a full ISO-conformant layer set with correct colors + and lineweights. Idempotent.""" + from engineering.layers import apply_layer_set + + result = await apply_layer_set(self, standard) + self._active_layer_set = standard + return {"ok": True, "standard": standard, "layers": result} + + async def dimension_auto( + self, + handles: list[str], + style: DimStyle = "chain", + offset: float = 10.0, + layer: str | None = None, + ) -> list[EntityInfo]: + """Generate ISO 129 dimensions across `handles` in the chosen style + (chain | baseline | ordinate). `offset` is the dimension-line + distance from the reference geometry (mm). `layer` defaults to the + active layer set's dimension layer (DIM, or M-DIMEN-T-N for iso13567).""" + if not handles: + return [] + dim_layer = layer or self._role_layer("dim") + if style not in ("chain", "baseline", "ordinate"): + raise RuntimeError( + f"dimension_auto: unknown style '{style}'. Use 'chain', 'baseline', or 'ordinate'." + ) + segs: list[tuple[float, float, float, float]] = [] + for h in handles: + ent = await self.entity_get(h) + if ent.type != "LINE": + raise RuntimeError( + f"dimension_auto V1: only LINE entities supported (handle {h} is {ent.type})." + ) + s = ent.properties.get("start") + e = ent.properties.get("end") + if s is None or e is None: + raise RuntimeError(f"dimension_auto: line {h} missing start/end.") + segs.append((float(s[0]), float(s[1]), float(e[0]), float(e[1]))) + + off = float(offset) + results: list[EntityInfo] = [] + + if style == "chain": + # Each line gets its own linear (rotated) dimension, offset clear of + # the geometry along its own perpendicular. + for sx, sy, ex, ey in segs: + mx = (sx + ex) / 2.0 + my = (sy + ey) / 2.0 + dx, dy = ex - sx, ey - sy + L = math.hypot(dx, dy) or 1.0 + nx, ny = -dy / L, dx / L + dim = await self.dimension_linear( + sx, + sy, + ex, + ey, + mx + nx * off, + my + ny * off, + rotation=math.degrees(math.atan2(dy, dx)), + layer=dim_layer, + ) + results.append(dim) + + elif style == "baseline": + sx0, sy0, ex0, ey0 = segs[0] + dx, dy = ex0 - sx0, ey0 - sy0 + L0 = math.hypot(dx, dy) or 1.0 + ux, uy = dx / L0, dy / L0 + base_rot = math.degrees(math.atan2(dy, dx)) + for i, (_sx, _sy, ex, ey) in enumerate(segs): + dim = await self.dimension_linear( + sx0, + sy0, + ex, + ey, + sx0 - uy * (off + i * off), + sy0 + ux * (off + i * off), + # Dimension line must run parallel to the (possibly rotated) + # baseline, not horizontal. + rotation=base_rot, + layer=dim_layer, + ) + results.append(dim) + + else: # ordinate — stacked X/Y distances; each feature offset so dims don't overlap + for i, (sx, sy, ex, ey) in enumerate(segs): + rung = off + i * off # stagger per feature to avoid coincident dims + if abs(ex - sx) > 1e-9: + dim = await self.dimension_linear( + sx, + sy, + ex, + sy, + sx + (ex - sx) / 2.0, + max(sy, ey) + rung, + layer=dim_layer, + ) + results.append(dim) + if abs(ey - sy) > 1e-9: + dim = await self.dimension_linear( + ex, + sy, + ex, + ey, + max(sx, ex) + rung, + (sy + ey) / 2.0, + rotation=90.0, + layer=dim_layer, + ) + results.append(dim) + + return results + + async def entity_select_smart( + self, + predicate: dict, + ) -> list[EntityInfo]: + """Semantic entity selection. Predicate keys (all optional, AND-ed): + - type: "LINE" | "CIRCLE" | ... + - layer: layer name + - near: [x, y, radius] — entity reference point within circle + - length_range: [min, max] — applies to LINE/ARC + - color: ACI int + """ + if not isinstance(predicate, dict): + raise RuntimeError("entity_select_smart: predicate must be a dict.") + ptype = predicate.get("type") + player = predicate.get("layer") + pnear = predicate.get("near") # [x, y, radius] + plen = predicate.get("length_range") # [min, max] + pcolor = predicate.get("color") + + ents = await self.entity_list( + type_filter=ptype.upper() if isinstance(ptype, str) else None, + layer_filter=player if isinstance(player, str) else None, + limit=5000, + ) + + def _ok(e: EntityInfo) -> bool: + if pcolor is not None and int(e.color) != int(pcolor): + return False + if pnear is not None: + try: + nx, ny, nr = float(pnear[0]), float(pnear[1]), float(pnear[2]) + except Exception: + return False + pt = ( + e.properties.get("start") + or e.properties.get("center") + or e.properties.get("insertion") + or e.properties.get("insertion_point") + ) + if pt is None: + return False + if (float(pt[0]) - nx) ** 2 + (float(pt[1]) - ny) ** 2 > nr**2: + return False + if plen is not None and e.type in ("LINE", "ARC"): + try: + lmin, lmax = float(plen[0]), float(plen[1]) + except Exception: + return False + length = e.properties.get("length") + if length is None or not (lmin <= float(length) <= lmax): + return False + return True + + return [e for e in ents if _ok(e)] diff --git a/backends/contracts/settings.py b/backends/contracts/settings.py new file mode 100644 index 0000000..872de99 --- /dev/null +++ b/backends/contracts/settings.py @@ -0,0 +1,139 @@ +"""Friendly facade over system variables. + +Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7). +``AutoCADBackend`` composes every contract in this package, so importing +``backends.base.AutoCADBackend`` is unchanged for callers. +""" + +from __future__ import annotations + +from typing import Any + + +class SettingsContract: + # A user-facing wrapper over system_get_variable / system_set_variable that + # maps memorable names ("units", "dimscale", …) to AutoCAD system variables. + # Concrete + backend-agnostic: both engines accept the bare sysvar name, so + # the same call reads/writes on live COM and headless ezdxf alike. + + async def drawing_settings(self, settings: dict | None = None) -> dict: + """Read (no args) or apply (with args) common drawing settings. + + With ``settings=None`` returns a snapshot of every known setting. With a + dict, applies each provided key and returns ``{applied, errors, current}``. + Friendly keys: units, linear_precision, angular_precision, ltscale, + dimscale, text_size, point_mode, point_size, osmode, fillet_radius. + `units` accepts mm/cm/m/inch/feet (mapped to the INSUNITS code).""" + if not settings: + snapshot: dict = {} + for key, (var, kind) in _SETTING_MAP.items(): + try: + raw = await self.system_get_variable(var) + except Exception as exc: + snapshot[key] = {"error": str(exc)} + continue + snapshot[key] = _decode_setting(key, kind, raw) + return {"ok": True, "settings": snapshot} + + applied: dict = {} + errors: dict = {} + for key, value in settings.items(): + spec = _SETTING_MAP.get(key) + if spec is None: + errors[key] = f"unknown setting (valid: {sorted(_SETTING_MAP)})" + continue + var, kind = spec + try: + encoded = _encode_setting(key, kind, value) + await self.system_set_variable(var, encoded) + applied[key] = value + except Exception as exc: + errors[key] = str(exc) + + result = {"ok": not errors, "applied": applied} + if errors: + result["errors"] = errors + return result + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +# Friendly setting name -> (AutoCAD system variable, value kind). +_SETTING_MAP: dict[str, tuple[str, str]] = { + "units": ("INSUNITS", "units"), + "linear_precision": ("LUPREC", "int"), + "angular_precision": ("AUPREC", "int"), + "ltscale": ("LTSCALE", "float"), + "dimscale": ("DIMSCALE", "float"), + "text_size": ("TEXTSIZE", "float"), + "point_mode": ("PDMODE", "int"), + "point_size": ("PDSIZE", "float"), + "osmode": ("OSMODE", "int"), + "fillet_radius": ("FILLETRAD", "float"), +} + +# INSUNITS code table (AutoCAD $INSUNITS): friendly name <-> integer code. +_UNIT_TO_CODE: dict[str, int] = { + "unitless": 0, + "inch": 1, + "inches": 1, + "in": 1, + "feet": 2, + "ft": 2, + "foot": 2, + "mm": 4, + "millimeter": 4, + "millimeters": 4, + "cm": 5, + "centimeter": 5, + "centimeters": 5, + "m": 6, + "meter": 6, + "meters": 6, +} +_CODE_TO_UNIT: dict[int, str] = {0: "unitless", 1: "inch", 2: "feet", 4: "mm", 5: "cm", 6: "m"} + + +def _encode_setting(key: str, kind: str, value: Any) -> Any: + """Coerce a friendly value into the raw system-variable value.""" + if kind == "units": + if isinstance(value, (int, float)) and not isinstance(value, bool): + return int(value) + code = _UNIT_TO_CODE.get(str(value).strip().lower()) + if code is None: + raise ValueError( + f"units: unknown value {value!r}. Use one of " + f"{sorted(set(_UNIT_TO_CODE))} or an INSUNITS integer." + ) + return code + if kind == "int": + return int(float(value)) + if kind == "float": + return float(value) + return value + + +def _decode_setting(key: str, kind: str, raw: Any) -> Any: + """Present a raw system-variable value in friendly form.""" + if raw is None: + return None + if kind == "units": + try: + code = int(raw) + except (TypeError, ValueError): + return raw + return {"code": code, "name": _CODE_TO_UNIT.get(code, "unknown")} + if kind == "int": + try: + return int(raw) + except (TypeError, ValueError): + return raw + if kind == "float": + try: + return float(raw) + except (TypeError, ValueError): + return raw + return raw diff --git a/backends/contracts/solids.py b/backends/contracts/solids.py new file mode 100644 index 0000000..b7fd766 --- /dev/null +++ b/backends/contracts/solids.py @@ -0,0 +1,79 @@ +"""ACIS solids (live AutoCAD only). + +Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7). +``AutoCADBackend`` composes every contract in this package, so importing +``backends.base.AutoCADBackend`` is unchanged for callers. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class SolidContract(ABC): + @abstractmethod + async def solid_box( + self, cx: float, cy: float, cz: float, length: float, width: float, height: float + ) -> dict: + """Create a 3D solid box centered at (cx, cy, cz).""" + ... + + @abstractmethod + async def solid_cylinder( + self, cx: float, cy: float, cz: float, radius: float, height: float + ) -> dict: + """Create a 3D solid cylinder centered at (cx, cy, cz).""" + ... + + @abstractmethod + async def solid_extrude( + self, profile_handle: str, height: float, taper_angle: float = 0.0 + ) -> dict: + """Extrude a closed profile (circle/closed polyline) into a solid.""" + ... + + @abstractmethod + async def solid_revolve( + self, + profile_handle: str, + axis_x1: float, + axis_y1: float, + axis_x2: float, + axis_y2: float, + angle: float = 360.0, + ) -> dict: + """Revolve a closed profile around an axis into a solid.""" + ... + + @abstractmethod + async def solid_boolean(self, target_handle: str, tool_handle: str, operation: str) -> dict: + """Boolean two solids: operation is union | subtract | intersect.""" + ... + + @abstractmethod + async def drawing_audit(self) -> dict: + """Audit the drawing, applying every repair the backend can make. + + This MUTATES the drawing. Returns ``{"ok", "repaired", "fixes", + "fix_count", "errors", "error_count"}``, where ``fixes`` are problems + already repaired and ``errors`` are problems that could not be; both are + lists of ``{"code": int, "name": str, "message": str, "handle": str | + None}``. + + A backend that repairs but cannot observe its own result reports + ``repaired``/``fix_count``/``error_count`` as ``None`` — never ``0``, + which would claim nothing was wrong — plus ``"detail": "unavailable"`` + and ``"capability": "audit_detail"`` so a client can pre-check the + boundary. Any implementation that repairs MUST mark the document dirty, + or the repair is discarded the next time the document is closed. + """ + ... + + @abstractmethod + async def drawing_close(self, save: bool = True) -> dict: ... + + @abstractmethod + async def drawing_undo(self) -> dict: ... + + @abstractmethod + async def drawing_redo(self) -> dict: ... diff --git a/backends/contracts/system.py b/backends/contracts/system.py new file mode 100644 index 0000000..a807761 --- /dev/null +++ b/backends/contracts/system.py @@ -0,0 +1,28 @@ +"""System variables and raw commands. + +Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7). +``AutoCADBackend`` composes every contract in this package, so importing +``backends.base.AutoCADBackend`` is unchanged for callers. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + + +class SystemContract(ABC): + @abstractmethod + async def system_status(self) -> dict: ... + + @abstractmethod + async def system_get_variable(self, name: str) -> Any: ... + + @abstractmethod + async def system_set_variable(self, name: str, value: Any) -> dict: ... + + @abstractmethod + async def system_run_command(self, command: str) -> dict: ... + + @abstractmethod + async def system_run_lisp(self, expression: str) -> dict: ... diff --git a/backends/contracts/transactions.py b/backends/contracts/transactions.py new file mode 100644 index 0000000..604a450 --- /dev/null +++ b/backends/contracts/transactions.py @@ -0,0 +1,21 @@ +"""Transactions. + +Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7). +``AutoCADBackend`` composes every contract in this package, so importing +``backends.base.AutoCADBackend`` is unchanged for callers. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class TransactionContract(ABC): + @abstractmethod + async def transaction_begin(self) -> dict: ... + + @abstractmethod + async def transaction_commit(self) -> dict: ... + + @abstractmethod + async def transaction_rollback(self) -> dict: ... diff --git a/backends/contracts/view.py b/backends/contracts/view.py new file mode 100644 index 0000000..4011b84 --- /dev/null +++ b/backends/contracts/view.py @@ -0,0 +1,43 @@ +"""Views and screenshots. + +Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7). +``AutoCADBackend`` composes every contract in this package, so importing +``backends.base.AutoCADBackend`` is unchanged for callers. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod + + +class ViewContract(ABC): + @abstractmethod + async def view_zoom_extents(self) -> dict: ... + + @abstractmethod + async def view_zoom_window( + self, + x1: float, + y1: float, + x2: float, + y2: float, + ) -> dict: ... + + @abstractmethod + async def view_screenshot(self, overlay_handles: bool = False) -> bytes | None: ... + + @abstractmethod + async def view_screenshot_grounded( + self, + overlay_handles: bool = True, + max_labels: int = 40, + ) -> dict: + """Screenshot plus the handle-to-position mapping it drew. + + Returns ``{"png", "labels", "labelled", "total", "truncated"}``. + ``labels`` maps handle -> ``[x, y]`` in drawing units, so the picture and + the handle space a caller can act on are the same thing. ``truncated`` + says a crowded drawing was capped rather than letting a labelled subset + read as the whole sheet. + """ + ... diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 8c65a88..360c11b 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -1,8 +1,12 @@ -"""ezdxf backend – headless DXF/DWG file operations. +"""ezdxf backend – headless DXF file operations. Works without AutoCAD installed. Ideal for file generation and analysis. All operations are synchronous but wrapped in asyncio.to_thread for non-blocking use in the FastMCP async context. + +DXF only: ezdxf has no DWG writer, so every save path that would put DXF bytes +behind a ``.dwg`` name refuses instead (capability ``dwg``). Real DWG I/O needs +the live COM backend. """ from __future__ import annotations @@ -19,7 +23,9 @@ from pathlib import Path from typing import Any import config +from engineering import measure +from . import ocs from .base import ( AutoCADBackend, BlockInfo, @@ -28,10 +34,14 @@ from .base import ( EntityInfo, FeatureCapability, LayerInfo, + UnsupportedCapabilityError, + _unsupported, normalize_lineweight, shoelace_area, ) +__all__ = ["EzdxfBackend", "UnsupportedCapabilityError", "_unsupported"] + log = logging.getLogger(__name__) @@ -56,11 +66,13 @@ def _new_agg_figure(*, figsize=None, dpi=100): try: import ezdxf from ezdxf import colors, units # noqa: F401 + from ezdxf.audit import AuditError from ezdxf.enums import TextEntityAlignment # noqa: F401 from ezdxf.math import BSpline, Vec2, Vec3 # noqa: F401 _EZDXF_OK = True except ImportError: + AuditError = None _EZDXF_OK = False log.warning("ezdxf not installed. ezdxf backend unavailable.") @@ -72,6 +84,71 @@ except ImportError: _BBOX_OK = False +# --------------------------------------------------------------------------- +# Capability refusals +# --------------------------------------------------------------------------- + + +# Re-exported, not redefined: server.py and the batch classifier import the +# refusal type from here, and an isinstance check against a second class object +# with the same name would quietly stop matching. + + +# ezdxf has no DWG writer. Writing DXF bytes to a .dwg path (the pre-1.5.0 +# behaviour of drawing_save) produces a file AutoCAD refuses or misreads, so the +# save paths refuse instead and name both escape hatches. +_DWG_WRITE_REFUSAL = ( + "{operation}: the headless ezdxf backend cannot write DWG. ezdxf has no DWG " + "writer, and writing DXF bytes to '{path}' would produce a file AutoCAD " + "refuses or misreads. Save with a .dxf extension instead, or switch to the " + "live COM backend (AUTOCAD_MCP_BACKEND=com, needs Windows + AutoCAD) for " + "real DWG output." +) + + +def _is_dwg_path(path: str) -> bool: + """True when `path` names a DWG file. The extension is authoritative (N2).""" + return Path(path).suffix.lower() == ".dwg" + + +def _refuse_dwg(operation: str, path: str) -> UnsupportedCapabilityError: + return UnsupportedCapabilityError( + "dwg", _DWG_WRITE_REFUSAL.format(operation=operation, path=path) + ) + + +def _audit_entry(entry) -> dict: + """Flatten an ezdxf ``ErrorEntry`` into something a client can read. + + ``ErrorEntry`` defines no ``__str__``, so the pre-1.5.0 + ``[str(e) for e in auditor.errors]`` handed users + ``''`` — a count of problems with no + way to learn what they were. ``code`` is the numeric ``AuditError`` value and + ``name`` its symbolic form (100/UNDEFINED_LINETYPE, 104/ + INVALID_BLOCK_REFERENCE_CYCLE, ...). + """ + entity = getattr(entry, "entity", None) + handle = None + if entity is not None and getattr(entity, "is_alive", True): + try: + handle = entity.dxf.get("handle", None) + except Exception: # a trashed entity has no usable namespace + handle = None + code = int(entry.code) + try: + name = AuditError(code).name + except ValueError: + # A code ezdxf added after this release. str(code) would put "999" in a + # field called `name`, which is not a name. + name = "UNKNOWN" + return { + "code": code, + "name": name, + "message": entry.message, + "handle": handle, + } + + # --------------------------------------------------------------------------- # Helper utilities # --------------------------------------------------------------------------- @@ -131,8 +208,300 @@ def _ensure_linetype_loaded(doc, name: str) -> None: ) +#: Types whose area lives in ACIS solid data. ezdxf stores SAT/SAB opaquely and +#: cannot evaluate or generate it; live AutoCAD exposes .Area on all of these. +#: This is an ENGINE boundary, so it refuses with a capability. +_ACIS_AREA_TYPES = frozenset({"REGION", "3DSOLID", "BODY", "SURFACE", "PLANESURFACE"}) + +_MEASURABLE_TYPES = ( + "LWPOLYLINE, POLYLINE (2D), CIRCLE, ELLIPSE, SPLINE, HATCH, SOLID, TRACE, 3DFACE" +) + + +def _measure_dxf_entity(ent, tolerance: float) -> dict: + """Area and perimeter of one entity, with its accuracy stated. + + The two failure modes are deliberately different exception types. A LINE + bounds no area on any engine, so that is a plain ``RuntimeError`` — a + capability tag would tell the user to switch backends for something no + backend can do. An ACIS type is a real headless-engine boundary and refuses + with ``UnsupportedCapabilityError``, naming the live backend. + """ + ent_type = ent.dxftype() + handle = ent.dxf.get("handle", "?") + result = { + "handle": str(handle), + "type": ent_type, + "closed": True, + "assumed_closed": False, + "method": "analytic", + "exact": True, + "flatten_tolerance": None, + "backend": "ezdxf", + "self_intersecting": None, + "perimeter_exact": True, + "loop_count": 1, + } + + if ent_type in _ACIS_AREA_TYPES: + raise UnsupportedCapabilityError( + "measure_area_acis", + f"entity_measure: {handle} is a {ent_type}. Its area lives in ACIS " + "solid data, which the headless ezdxf backend cannot evaluate — ezdxf " + "stores SAT/SAB opaquely and cannot generate it. Switch to the live " + "COM backend (AUTOCAD_MCP_BACKEND=com, needs Windows + AutoCAD).", + ) + + if ent_type == "CIRCLE": + radius = float(ent.dxf.radius) + result["area"] = math.pi * radius * radius + result["perimeter"] = 2.0 * math.pi * radius + return _round_measure(result) + + if ent_type == "ELLIPSE": + major = Vec2(ent.dxf.major_axis).magnitude + span = abs(float(ent.dxf.end_param) - float(ent.dxf.start_param)) + if abs(span - 2.0 * math.pi) < 1e-9: + result["area"] = measure.ellipse_area(major, float(ent.dxf.ratio)) + result["perimeter"] = measure.ellipse_perimeter_ramanujan(major, float(ent.dxf.ratio)) + result["method"] = "analytic_ellipse" + # Exact area, approximate perimeter — the two are reported separately + # so neither claim borrows credibility from the other. + result["perimeter_exact"] = False + return _round_measure(result) + return _round_measure(_flatten_measure(ent, tolerance, result)) + + if ent_type == "SPLINE": + return _round_measure(_flatten_measure(ent, tolerance, result)) + + if ent_type == "HATCH": + return _measure_hatch(ent, tolerance, result) + + vertices = None + if ent_type == "LWPOLYLINE": + vertices = [(float(x), float(y), float(b)) for x, y, b in ent.get_points(format="xyb")] + result["closed"] = bool(ent.closed) + elif ent_type == "POLYLINE": + mode = ent.get_mode() + if mode != "AcDb2dPolyline": + raise RuntimeError( + f"entity_measure: handle {handle} is a {mode}, which does not bound " + f"a planar area. Measurable types: {_MEASURABLE_TYPES}." + ) + vertices = [ + ( + float(v.dxf.location.x), + float(v.dxf.location.y), + float(v.dxf.get("bulge", 0.0) or 0.0), + ) + for v in ent.vertices + ] + result["closed"] = bool(ent.is_closed) + elif ent_type in ("SOLID", "TRACE", "3DFACE"): + corners = [ent.dxf.get(name) for name in ("vtx0", "vtx1", "vtx3", "vtx2")] + vertices = [(float(c.x), float(c.y), 0.0) for c in corners if c is not None] + + if vertices is None: + raise RuntimeError( + f"entity_measure: handle {handle} is {ent_type}, which does not bound an " + f"area. Measurable types: {_MEASURABLE_TYPES}." + ) + + result["assumed_closed"] = not result["closed"] + result["area"], result["perimeter"] = measure.polygon_area_perimeter(vertices, result["closed"]) + result["method"] = "analytic_bulge" if any(abs(v[2]) > 1e-12 for v in vertices) else "analytic" + result["self_intersecting"] = measure.is_self_intersecting([(v[0], v[1]) for v in vertices]) + return _round_measure(result) + + +def _flatten_measure(ent, tolerance: float, result: dict) -> dict: + """Fall back to a dense polyline approximation, and say that is what it is.""" + from ezdxf import path as _path + + pth = _path.make_path(ent) + points = [(float(v.x), float(v.y)) for v in pth.flattening(distance=tolerance)] + if len(points) < 3: + raise RuntimeError( + f"entity_measure: handle {result['handle']} produced no measurable outline." + ) + result["closed"] = bool(pth.is_closed) + result["assumed_closed"] = not result["closed"] + result["area"], result["perimeter"] = measure.polygon_area_perimeter( + [(x, y, 0.0) for x, y in points], result["closed"] + ) + result["method"] = "flattened" + result["exact"] = False + result["perimeter_exact"] = False + result["flatten_tolerance"] = tolerance + result["self_intersecting"] = measure.is_self_intersecting(points) + return result + + +#: AutoCAD's HPISLANDDETECTION, by the code DXF stores in group 75. +_HATCH_STYLE_NAMES = {0: "normal", 1: "outer", 2: "ignore"} + + +def _nesting_depths(loops: list[list[tuple[float, float]]]) -> list[int]: + """How many other loops each loop sits inside. + + Read off containment rather than off the boundary-path flags, because the + flags do not carry it: DXF marks a path external or outermost, never "two + levels down". Depth is precisely where AutoCAD's three island styles stop + agreeing, so guessing it would make `normal` and `outer` return the same + number and quietly be wrong for one of them. + + Hatch boundary loops do not cross, so any vertex of the inner loop settles + the question — the first one that is not sitting exactly on the other + loop's edge decides. + """ + from ezdxf import edgesmith + from ezdxf.math import Vec2 + + rings = [[Vec2(x, y) for x, y in points] for points in loops] + depths = [] + for index, points in enumerate(loops): + depth = 0 + for other, ring in enumerate(rings): + if other == index: + continue + for x, y in points: + verdict = edgesmith.is_point_in_polygon_2d(Vec2(x, y), ring) + if verdict != 0: + depth += verdict > 0 + break + depths.append(depth) + return depths + + +def _measure_hatch(ent, tolerance: float, result: dict) -> dict: + """The area a hatch actually fills: outer loops minus the islands in them. + + Counting the islands as filled is a 25% error on the test fixture here and + an unbounded one on a real section view, which is mostly holes. Which loops + count is not a house rule — it is `hatch_style`, reported back so the number + can be read against the drawing's own island setting. + + On a curved edge `flatten_tolerance` is not the accuracy knob it looks + like. ezdxf hands boundary paths over as cubic Beziers, so a circular edge + already carries the ~0.027% Bezier-vs-circle error before anything is + flattened, and tightening the tolerance converges on that wrong number + rather than on the true one. `exact` goes False for exactly this reason. + """ + from ezdxf import path as _path + + outlines: list[list[tuple[float, float]]] = [] + perimeter = 0.0 + curved = False + open_path = False + for boundary in ent.paths: + pth = _path.from_hatch_boundary_path(boundary) + curved = curved or pth.has_curves + open_path = open_path or not pth.is_closed + points = [(float(v.x), float(v.y)) for v in pth.flattening(distance=tolerance)] + if len(points) < 3: + continue + outlines.append(points) + perimeter += measure.polygon_area_perimeter([(x, y, 0.0) for x, y in points], True)[1] + + if not outlines: + raise RuntimeError( + f"entity_measure: handle {result['handle']} is a HATCH whose boundary paths " + "enclose nothing. A hatch that bounds no loop fills no area." + ) + + style_code = int(ent.dxf.get("hatch_style", 0) or 0) + style = _HATCH_STYLE_NAMES.get(style_code, "normal") + depths = _nesting_depths(outlines) + + area = 0.0 + for points, depth in zip(outlines, depths, strict=True): + if style == "ignore" and depth > 0: + continue # HPISLAND=2 fills straight over every island + if style == "outer" and depth > 1: + continue # only the outermost ring and the holes directly in it + signed = -1.0 if depth % 2 else 1.0 + area += signed * abs( + measure.polygon_area_perimeter([(x, y, 0.0) for x, y in points], True)[0] + ) + + verdicts = [measure.is_self_intersecting(points) for points in outlines] + result["area"] = abs(area) + result["perimeter"] = perimeter + result["method"] = "hatch_loops" + result["hatch_style"] = style + result["loop_count"] = len(outlines) + result["closed"] = True + result["assumed_closed"] = open_path + result["exact"] = not curved + result["perimeter_exact"] = not curved + result["flatten_tolerance"] = tolerance if curved else None + result["self_intersecting"] = ( + True + if any(v is True for v in verdicts) + else (False if any(v is False for v in verdicts) else None) + ) + return _round_measure(result) + + +def _round_measure(result: dict) -> dict: + """Six decimal places, matching analysis_measure_distance.""" + result["area"] = round(float(result["area"]), 6) + result["perimeter"] = round(float(result["perimeter"]), 6) + return result + + +#: How many handle labels a grounded screenshot will draw before it stops and +#: says so. Past this the labels bury the geometry they exist to explain. +DEFAULT_MAX_HANDLE_LABELS = 40 + + +def _label_anchor(ent) -> tuple[float, float] | None: + """Where an entity's handle label goes — the centre of its own extents. + + Uses the renderer's own bbox rather than the entity's stored coordinates, so + the label lands where the entity is *drawn* even in a rotated frame (T0.4). + """ + try: + from ezdxf import bbox as _bbox + + box = _bbox.extents([ent]) + if not box.has_data: + return None + return ( + (float(box.extmin.x) + float(box.extmax.x)) / 2.0, + (float(box.extmin.y) + float(box.extmax.y)) / 2.0, + ) + except Exception as exc: + log.debug("no label anchor for %s: %s", ent.dxftype(), exc) + return None + + +_IDENTITY_EXTRUSION = (0.0, 0.0, 1.0) + +#: The DXF types whose stored geometry is in the entity's own frame. LINE, MTEXT, +#: ELLIPSE, SPLINE and POINT carry an `extrusion` attribute too, and are already +#: correct — ezdxf overrides their `ocs()` to a pass-through — so any fix keyed on +#: "has extrusion" would break them. +_OCS_ENTITY_TYPES = frozenset( + { + "CIRCLE", + "ARC", + "LWPOLYLINE", + "TEXT", + "INSERT", + "DIMENSION", + "DIMLINEAR", + "DIMALIGNED", + "DIMANGULAR", + "DIMRADIUS", + "DIMDIAMETER", + "DIMORDINATE", + } +) + + def _entity_info_dxf(ent) -> EntityInfo: - """Convert an ezdxf entity to EntityInfo.""" + """Convert an ezdxf entity to EntityInfo. Coordinates out are always WCS.""" handle = ent.dxf.get("handle", "?") ent_type = ent.dxftype() layer = ent.dxf.get("layer", "0") @@ -142,34 +511,100 @@ def _entity_info_dxf(ent) -> EntityInfo: props: dict = {} + # T0.4 — everything leaving this function is WCS. CIRCLE/ARC/LWPOLYLINE/TEXT/ + # INSERT and a dimension's text position are stored in the entity's own frame, + # so a raw read is not where the entity is; `bounding_box` below already + # reports WCS, which is how the contradiction was found. `flat` distinguishes + # the two kinds of non-identity frame: a mirrored entity (extrusion -Z) still + # lies in the WCS XY plane and every field survives translation, while a + # tilted one projects to something xy cannot describe. + extrusion = _IDENTITY_EXTRUSION + try: + if ent.dxf.is_supported("extrusion"): + extrusion = tuple(ent.dxf.get("extrusion", _IDENTITY_EXTRUSION)) + except Exception as exc: + log.debug("reading extrusion for %s: %s", ent_type, exc) + needs_ocs = ent_type in _OCS_ENTITY_TYPES and not ocs.is_wcs_frame(extrusion) + flat = ocs.is_flat_frame(extrusion) + def _v2(v): """Convert Vec3/tuple to [x, y] list.""" return [float(v.x), float(v.y)] if hasattr(v, "x") else [float(v[0]), float(v[1])] + def _pt(v): + """A stored point as WCS [x, y] — the only spelling that leaves here.""" + x, y, z = ( + (float(v.x), float(v.y), float(getattr(v, "z", 0.0))) + if hasattr(v, "x") + else (float(v[0]), float(v[1]), float(v[2]) if len(v) > 2 else 0.0) + ) + return ocs.to_wcs_2d(extrusion, x, y, z) if needs_ocs else [x, y] + try: if ent_type == "LINE": props["start"] = _v2(ent.dxf.start) props["end"] = _v2(ent.dxf.end) props["length"] = ent.dxf.start.distance(ent.dxf.end) elif ent_type == "CIRCLE": - props["center"] = _v2(ent.dxf.center) - props["radius"] = ent.dxf.radius + props["center"] = _pt(ent.dxf.center) + # A tilted circle projects to an ellipse (measured: radius 5.0 has a + # 5.0 x 3.99 WCS-XY footprint), so `radius` has no meaning in the + # frame this dict is expressed in. Omitting it is silence; reporting + # it would be a fresh silent wrong number. + if flat: + props["radius"] = ent.dxf.radius elif ent_type == "ARC": - props["center"] = _v2(ent.dxf.center) - props["radius"] = ent.dxf.radius - props["start_angle"] = ent.dxf.start_angle - props["end_angle"] = ent.dxf.end_angle + props["center"] = _pt(ent.dxf.center) + if flat: + props["radius"] = ent.dxf.radius + props["start_angle"], props["end_angle"] = ( + ocs.wcs_arc_angles( + extrusion, + ent.dxf.center, + ent.dxf.radius, + ent.dxf.start_angle, + ent.dxf.end_angle, + ) + if needs_ocs + else (ent.dxf.start_angle, ent.dxf.end_angle) + ) # Arc length (N3) so entity_select_smart length_range works on ARCs. + # Computed from the ORIGINAL sweep: length is frame-invariant, and the + # WCS pair above is swapped for a left-handed frame. _sweep = (ent.dxf.end_angle - ent.dxf.start_angle) % 360.0 props["length"] = ent.dxf.radius * math.radians(_sweep) elif ent_type == "LWPOLYLINE": - props["points"] = [[float(pt[0]), float(pt[1])] for pt in ent.get_points()] + # LWPOLYLINE vertices carry no z of their own; the whole polyline + # sits at dxf.elevation within its frame. + _elev = float(ent.dxf.get("elevation", 0.0)) + props["points"] = [ + ( + ocs.to_wcs_2d(extrusion, pt[0], pt[1], _elev) + if needs_ocs + else [float(pt[0]), float(pt[1])] + ) + for pt in ent.get_points() + ] props["closed"] = ent.closed - props["length"] = ent.length() + # `ent.length()` used to be here. LWPolyline has no such method, so + # the AttributeError went into the debug log below and the key simply + # never appeared — and a caller shoelacing the xy points above to + # recover it lost 28.2% of the area on bulged geometry. Both come + # from the same exact, bulge-aware maths as entity_measure. + _verts = [(float(x), float(y), float(b)) for x, y, b in ent.get_points(format="xyb")] + _area, _length = measure.polygon_area_perimeter(_verts, bool(ent.closed)) + props["length"] = round(_length, 6) + if ent.closed: + props["area"] = round(_area, 6) elif ent_type == "TEXT": props["text"] = ent.dxf.text - props["insertion"] = _v2(ent.dxf.insert) + props["insertion"] = _pt(ent.dxf.insert) props["height"] = ent.dxf.height + # `rotation` stays in the entity's own frame. A mirrored TEXT is + # mirror-imaged, and no single scalar angle expresses that — measured + # 29.999 reported against a true WCS 150.0 — so normalising it would + # replace one wrong number with another. Named in the + # `ocs_normalized` capability reason instead. props["rotation"] = ent.dxf.get("rotation", 0.0) elif ent_type == "MTEXT": props["text"] = ent.text @@ -178,7 +613,7 @@ def _entity_info_dxf(ent) -> EntityInfo: props["rotation"] = ent.dxf.get("rotation", 0.0) elif ent_type == "INSERT": props["block_name"] = ent.dxf.name - props["insertion"] = _v2(ent.dxf.insert) + props["insertion"] = _pt(ent.dxf.insert) props["x_scale"] = ent.dxf.xscale if ent.dxf.hasattr("xscale") else 1.0 props["y_scale"] = ent.dxf.yscale if ent.dxf.hasattr("yscale") else 1.0 props["rotation_deg"] = ent.dxf.get("rotation", 0.0) @@ -203,18 +638,28 @@ def _entity_info_dxf(ent) -> EntityInfo: # Reference points for dim_overlap critique. ezdxf dimensions carry # a definition point and (usually) the text midpoint. try: + # defpoint is DXF group code 10 and WCS already — ezdxf's + # Dimension.transform partitions exactly here, sending defpoint + # through the raw matrix and only text_midpoint through the OCS. + # Translating it would corrupt a correct field. props["defpoint"] = _v2(ent.dxf.defpoint) except Exception: pass try: tm = ent.dxf.get("text_midpoint", None) if tm is not None: - props["text_position"] = _v2(tm) + props["text_position"] = _pt(tm) # group code 11 — OCS except Exception: pass except Exception as exc: log.debug("extracting entity properties for %s: %s", ent_type, exc) + if needs_ocs and not flat: + # The frame cannot be flattened into xy without losing information, so + # say which plane the entity is in rather than leaving the omissions + # above unexplained. + props["plane_normal"] = ocs.plane_normal(extrusion) + # bounding_box parity with the COM backend (same {min,max} shape) so clients # reading properties["bounding_box"] work on both engines (N5). try: @@ -270,6 +715,8 @@ class EzdxfBackend(AutoCADBackend): self._dirty: bool = False self._current_layer: str = "0" self._undo_stack: list[Path] = [] # user-facing undo history + self._redo_stack: list[Path] = [] # states stepped off by an undo + self._current_space: str = "Model" # where entity_create_* writes self._transaction_stack: list[Path] = [] # isolated transaction snapshots self._connected = False self._lock = asyncio.Lock() @@ -294,17 +741,63 @@ class EzdxfBackend(AutoCADBackend): features={ "drawing_2d": FeatureCapability(True, "native"), "dxf": FeatureCapability(True, "native"), + "dwg": FeatureCapability(False, reason="no_dwg_writer_requires_live_autocad"), "pdf": render_capability, "png": render_capability, "transactions": FeatureCapability(True, "snapshot"), + "audit_detail": FeatureCapability(True, "native"), + "handle_overlay": render_capability, + "revcloud": FeatureCapability(True, "native", reason="ezdxf_revcloud_helper"), + "wipeout": FeatureCapability(True, "native"), + "mtext_background_color": FeatureCapability(True, "native"), + "boundary_trace": FeatureCapability( + True, + "computed", + reason="straight_edges_split_at_intersections;curved_edges_not_split", + ), + "hatch_edge_paths": FeatureCapability(True, "native"), + "chspace": FeatureCapability( + True, + "computed", + reason=( + "top_view_untwisted_viewports_only;dimensions_refused_unless_frozen;" + "acis_proxy_table_refused;viewport_clipping_reported_not_applied" + ), + ), + "undo_history": ( + FeatureCapability(True, "snapshot") + if config.settings.ezdxf_undo_depth > 0 + else FeatureCapability(False, reason="set_EZDXF_UNDO_DEPTH_to_enable") + ), + "measure_area_acis": FeatureCapability( + False, reason="acis_evaluation_requires_live_autocad" + ), + "ocs_normalized": FeatureCapability( + True, + "wcs", + reason=( + "reported_xy_is_wcs;z_component_not_reported;" + "text_rotation_stays_in_entity_frame;hatch_boundaries_not_reported" + ), + ), + "ocs_tilted_plane": FeatureCapability( + False, reason="2d_xy_cannot_address_a_tilted_plane" + ), "table": FeatureCapability(True, "composite"), "mleader": FeatureCapability(True, "composite"), "preflight": FeatureCapability(True, "shared"), "refiner": FeatureCapability(True, "shared"), "delivery": FeatureCapability(True, "shared"), "paper_space": FeatureCapability(True, "native"), - "viewport_render": FeatureCapability( - False, reason="viewport_model_projection_requires_live_autocad" + # Measured, not assumed: a sheet holding nothing but a viewport + # renders the model geometry that viewport looks at, at its + # scale and position, clipped to its window. v1.4 declared this + # COM-only and sent callers to the live backend for something + # the headless one already did. + "viewport_render": ( + FeatureCapability(True, "rendered", reason="viewport_borders_not_drawn") + if render_available + else FeatureCapability(False, reason="optional_dependency_missing:matplotlib") ), "solid_3d": FeatureCapability( False, reason="acis_generation_requires_live_autocad" @@ -323,7 +816,7 @@ class EzdxfBackend(AutoCADBackend): self._connected = False def _cleanup_undo_stack(self): - for stack in (self._undo_stack, self._transaction_stack): + for stack in (self._undo_stack, self._redo_stack, self._transaction_stack): while stack: p = stack.pop() try: @@ -337,11 +830,113 @@ class EzdxfBackend(AutoCADBackend): return self._doc def _msp(self): - return self._require_doc().modelspace() + """The space new geometry goes into — model space, or the current layout. + + This returned ``doc.modelspace()`` unconditionally, so + ``layout_set_current("A3-Sheet")`` reported success and then every + entity created afterwards still landed in model space. Following the + current layout is also what makes a title block on a sheet possible at + all: the title block draws through the same ``entity_create_*`` calls. + """ + doc = self._require_doc() + if self._current_space == "Model": + return doc.modelspace() + try: + return doc.layouts.get(self._current_space) + except Exception as exc: + # A layout deleted out from under us must not strand every later + # write; fall back to model space and say why. + log.warning( + "current layout %r is gone (%s); falling back to model space", + self._current_space, + exc, + ) + self._current_space = "Model" + return doc.modelspace() + + @staticmethod + def _busy_error(timeout: float) -> RuntimeError: + return RuntimeError( + "ezdxf backend busy: another call has held the document lock for more " + f"than {timeout:g}s. Retry once it finishes, or raise EZDXF_CALL_TIMEOUT " + "(seconds; 0 disables the timeout entirely)." + ) + + @staticmethod + def _timeout_error(func, timeout: float) -> RuntimeError: + name = getattr(func, "__name__", "call") + return RuntimeError( + f"ezdxf call '{name}' timed out after {timeout:g}s. asyncio.to_thread " + "cannot be cancelled, so the worker thread was abandoned and the drawing " + "may be left mid-write - reopen it before trusting its contents. Raise " + "EZDXF_CALL_TIMEOUT (seconds; 0 disables the timeout entirely) if this " + "operation is legitimately slow." + ) async def _async(self, func, *args, **kwargs): - async with self._lock: - return await asyncio.to_thread(func, *args, **kwargs) + """Run a blocking ezdxf call in a worker thread, serialised and timed out. + + The ezdxf ``Drawing`` is not thread-safe, so every backend method funnels + through one lock. Without a timeout a single hung call held that lock + forever and every later tool call on the server deadlocked behind it. + + ``EZDXF_CALL_TIMEOUT`` (seconds, ``0`` disables) bounds the *whole* wait — + queueing behind an in-flight call plus the call itself — mirroring + ``ComBackend._run``, where the executor queue wait sits inside the same + ``wait_for``. + + Lock ownership on timeout: ``asyncio.to_thread`` cannot be cancelled, so + the worker thread outlives the timeout and may still be mutating the + document. The timed-out call therefore deliberately **never releases its + lock**; the backend swaps in a fresh ``asyncio.Lock`` and leaves the + abandoned call holding the orphaned one. The runaway thread can then never + hand the lock to new work mid-write, and the next tool call proceeds + immediately instead of deadlocking — the same abandon-and-rebuild shape the + COM backend uses for its stuck STA executor. Callers already queued on the + orphaned lock get the explicit "backend busy" error rather than silently + interleaving with the runaway thread. Timing out while merely *waiting* + for the lock does not abandon it: that lock is held by a live call which + will release it normally. Outside cancellation (client disconnect) also + releases normally, as it did before the timeout existed. + """ + timeout = config.settings.ezdxf_call_timeout + if timeout <= 0: + async with self._lock: + return await asyncio.to_thread(func, *args, **kwargs) + + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout + lock = self._lock + try: + await asyncio.wait_for(lock.acquire(), timeout) + except TimeoutError as exc: + raise self._busy_error(timeout) from exc + + call = asyncio.ensure_future(asyncio.to_thread(func, *args, **kwargs)) + try: + result = await asyncio.wait_for(call, max(deadline - loop.time(), 0.0)) + except TimeoutError as exc: + if not call.cancelled(): + # The call itself raised TimeoutError (an OSError subclass, and + # the same type wait_for raises) rather than overrunning the + # deadline. That is an ordinary failure: release the lock and + # let it through unchanged instead of blaming the deadline. + lock.release() + raise + if self._lock is lock: + self._lock = asyncio.Lock() + log.error( + "ezdxf call %r exceeded EZDXF_CALL_TIMEOUT=%gs; abandoning the worker " + "thread and the lock it holds", + getattr(func, "__name__", func), + timeout, + ) + raise self._timeout_error(func, timeout) from exc + except BaseException: + lock.release() + raise + lock.release() + return result # ── drawing management ──────────────────────────────────────────────────── @@ -397,6 +992,8 @@ class EzdxfBackend(AutoCADBackend): self._dirty = False self._current_layer = "0" self._reset_document_state() + self._current_space = "Model" + self._reset_history_baseline() return {"ok": True, "name": "untitled.dxf"} return await self._async(_sync) @@ -423,14 +1020,21 @@ class EzdxfBackend(AutoCADBackend): log.debug("reading $CLAYER from header: %s", exc) self._current_layer = "0" self._reset_document_state() + self._current_space = "Model" + self._reset_history_baseline() return {"ok": True, "name": Path(path).name, "path": path} return await self._async(_sync) async def drawing_save(self, path: str | None = None) -> dict: + save_path = path or self._doc_path + # `doc.saveas` derives the format from the extension and ezdxf can only + # write DXF, so a .dwg destination here would silently mislabel the file. + if save_path and _is_dwg_path(save_path): + raise _refuse_dwg("drawing_save", save_path) + def _sync(): doc = self._require_doc() - save_path = path or self._doc_path if not save_path: raise RuntimeError("No path specified and no current file path.") doc.saveas(save_path) @@ -441,11 +1045,10 @@ class EzdxfBackend(AutoCADBackend): return await self._async(_sync) async def drawing_save_as(self, path: str, fmt: str = "dxf") -> dict: - if fmt.lower() == "dwg": - raise RuntimeError( - "ezdxf backend cannot write true DWG binary. " - "Use save_path with a .dxf extension, or switch to the COM backend." - ) + # The extension is authoritative (N2): a .dwg name is a DWG request even + # when the caller passes fmt="dxf", because the bytes must match the name. + if fmt.lower() == "dwg" or _is_dwg_path(path): + raise _refuse_dwg("drawing_save_as", path) def _sync(): doc = self._require_doc() @@ -462,18 +1065,24 @@ class EzdxfBackend(AutoCADBackend): async def drawing_export_pdf(self, path: str, layout: str | None = None) -> dict: """Export to PDF via ezdxf's matplotlib backend. - With ``layout`` set to a paper-space layout name the layout's own - entities (titleblock, viewport frames, annotation) are rendered. - Model content is not projected through viewports headlessly - that - remains a COM-native capability and is reported in the result. + With ``layout`` set to a paper-space layout name, the layout's own + entities *and* the model content each viewport looks at are rendered, + at the viewport's scale and position and clipped to its window. + + This docstring used to say model projection was COM-only. It was + measured and it is not: a sheet holding nothing but a viewport renders + the geometry that viewport shows, and geometry outside the window is + correctly left out. What is genuinely missing is the viewport *border*, + which the headless renderer does not draw. """ def _sync(): doc = self._require_doc() - if layout and layout != "Model": - if layout not in doc.layouts.names(): + resolved = self._find_layout(layout) if layout else None + if layout and layout.strip().lower() != "model": + if resolved is None: return {"ok": False, "error": f"Layout not found: {layout}"} - target = doc.layouts.get(layout) + target = doc.layouts.get(resolved) else: target = self._msp() try: @@ -487,11 +1096,12 @@ class EzdxfBackend(AutoCADBackend): Frontend(ctx, out).draw_layout(target, finalize=True) fig.savefig(path, dpi=150) result = {"ok": True, "path": path} - if layout and layout != "Model": - result["layout"] = layout + if resolved is not None and resolved != "Model": + result["layout"] = resolved result["note"] = ( - "Paper-space entities and viewport frames are rendered; " - "projecting model content through viewports is COM-only." + "Paper-space entities are rendered, and model content is projected " + "through each viewport at its scale and clipped to its window. " + "Viewport borders themselves are not drawn." ) return result except ImportError: @@ -503,6 +1113,58 @@ class EzdxfBackend(AutoCADBackend): # ── layouts / paper space ──────────────────────────────────────────────── + @staticmethod + def _layout_name(raw: str) -> str: + """Names arrive from tool input, so blanks have to die at the door. + + ``doc.layouts.delete("")`` deletes the first paper-space layout and + ``doc.layouts.get("")`` returns the active one — neither refuses, so an + empty string is a live footgun rather than a no-op. + """ + return str(raw or "").strip() + + def _find_layout(self, raw: str) -> str | None: + """Resolve a layout name the way the engine does, or ``None``. + + ezdxf matches layout names case-insensitively in ``get``, ``new``, + ``rename``, ``delete`` and ``set_active_layout``, but ``names()`` + returns the *stored* spelling — so every guard written as + ``name in doc.layouts.names()`` disagrees with the code it guards, in + both directions: a duplicate walks past the check and dies on a raw + ``DXFValueError`` from inside ezdxf, and an existing layout typed in + another case is reported as missing. Returns the stored spelling so + callers compare and store one canonical form. + """ + name = self._layout_name(raw) + if not name: + return None + lowered = name.lower() + return next( + (n for n in self._require_doc().layouts.names() if n.lower() == lowered), + None, + ) + + def _free_layout_name(self, seed: str) -> str: + """A layout name nothing currently holds, derived from ``seed``.""" + for suffix in range(1, 1000): + candidate = f"{seed}~{suffix}" + if self._find_layout(candidate) is None: + return candidate + raise RuntimeError("cannot find a free temporary layout name") + + def _resync_space(self) -> None: + """Re-assert ``_current_space`` over ``$TILEMODE`` and the active layout. + + Called after a lifecycle change so the three ways a caller can ask + "where am I?" cannot drift apart. + """ + doc = self._require_doc() + if self._current_space == "Model": + doc.header["$TILEMODE"] = 1 + else: + doc.layouts.set_active_layout(self._current_space) + doc.header["$TILEMODE"] = 0 + async def layout_list(self) -> dict: def _sync(): doc = self._require_doc() @@ -520,26 +1182,223 @@ class EzdxfBackend(AutoCADBackend): async def layout_create(self, name: str) -> dict: def _sync(): doc = self._require_doc() - if name in doc.layouts.names(): - return {"ok": False, "error": f"Layout already exists: {name}"} - doc.layouts.new(name) - return {"ok": True, "layout": name} + wanted = self._layout_name(name) + if not wanted: + return {"ok": False, "error": "Layout name must not be blank"} + existing = self._find_layout(wanted) + if existing is not None: + return {"ok": False, "error": f"Layout already exists: {existing}"} + doc.layouts.new(wanted) + return {"ok": True, "layout": wanted} return await self._async(_sync) async def layout_set_current(self, name: str) -> dict: def _sync(): doc = self._require_doc() - if name not in doc.layouts.names(): + resolved = self._find_layout(name) + if resolved is None: return {"ok": False, "error": f"Layout not found: {name}"} - if name == "Model": + if resolved == "Model": doc.header["$TILEMODE"] = 1 else: # Rebind *Paper_Space so AutoCAD treats it as the active # paper-space layout; TILEMODE=0 selects the paper tab. - doc.layouts.set_active_layout(name) + doc.layouts.set_active_layout(resolved) doc.header["$TILEMODE"] = 0 - return {"ok": True, "current": name} + # The whole point: subsequent entity_create_* calls write here. + # Stored in the canonical spelling so _msp and _resync_space + # compare against one form. + self._current_space = resolved + return {"ok": True, "current": resolved} + + return await self._async(_sync) + + async def layout_delete(self, name: str) -> dict: + def _sync(): + doc = self._require_doc() + if not self._layout_name(name): + return {"ok": False, "error": "Layout name must not be blank"} + target = self._find_layout(name) + if target is None: + return {"ok": False, "error": f"Layout not found: {name}"} + if target == "Model": + return {"ok": False, "error": "Model space cannot be deleted"} + paper = [n for n in doc.layouts.names() if n != "Model"] + if len(paper) <= 1: + return { + "ok": False, + "error": ( + f"Cannot delete {target}: a drawing must keep at least one " + "paper-space layout" + ), + } + + destroyed = len(list(doc.layouts.get(target))) + doc.layouts.delete(target) + # Destroyed entities stay in entitydb, where a plain `is None` check + # walks straight past them and the next attribute access explodes. + doc.entitydb.purge() + if self._current_space == target: + self._current_space = "Model" + self._resync_space() + self._mark_dirty() + return { + "ok": True, + "deleted": target, + "entities_destroyed": destroyed, + "current": self._current_space, + } + + return await self._async(_sync) + + async def layout_rename(self, old_name: str, new_name: str) -> dict: + def _sync(): + from ezdxf.lldxf.validator import is_valid_table_name + + doc = self._require_doc() + new = self._layout_name(new_name) + if not self._layout_name(old_name) or not new: + return {"ok": False, "error": "Layout names must not be blank"} + old = self._find_layout(old_name) + if old is None: + return {"ok": False, "error": f"Layout not found: {old_name}"} + if "Model" in (old, new) or new.lower() == "model": + return {"ok": False, "error": "Model space cannot be renamed or shadowed"} + # Re-spelling a layout's own name is a rename onto itself, not a + # collision — only a *different* layout blocks the new name. + clash = self._find_layout(new) + if clash is not None and clash != old: + return {"ok": False, "error": f"Layout already exists: {clash}"} + # `rename()` skips the name check `new()` performs, so `Bad/Name` + # lands in the table and the file still audits clean. + if not is_valid_table_name(new): + return {"ok": False, "error": f"Invalid layout name: {new!r}"} + + if clash == old and new != old: + # Pure re-spelling. ezdxf's own `rename` tests `new_name in + # self` case-insensitively, which matches the layout against + # itself, so a direct call refuses something AutoCAD allows. + # Two renames through a name nothing else holds get there. + staging = self._free_layout_name(new) + doc.layouts.rename(old, staging) + doc.layouts.rename(staging, new) + else: + doc.layouts.rename(old, new) + if self._current_space == old: + self._current_space = new + self._resync_space() + self._mark_dirty() + return {"ok": True, "old": old, "new": new, "current": self._current_space} + + return await self._async(_sync) + + #: Attributes that identify a layout rather than describe it. Copying these + #: onto a clone would point it back at the source's block record. + _LAYOUT_IDENTITY_ATTRIBS = frozenset( + {"handle", "owner", "name", "taborder", "block_record_handle", "viewport_handle"} + ) + + @staticmethod + def _remap_boundaries(layout, handle_map: dict[str, str]) -> tuple[int, int]: + """Re-point cloned hatch boundaries at the cloned entities. + + A copied associative hatch keeps the *source* layout's handles in + ``source_boundary_objects``. Nothing complains — both the live and the + reloaded document audit clean — right up until the source layout is + deleted and the reference dangles. + """ + remapped = dropped = 0 + for entity in layout: + if entity.dxftype() != "HATCH": + continue + for path in entity.paths: + refs = list(getattr(path, "source_boundary_objects", []) or []) + if not refs: + continue + kept = [] + for handle in refs: + mapped = handle_map.get(handle) + if mapped is None: + dropped += 1 + else: + kept.append(mapped) + remapped += 1 + path.source_boundary_objects = kept + return remapped, dropped + + async def layout_copy(self, source: str, new_name: str) -> dict: + def _sync(): + from ezdxf.entities.copy import CopyNotSupported + from ezdxf.lldxf.validator import is_valid_table_name + + doc = self._require_doc() + dst_name = self._layout_name(new_name) + if not self._layout_name(source) or not dst_name: + return {"ok": False, "error": "Layout names must not be blank"} + src_name = self._find_layout(source) + if src_name is None: + return {"ok": False, "error": f"Layout not found: {source}"} + if src_name == "Model": + return { + "ok": False, + "error": "Model space cannot be copied to a sheet; use viewport_create", + } + if dst_name.lower() == "model": + return {"ok": False, "error": "Model space cannot be overwritten"} + clash = self._find_layout(dst_name) + if clash is not None: + return {"ok": False, "error": f"Layout already exists: {clash}"} + if not is_valid_table_name(dst_name): + return {"ok": False, "error": f"Invalid layout name: {dst_name!r}"} + + src = doc.layouts.get(src_name) + # A deleted layout leaves a gap in the tab order and `new()` reuses + # it, so two tabs end up sharing an order and surviving reload. + orders = [ + int(doc.layouts.get(n).dxf.get("taborder", 0) or 0) + for n in doc.layouts.names() + if n != "Model" + ] + dst = doc.layouts.new(dst_name, dxfattribs={"taborder": max(orders, default=0) + 1}) + # Set after `new()`, not through it: `new()` resets the paper limits + # *after* applying dxfattribs, so a page setup passed in looks + # copied and is not — the sheet then simply plots wrong. + for key, value in src.dxf.all_existing_dxf_attribs().items(): + if key not in self._LAYOUT_IDENTITY_ATTRIBS: + dst.dxf.set(key, value) + + handle_map: dict[str, str] = {} + skipped: list[str] = [] + for entity in list(src): + try: + clone = doc.entitydb.duplicate_entity(entity) + except (CopyNotSupported, NotImplementedError): + skipped.append(entity.dxftype()) + continue + dst.add_entity(clone) + handle_map[entity.dxf.handle] = clone.dxf.handle + + remapped, dropped = self._remap_boundaries(dst, handle_map) + + # The copy brought the source's main viewport with it; the new tab + # has to point at *that* one, not at the source's handle. + main = dst.main_viewport() + if main is not None: + dst.set_current_viewport_handle(main.dxf.handle) + else: + dst.dxf.discard("viewport_handle") + + self._mark_dirty() + return { + "ok": True, + "source": src_name, + "layout": dst_name, + "entities_copied": len(handle_map), + "skipped": skipped, + "associativity_remapped": remapped, + "associativity_dropped": dropped, + } return await self._async(_sync) @@ -578,33 +1437,457 @@ class EzdxfBackend(AutoCADBackend): return await self._async(_sync) + # ── viewports ──────────────────────────────────────────────────────────── + + #: DXF viewport flag bit for "display locked" (VSF_LOCK_ZOOM). + _VP_LOCK_ZOOM = 0x4000 + + #: R12 has no slot for view_height or flags, so both are dropped on export. + _R12_NOTE = "R12 stores neither viewport scale nor display lock; both are reported as null" + + def _paper_layouts(self) -> list[str]: + return [n for n in self._require_doc().layouts.names_in_taborder() if n != "Model"] + + def _resolve_viewport(self, handle: str): + """``(viewport, None)`` or ``(None, refusal)``. + + Liveness is checked before the type, because a deleted entity stays in + ``entitydb`` and still answers ``dxftype() == "VIEWPORT"`` — so a + type-check-first resolver hands back a corpse. + """ + doc = self._require_doc() + key = str(handle or "").strip().upper() + entity = doc.entitydb.get(key) + if entity is None or not entity.is_alive: + return None, {"ok": False, "error": f"Viewport handle not found: {handle}"} + if entity.dxftype() != "VIEWPORT": + return None, { + "ok": False, + "error": f"Handle {key} is a {entity.dxftype()}, not a VIEWPORT", + } + return entity, None + + @staticmethod + def _is_main_viewport(viewport) -> bool: + """Ask the layout, rather than testing ``id == 1``. + + ezdxf resolves the main viewport by ``status == 1`` first and falls back + to ``id == 1``, and the two can disagree. + """ + try: + layout = viewport.get_layout() + except Exception: + return False + main = layout.main_viewport() if layout is not None else None + return main is not None and main.dxf.handle == viewport.dxf.handle + + def _is_r12(self) -> bool: + return self._require_doc().dxfversion == "AC1009" + + def _viewport_row(self, viewport, layout_name: str, r12: bool) -> dict: + center = viewport.dxf.center + view_center = viewport.dxf.get("view_center_point", None) + view_height = viewport.dxf.get("view_height", None) + flags = viewport.dxf.get("flags", None) + return { + "handle": viewport.dxf.handle, + "layout": layout_name, + "center": [float(center.x), float(center.y)], + "width": float(viewport.dxf.width), + "height": float(viewport.dxf.height), + "view_center": ( + [float(view_center.x), float(view_center.y)] if view_center is not None else None + ), + "view_height": float(view_height) if view_height is not None else None, + # get_scale() divides by a view_height the file does not have and + # returns a number anyway; refuse to pass that on as a scale. + "scale": (None if (r12 or not view_height) else float(viewport.get_scale())), + # On R2000+ an absent flags field means "no flags set", i.e. + # unlocked. On R12 the field cannot exist at all, so the honest + # answer is "this file cannot say". + "locked": (None if r12 else bool(int(flags or 0) & self._VP_LOCK_ZOOM)), + "status": viewport.dxf.get("status", None), + "id": viewport.dxf.get("id", None), + "is_main": self._is_main_viewport(viewport), + } + + async def viewport_list(self, layout: str | None = None) -> dict: + def _sync(): + doc = self._require_doc() + if layout is None: + names = self._paper_layouts() + else: + if not self._layout_name(layout): + return {"ok": False, "error": "Layout name must not be blank"} + resolved = self._find_layout(layout) + if resolved is None: + return {"ok": False, "error": f"Layout not found: {layout}"} + if resolved == "Model": + return {"ok": False, "error": "Model space has no paper-space viewports"} + names = [resolved] + + r12 = self._is_r12() + rows = [ + self._viewport_row(vp, name, r12) + for name in names + for vp in doc.layouts.get(name).viewports() + ] + result = {"ok": True, "viewports": rows, "count": len(rows)} + if r12: + result["note"] = self._R12_NOTE + return result + + return await self._async(_sync) + + async def viewport_set_scale(self, handle: str, scale: float) -> dict: + def _sync(): + if float(scale) <= 0: + return {"ok": False, "error": "scale must be > 0 (paper:model, e.g. 0.5 for 1:2)"} + viewport, refusal = self._resolve_viewport(handle) + if refusal: + return refusal + if self._is_r12(): + return { + "ok": False, + "error": ( + "R12 drops view_height on export, so the scale would be lost on save. " + "Save as R2000 or newer first." + ), + } + if self._is_main_viewport(viewport): + return { + "ok": False, + "error": ( + "This is the layout's main viewport: its view height is the tab's own " + "pan/zoom state, not a drafting scale. Create a viewport with " + "viewport_create and scale that." + ), + } + height = float(viewport.dxf.height) + if height <= 0: + return {"ok": False, "error": "Viewport has zero height; its scale is undefined"} + + viewport.dxf.view_height = height / float(scale) + self._mark_dirty() + return { + "ok": True, + "handle": viewport.dxf.handle, + "scale": float(viewport.get_scale()), + "view_height": float(viewport.dxf.view_height), + "note": "geometric scale only; annotative text and dimensions do not resize", + } + + return await self._async(_sync) + + async def viewport_lock(self, handle: str, locked: bool = True) -> dict: + def _sync(): + viewport, refusal = self._resolve_viewport(handle) + if refusal: + return refusal + if self._is_r12(): + return { + "ok": False, + "error": ( + "R12 has no viewport flags field, so the lock would be lost on save. " + "Save as R2000 or newer first." + ), + } + # Read-modify-write: `flags` also carries the UCS-icon and grid + # bits, and a bare assignment drops them and still saves cleanly. + flags = int(viewport.dxf.get("flags", 0) or 0) + viewport.dxf.flags = ( + flags | self._VP_LOCK_ZOOM if locked else flags & ~self._VP_LOCK_ZOOM + ) + self._mark_dirty() + return { + "ok": True, + "handle": viewport.dxf.handle, + "locked": bool(viewport.dxf.flags & self._VP_LOCK_ZOOM), + } + + return await self._async(_sync) + + async def viewport_delete(self, handle: str, force: bool = False) -> dict: + def _sync(): + viewport, refusal = self._resolve_viewport(handle) + if refusal: + return refusal + was_main = self._is_main_viewport(viewport) + if was_main and not force: + return { + "ok": False, + "error": ( + "This is the layout's main viewport; deleting it leaves the tab without " + "its own view state. Pass force=true if that is what you want." + ), + } + + layout = viewport.get_layout() + layout_name = layout.name if layout is not None else None + layout.delete_entity(viewport) + + repaired = False + if was_main and layout is not None: + survivor = next(iter(layout.viewports()), None) + if survivor is not None: + layout.set_current_viewport_handle(survivor.dxf.handle) + else: + # A pointer at a destroyed entity audits clean here and + # breaks in AutoCAD; drop it instead. + layout.dxf.discard("viewport_handle") + repaired = True + + self._mark_dirty() + return { + "ok": True, + "handle": str(handle).strip().upper(), + "layout": layout_name, + "was_main": was_main, + "viewport_handle_repaired": repaired, + } + + return await self._async(_sync) + + # ── change space (CHSPACE) ─────────────────────────────────────────────── + + #: Types whose ``transform()`` is accepted and does nothing: the matrix is + #: parked in a pending transformation only an ACIS kernel could apply. + _CHSPACE_ACIS_TYPES = frozenset({"3DSOLID", "BODY", "REGION", "SURFACE", "EXTRUDEDSURFACE"}) + + #: Types whose ``transform()`` raises. ``hasattr(e, "transform")`` is True + #: for all of them, so a capability probe does not filter them out. + _CHSPACE_UNTRANSFORMABLE = frozenset( + {"ACAD_TABLE", "ACAD_PROXY_ENTITY", "OLE2FRAME", "OLEFRAME"} + ) + + async def entity_change_space( + self, + handles: list[str], + viewport_handle: str, + direction: str = "to_paper", + freeze_dimensions: bool = False, + ) -> dict: + def _sync(): + from ezdxf.math import Vec3 + + doc = self._require_doc() + if direction not in ("to_paper", "to_model"): + return { + "ok": False, + "error": f"direction must be to_paper or to_model: {direction!r}", + } + if not handles: + return {"ok": False, "error": "No entity handles given"} + + viewport, refusal = self._resolve_viewport(viewport_handle) + if refusal: + return refusal + # ezdxf hands back a top-view matrix for a non-top view without + # complaining, and rotates about the paper origin for a twisted + # one. Both produce plausible-looking, wrong geometry. + if not viewport.is_top_view: + return { + "ok": False, + "error": ( + "Viewport is not a plan (top) view; CHSPACE through it would place " + "geometry using a projection this engine cannot reproduce." + ), + } + if float(viewport.dxf.get("view_twist_angle", 0.0) or 0.0): + return { + "ok": False, + "error": ( + "Viewport has a view twist angle; this engine rotates about the paper " + "origin rather than the view centre, so the result would be wrong." + ), + } + + sheet = viewport.get_layout() + if sheet is None: + return {"ok": False, "error": "Viewport is not on a paper-space layout"} + model = doc.modelspace() + to_paper = direction == "to_paper" + source, target = (model, sheet) if to_paper else (sheet, model) + + to_paper_matrix = viewport.get_transformation_matrix() + if to_paper: + matrix = to_paper_matrix + else: + # Matrix44.inverse() mutates in place and returns None. + matrix = to_paper_matrix.copy() + matrix.inverse() + + scale = float(viewport.get_scale()) + vp_center = viewport.dxf.center + half_w, half_h = float(viewport.dxf.width) / 2, float(viewport.dxf.height) / 2 + paper_limits = sheet.get_paper_limits() + + moved: list[dict] = [] + refused: list[dict] = [] + for raw in handles: + key = str(raw or "").strip().upper() + entity = doc.entitydb.get(key) + if entity is None or not entity.is_alive: + refused.append({"handle": raw, "reason": f"Entity handle not found: {raw}"}) + continue + + dxftype = entity.dxftype() + if dxftype == "VIEWPORT": + refused.append( + {"handle": key, "reason": "VIEWPORT entities do not change space"} + ) + continue + if dxftype in self._CHSPACE_ACIS_TYPES: + refused.append( + { + "handle": key, + "reason": ( + f"{dxftype}: this engine accepts the transform and moves no " + "geometry (no ACIS kernel), so the move would be a lie" + ), + } + ) + continue + if dxftype in self._CHSPACE_UNTRANSFORMABLE: + refused.append( + {"handle": key, "reason": f"{dxftype} cannot be transformed by this engine"} + ) + continue + + # Membership is checked before anything is written, because + # `move_to_layout` raises from deep inside ezdxf *after* the + # transform has been applied — leaving the entity rescaled by a + # viewport it never moved through. + owner = entity.dxf.owner + if owner == target.block_record.dxf.handle: + refused.append( + {"handle": key, "reason": "Entity is already in the target space"} + ) + continue + if owner != source.block_record.dxf.handle: + refused.append( + { + "handle": key, + "reason": ( + f"Entity is in {self._space_name(owner)}, not in the space this " + f"move starts from ({'Model' if to_paper else sheet.name})" + ), + } + ) + continue + + frozen_text = None + if dxftype == "DIMENSION": + if not freeze_dimensions: + refused.append( + { + "handle": key, + "reason": ( + "DIMENSION: scaling changes the measurement while the baked " + "dimension text keeps the old value. Pass " + "freeze_dimensions=true to bake the current measurement in " + "first." + ), + } + ) + continue + frozen_text = self._format_measurement(entity.get_measurement()) + entity.dxf.text = frozen_text + + entity.transform(matrix) + source.move_to_layout(entity, target) + + # Where the entity now sits, expressed in paper coordinates. + # Clipping is reported, not enforced: AutoCAD lets you move + # geometry the viewport does not show, and so does this — but + # the caller has to be told the drawing left the sheet. + anchor = _label_anchor(entity) + inside = on_sheet = None + if anchor is not None: + point = Vec3(anchor) + if not to_paper: + point = to_paper_matrix.transform(point) + inside = ( + abs(point.x - vp_center.x) <= half_w + and abs(point.y - vp_center.y) <= half_h + ) + on_sheet = ( + paper_limits[0].x <= point.x <= paper_limits[1].x + and paper_limits[0].y <= point.y <= paper_limits[1].y + ) + row = { + "handle": key, + "type": dxftype, + "from": "Model" if to_paper else sheet.name, + "to": sheet.name if to_paper else "Model", + "inside_viewport": inside, + "on_sheet": on_sheet, + } + if frozen_text is not None: + row["frozen_text"] = frozen_text + moved.append(row) + + if moved: + self._mark_dirty() + return { + "ok": True, + "moved": moved, + "refused": refused, + "scale": scale, + "viewport": viewport.dxf.handle, + "layout": sheet.name, + } + + return await self._async(_sync) + + def _space_name(self, block_record_handle: str) -> str: + """Name the layout tab an owning block record belongs to.""" + doc = self._require_doc() + if block_record_handle == doc.modelspace().block_record.dxf.handle: + return "Model" + for name in doc.layouts.names(): + if name == "Model": + continue + if doc.layouts.get(name).block_record.dxf.handle == block_record_handle: + return name + return "a block definition" + + @staticmethod + def _format_measurement(value: float) -> str: + """Render a dimension measurement the way a dimension text reads.""" + text = f"{float(value):.4f}".rstrip("0").rstrip(".") + return text or "0" + # ── 3D solids (unsupported headlessly — honest capability boundary) ───── @staticmethod - def _solid_unsupported(operation: str) -> dict: - return { - "ok": False, - "error": ( - f"{operation}: 3D ACIS solids cannot be generated headlessly; " - "use the live COM backend with ENABLE_3D=true" - ), - "capability": "solid_3d", - } + def _solid_unsupported(operation: str) -> UnsupportedCapabilityError: + """Raise, don't return: a refusal returned as a value is invisible. + + The message is unchanged, so the payload a client sees is identical; what + changes is that ``is_error`` stops being quietly ``False`` on a call that + did nothing, and that the one refusal middleware can see this at all. + """ + return UnsupportedCapabilityError( + "solid_3d", + f"{operation}: 3D ACIS solids cannot be generated headlessly; " + "use the live COM backend with ENABLE_3D=true", + ) async def solid_box( self, cx: float, cy: float, cz: float, length: float, width: float, height: float ) -> dict: - return self._solid_unsupported("solid_box") + raise self._solid_unsupported("solid_box") async def solid_cylinder( self, cx: float, cy: float, cz: float, radius: float, height: float ) -> dict: - return self._solid_unsupported("solid_cylinder") + raise self._solid_unsupported("solid_cylinder") async def solid_extrude( self, profile_handle: str, height: float, taper_angle: float = 0.0 ) -> dict: - return self._solid_unsupported("solid_extrude") + raise self._solid_unsupported("solid_extrude") async def solid_revolve( self, @@ -615,10 +1898,10 @@ class EzdxfBackend(AutoCADBackend): axis_y2: float, angle: float = 360.0, ) -> dict: - return self._solid_unsupported("solid_revolve") + raise self._solid_unsupported("solid_revolve") async def solid_boolean(self, target_handle: str, tool_handle: str, operation: str) -> dict: - return self._solid_unsupported("solid_boolean") + raise self._solid_unsupported("solid_boolean") async def drawing_purge(self) -> dict: def _sync(): @@ -689,13 +1972,31 @@ class EzdxfBackend(AutoCADBackend): return await self._async(_sync) async def drawing_audit(self) -> dict: + """Audit and repair, then report both halves of what happened. + + ``Drawing.audit()`` is not a read: it fixes every fixable problem in + place, so each entry in ``auditor.fixes`` is a mutation that has already + been applied. Reporting only ``errors`` therefore said "0 problems" + about a document the call had just rewritten. Marking dirty is the + load-bearing half — ``drawing_close`` saves only ``if save and + self._dirty``, so without it the repairs are discarded on close and the + report becomes a promise the file never keeps. + """ + def _sync(): doc = self._require_doc() auditor = doc.audit() + fixes = [_audit_entry(e) for e in auditor.fixes] + errors = [_audit_entry(e) for e in auditor.errors] + if fixes: + self._mark_dirty() return { "ok": True, - "errors": [str(e) for e in auditor.errors], - "error_count": len(auditor.errors), + "repaired": bool(fixes), + "fixes": fixes, + "fix_count": len(fixes), + "errors": errors, + "error_count": len(errors), } return await self._async(_sync) @@ -714,26 +2015,52 @@ class EzdxfBackend(AutoCADBackend): return await self._async(_sync) async def drawing_undo(self) -> dict: + self._require_undo_history("drawing_undo") + # NEW-undo-1: read+mutate _undo_stack under self._lock (via _async) so # the empty-check and the pop are serialized with all other sync work. def _sync(): - if not self._undo_stack: - return {"ok": False, "error": "Nothing to undo (no undo history in ezdxf backend)"} - p = self._undo_stack.pop() - try: - self._doc = ezdxf.readfile(str(p)) - self._dirty = True - finally: - try: - p.unlink() - except OSError: - pass - return {"ok": True, "message": "Undone to last saved state"} + # Two entries minimum: the top is the current state, and undo means + # restoring the one below it. + if len(self._undo_stack) < 2: + return { + "ok": False, + "error": "Nothing to undo — this is the oldest state in the history.", + "undo_depth": max(0, len(self._undo_stack) - 1), + } + self._redo_stack.append(self._undo_stack.pop()) + self._restore_snapshot(self._undo_stack[-1]) + return { + "ok": True, + "message": "Stepped back one operation.", + "undo_depth": len(self._undo_stack) - 1, + "redo_depth": len(self._redo_stack), + } return await self._async(_sync) async def drawing_redo(self) -> dict: - return {"ok": False, "error": "Redo not supported in ezdxf backend"} + self._require_undo_history("drawing_redo") + + def _sync(): + if not self._redo_stack: + return { + "ok": False, + "error": "Nothing to redo — nothing has been undone, or a " + "later edit discarded the branch.", + "redo_depth": 0, + } + snapshot = self._redo_stack.pop() + self._undo_stack.append(snapshot) + self._restore_snapshot(snapshot) + return { + "ok": True, + "message": "Reapplied the operation you undid.", + "undo_depth": len(self._undo_stack) - 1, + "redo_depth": len(self._redo_stack), + } + + return await self._async(_sync) # ── internal: apply common attrs ────────────────────────────────────────── @@ -757,6 +2084,88 @@ class EzdxfBackend(AutoCADBackend): def _mark_dirty(self): self._dirty = True + self._push_undo_snapshot() + + # ── undo / redo ─────────────────────────────────────────────────────────── + # + # ezdxf keeps no journal, so a history step is a whole DXF snapshot. The + # stack holds *post*-mutation states — S0 from drawing_new, then one per + # mutation — because _mark_dirty is the single hook every mutating method + # already calls, and it runs after the change. Undo therefore restores the + # entry below the top rather than the top itself. + + def _snapshot_doc(self) -> Path | None: + """Write the current document to a temp DXF, or None if there is none.""" + if self._doc is None: + return None + fd, tmp_path = tempfile.mkstemp(suffix=".dxf", prefix="acad_mcp_undo_") + os.close(fd) + try: + self._doc.saveas(tmp_path) + except Exception: + try: + os.unlink(tmp_path) + except OSError: + pass + raise + return Path(tmp_path) + + @staticmethod + def _discard(paths: list[Path]) -> None: + while paths: + try: + paths.pop().unlink() + except OSError: + pass + + def _push_undo_snapshot(self) -> None: + depth = config.settings.ezdxf_undo_depth + if depth <= 0 or self._doc is None: + return + snapshot = self._snapshot_doc() + if snapshot is None: + return + self._undo_stack.append(snapshot) + # A mutation after an undo abandons the branch the user stepped off. + # Keeping it would let redo restore a state that never existed: geometry + # they had removed reappearing beside geometry they drew afterwards. + self._discard(self._redo_stack) + # depth+1 because the bottom entry is the state *before* the first + # undoable step, and restoring it is what the first undo does. + while len(self._undo_stack) > depth + 1: + try: + self._undo_stack.pop(0).unlink() + except OSError: + pass + + def _restore_snapshot(self, path: Path) -> None: + self._doc = ezdxf.readfile(str(path)) + self._current_layer = str(self._doc.header.get("$CLAYER", "0")) + self._dirty = True + + def _reset_history_baseline(self) -> None: + """Start the history at the document as opened or created. + + Without this the stack's oldest entry is the state *after* the first + edit, so that edit could never be undone — the history would silently be + one step shorter than the user asked for. + """ + self._cleanup_undo_stack() + self._push_undo_snapshot() + + def _require_undo_history(self, operation: str) -> None: + if config.settings.ezdxf_undo_depth > 0: + return + raise UnsupportedCapabilityError( + "undo_history", + f"{operation}: the headless backend keeps no history by default. ezdxf " + "has no journal, so every step is a full DXF snapshot — measured at " + "3 ms for 10 entities but 130 ms and 658 KB at 5000, paid on every " + "mutating call. Set EZDXF_UNDO_DEPTH to the number of steps you want " + "(e.g. 10) to switch it on, or use transaction_begin/rollback for a " + "single explicit checkpoint. The live COM backend uses AutoCAD's own " + "undo and needs no setting.", + ) # ── entity creation ─────────────────────────────────────────────────────── @@ -1156,6 +2565,290 @@ class EzdxfBackend(AutoCADBackend): return await self._async(_sync) + # ── hatch depth (M8 / F4) ──────────────────────────────────────────────── + + #: Island style names, in DXF ``hatch_style`` order. + _HATCH_STYLES = ("normal", "outer", "ignore") + + def _resolve_hatch(self, handle: str): + entity = self._get_entity(handle) + if entity.dxftype() != "HATCH": + return None, { + "ok": False, + "error": f"Handle {handle} is a {entity.dxftype()}, not a HATCH", + } + return entity, None + + async def hatch_set_gradient( + self, + handle, + color1, + color2, + rotation: float = 0.0, + centered: float = 0.0, + one_color: bool = False, + tint: float = 0.0, + name: str = "LINEAR", + ) -> dict: + def _sync(): + hatch, refusal = self._resolve_hatch(handle) + if refusal: + return refusal + try: + rgb1 = tuple(int(c) for c in color1) + rgb2 = tuple(int(c) for c in color2) + except (TypeError, ValueError): + return {"ok": False, "error": "color1 and color2 must be [r, g, b] triples"} + if len(rgb1) != 3 or len(rgb2) != 3: + return {"ok": False, "error": "color1 and color2 must be [r, g, b] triples"} + + hatch.set_gradient( + color1=rgb1, + color2=rgb2, + rotation=float(rotation), + centered=float(centered), + one_color=1 if one_color else 0, + tint=float(tint), + name=str(name), + ) + self._mark_dirty() + gradient = hatch.gradient + return { + "ok": True, + "handle": hatch.dxf.handle, + "gradient": { + "color1": list(gradient.color1), + "color2": list(gradient.color2), + "rotation": float(gradient.rotation), + "centered": float(gradient.centered), + "one_color": bool(gradient.one_color), + "tint": float(gradient.tint), + "name": gradient.name, + }, + } + + return await self._async(_sync) + + async def hatch_edit( + self, + handle, + pattern: str = "", + scale: float | None = None, + angle: float | None = None, + color: int | None = None, + style: str = "", + ) -> dict: + def _sync(): + hatch, refusal = self._resolve_hatch(handle) + if refusal: + return refusal + if scale is not None and float(scale) <= 0: + return {"ok": False, "error": "scale must be > 0"} + wanted_style = str(style).strip().lower() + if wanted_style and wanted_style not in self._HATCH_STYLES: + return { + "ok": False, + "error": ( + f"Unknown style {style!r}; valid styles are {', '.join(self._HATCH_STYLES)}" + ), + } + + # Every write is conditional on the parameter being given: a hatch + # edit that resets the attributes the caller did not mention is + # silent data loss. `changed` then reports what actually *moved*, + # not what was passed — re-setting a value to what it already was + # is a no-op and saying otherwise would make the field useless for + # deciding whether anything happened. + def _snapshot(): + return { + "pattern": hatch.dxf.pattern_name, + "scale": float(hatch.dxf.pattern_scale), + "angle": float(hatch.dxf.pattern_angle), + "color": int(hatch.dxf.color), + "style": int(hatch.dxf.hatch_style), + } + + before = _snapshot() + if pattern: + hatch.set_pattern_fill( + str(pattern), + scale=float(scale) if scale is not None else before["scale"], + angle=float(angle) if angle is not None else before["angle"], + color=int(color) if color is not None else before["color"], + ) + else: + if scale is not None: + hatch.set_pattern_scale(float(scale)) + if angle is not None: + hatch.set_pattern_angle(float(angle)) + if color is not None: + hatch.dxf.color = int(color) + if wanted_style: + hatch.dxf.hatch_style = self._HATCH_STYLES.index(wanted_style) + + after = _snapshot() + changed = [key for key in before if before[key] != after[key]] + if changed: + self._mark_dirty() + return {"ok": True, "handle": hatch.dxf.handle, "changed": changed} + + return await self._async(_sync) + + async def hatch_add_boundary(self, handle, edges) -> dict: + def _sync(): + hatch, refusal = self._resolve_hatch(handle) + if refusal: + return refusal + if not edges: + return {"ok": False, "error": "edges must not be empty"} + + def _need(edge, *keys): + missing = [k for k in keys if k not in edge] + return missing[0] if missing else None + + prepared: list[tuple[str, dict]] = [] + for index, edge in enumerate(edges): + if not isinstance(edge, dict): + return {"ok": False, "error": f"edge {index} is not an object"} + kind = str(edge.get("type", "")).strip().lower() + if kind == "line": + missing = _need(edge, "start", "end") + elif kind == "arc": + missing = _need(edge, "center", "radius", "start_angle", "end_angle") + elif kind == "ellipse": + missing = _need(edge, "center", "major_axis", "ratio") + else: + return { + "ok": False, + "error": ( + f"edge {index}: unknown type {edge.get('type')!r}; valid types are " + "line, arc, ellipse" + ), + } + if missing: + return {"ok": False, "error": f"edge {index} ({kind}) is missing {missing!r}"} + prepared.append((kind, edge)) + + # Validated in full before a single edge is written: a half-built + # boundary path is worse than a refusal. + path = hatch.paths.add_edge_path() + for kind, edge in prepared: + if kind == "line": + path.add_line(tuple(edge["start"][:2]), tuple(edge["end"][:2])) + elif kind == "arc": + path.add_arc( + center=tuple(edge["center"][:2]), + radius=float(edge["radius"]), + start_angle=float(edge["start_angle"]), + end_angle=float(edge["end_angle"]), + ccw=bool(edge.get("ccw", True)), + ) + else: + path.add_ellipse( + center=tuple(edge["center"][:2]), + major_axis=tuple(edge["major_axis"][:2]), + ratio=float(edge["ratio"]), + start_angle=float(edge.get("start_angle", 0.0)), + end_angle=float(edge.get("end_angle", 360.0)), + ccw=bool(edge.get("ccw", True)), + ) + + self._mark_dirty() + return { + "ok": True, + "handle": hatch.dxf.handle, + "path_count": len(hatch.paths), + "edge_types": [kind for kind, _ in prepared], + } + + return await self._async(_sync) + + # ── annotation objects (M8 / F15) ──────────────────────────────────────── + + @staticmethod + def _plane_points(points) -> list[tuple[float, float]] | None: + """Coerce tool input to 2D vertices, or ``None`` if it is not shaped like points.""" + try: + return [(float(p[0]), float(p[1])) for p in points] + except (TypeError, ValueError, IndexError): + return None + + async def entity_create_wipeout(self, points, layer=None) -> dict: + def _sync(): + vertices = self._plane_points(points) + if vertices is None: + return {"ok": False, "error": "points must be a list of [x, y] pairs"} + if len(vertices) < 3: + return { + "ok": False, + "error": ( + f"A wipeout needs at least 3 points to enclose an area; got {len(vertices)}" + ), + } + msp = self._msp() + entity = msp.add_wipeout(vertices) + if layer: + entity.dxf.layer = layer + self._mark_dirty() + return { + "ok": True, + "handle": entity.dxf.handle, + "points": [[x, y] for x, y in vertices], + "layer": entity.dxf.layer, + } + + return await self._async(_sync) + + async def entity_create_revcloud( + self, points, segment_length, layer=None, closed: bool = True + ) -> dict: + def _sync(): + from ezdxf.math import Vec2 + from ezdxf.revcloud import add_entity as add_revcloud + + vertices = self._plane_points(points) + if vertices is None: + return {"ok": False, "error": "points must be a list of [x, y] pairs"} + if len(vertices) < 2: + return {"ok": False, "error": "A revision cloud needs at least 2 points"} + length = float(segment_length) + if length <= 0: + return {"ok": False, "error": "segment_length must be > 0"} + + # The arcs are the cloud. ezdxf emits a straight segment when the + # requested length exceeds the edge, so an oversized segment_length + # produces a plain polyline that still reports success. + path = [Vec2(v) for v in vertices] + edges = list(zip(path, path[1:] + ([path[0]] if closed else []), strict=False)) + shortest = min((a - b).magnitude for a, b in edges) if edges else 0.0 + if length > shortest: + return { + "ok": False, + "error": ( + f"segment_length {length:g} exceeds the shortest edge ({shortest:.4g}); " + "the result would carry no arcs and would not be a revision cloud" + ), + } + + # ezdxf's own helper, not a hand-rolled polyline: it stamps the + # REVCLOUD_PROPS xdata and picks the bulge sign from the winding + # direction, which is what makes the result recognisable as a + # revision cloud rather than a wobbly polyline. + msp = self._msp() + entity = add_revcloud(msp, vertices, length) + if layer: + entity.dxf.layer = layer + self._mark_dirty() + return { + "ok": True, + "handle": entity.dxf.handle, + "segments": len(entity), + "segment_length": length, + "layer": entity.dxf.layer, + } + + return await self._async(_sync) + # ── dimensions ─────────────────────────────────────────────────────────── async def dimension_linear( @@ -1342,7 +3035,10 @@ class EzdxfBackend(AutoCADBackend): def _get_entity(self, handle: str): doc = self._require_doc() ent = doc.entitydb.get(handle) - if ent is None: + # `entitydb.get` is documented not to filter destroyed entities, so a + # handle from a deleted layout used to pass this guard and then die on + # the first attribute access with an AttributeError. + if ent is None or not ent.is_alive: raise RuntimeError(f"Entity with handle '{handle}' not found.") return ent @@ -1487,7 +3183,15 @@ class EzdxfBackend(AutoCADBackend): return _entity_info_dxf(new_ent) elif ent_type == "CIRCLE": - cx, cy = ent.dxf.center.x, ent.dxf.center.y + # WCS centre, because side_x/side_y are WCS — the caller got them + # from entity_get or point_from_snap. Comparing a WCS side point + # against an OCS centre put the new circle 60 mm away *and* got + # the inside/outside test backwards, so it grew when asked to + # shrink. The new circle is created with identity extrusion, so + # writing WCS into it is correct and the two end up concentric. + _extrusion = tuple(ent.dxf.get("extrusion", _IDENTITY_EXTRUSION)) + _c = ent.dxf.center + cx, cy = ocs.to_wcs_2d(_extrusion, _c.x, _c.y, _c.z) # side_x/side_y selects inside (shrink) or outside (grow); default = outside sign = 1.0 if side_x is not None and side_y is not None: @@ -1651,6 +3355,143 @@ class EzdxfBackend(AutoCADBackend): return await self._async(_sync) + # ── text annotation (M8 / F15) ─────────────────────────────────────────── + + #: Entity types whose text this server can both read and write. DIMENSION is + #: deliberately absent: its ``text`` field holds the ``<>`` override + #: placeholder rather than the measurement. + _TEXT_BEARING_TYPES = ("TEXT", "MTEXT", "ATTRIB", "ATTDEF") + + _DIMENSION_NOTE = ( + "DIMENSION text is not searched: its text field holds the '<>' override " + "placeholder rather than the measured value, so editing it would either do " + "nothing or detach the dimension from what it measures. Use " + "dimension_* text_override for that." + ) + + async def text_set_background( + self, handle, enabled: bool = True, color: int | None = None, scale: float = 1.5 + ) -> dict: + def _sync(): + entity = self._get_entity(handle) + if entity.dxftype() != "MTEXT": + return { + "ok": False, + "error": ( + f"Handle {handle} is {entity.dxftype()}; only MTEXT carries a " + "background fill. Setting one on TEXT would report success and " + "change nothing." + ), + } + if not enabled: + entity.dxf.discard("bg_fill") + entity.dxf.discard("bg_fill_color") + entity.dxf.discard("box_fill_scale") + self._mark_dirty() + return {"ok": True, "handle": entity.dxf.handle, "enabled": False} + + factor = float(scale) + if factor < 1.0: + return { + "ok": False, + "error": ( + f"scale must be >= 1.0 (got {factor:g}); a box smaller than its " + "text is a stripe through the text, not a mask" + ), + } + entity.set_bg_color(int(color) if color is not None else None, scale=factor) + self._mark_dirty() + return { + "ok": True, + "handle": entity.dxf.handle, + "enabled": True, + "color": color, + "scale": factor, + } + + return await self._async(_sync) + + async def text_find_replace( + self, + find: str, + replace: str, + layer: str | None = None, + match_case: bool = True, + dry_run: bool = False, + ) -> dict: + def _sync(): + import re + + needle = str(find) + if not needle: + return { + "ok": False, + "error": ( + "find must not be empty: an empty pattern matches between every " + "character and would shred the text" + ), + } + doc = self._require_doc() + pattern = re.compile(re.escape(needle), 0 if match_case else re.IGNORECASE) + + def _candidates(): + # Block *definitions* carry ATTDEF and any text drawn inside the + # block; a search that only walks layouts silently misses them. + yield from doc.modelspace() + for layout_name in doc.layouts.names(): + if layout_name != "Model": + yield from doc.layouts.get(layout_name) + for block in doc.blocks: + yield from block + for entity in doc.modelspace().query("INSERT"): + yield from entity.attribs + + changed: list[dict] = [] + seen: set[str] = set() + for entity in _candidates(): + dxftype = entity.dxftype() + if dxftype not in self._TEXT_BEARING_TYPES: + continue + key = entity.dxf.get("handle", None) + if key in seen: + continue + seen.add(key) + if layer and entity.dxf.get("layer", "0") != layer: + continue + current = entity.text if dxftype == "MTEXT" else entity.dxf.get("text", "") + if not current or not pattern.search(current): + continue + updated = pattern.sub(replace, current) + if not dry_run: + if dxftype == "MTEXT": + entity.text = updated + else: + entity.dxf.text = updated + changed.append( + { + "handle": key, + "type": dxftype, + "before": current, + "after": updated, + } + ) + + if changed and not dry_run: + self._mark_dirty() + return { + "ok": True, + "replaced": len(changed), + "entities": changed, + # Reported, not implied: "no matches" and "never looked there" + # are different answers, and the caller can only tell them apart + # if the scope is on the response. + "searched_types": list(self._TEXT_BEARING_TYPES), + "note": self._DIMENSION_NOTE, + "dry_run": bool(dry_run), + } + + return await self._async(_sync) + async def entity_edit_geometry( self, handle, @@ -1667,11 +3508,38 @@ class EzdxfBackend(AutoCADBackend): def _sync(): ent = self._get_entity(handle) et = ent.dxftype() + # cx/cy arrive in WCS, because that is what entity_get reports. For a + # mirrored entity the stored attribute is not WCS, so the value has to + # be mapped back into the entity's own frame before it is written. + # Read and write used to be *equally* wrong, which made a round trip an + # accidental no-op; normalising only the read would have turned that + # no-op into a real 60 mm move. + new_cx, new_cy = cx, cy + if et in ("CIRCLE", "ARC"): + extrusion = tuple(ent.dxf.get("extrusion", _IDENTITY_EXTRUSION)) + if not ocs.is_wcs_frame(extrusion): + if not ocs.is_flat_frame(extrusion): + raise UnsupportedCapabilityError( + "ocs_tilted_plane", + f"entity_edit_geometry: entity {handle} lies in a plane " + f"tilted out of WCS XY (normal=" + f"{tuple(round(float(v), 4) for v in extrusion)}). A 2D " + "(cx, cy) does not determine a point in that plane, so " + "writing one would displace the entity out of its own " + "plane by an amount this server cannot infer. Use " + "entity_move, which transforms the whole entity and " + "preserves its plane, or edit it in AutoCAD.", + ) + centre = ent.dxf.center + current = ocs.to_wcs_2d(extrusion, centre.x, centre.y, centre.z) + target_x = float(cx) if cx is not None else current[0] + target_y = float(cy) if cy is not None else current[1] + new_cx, new_cy = ocs.from_wcs(extrusion, target_x, target_y, centre.z)[:2] if et == "CIRCLE": c = ent.dxf.center ent.dxf.center = ( - float(cx) if cx is not None else c[0], - float(cy) if cy is not None else c[1], + float(new_cx) if new_cx is not None else c[0], + float(new_cy) if new_cy is not None else c[1], c[2] if len(c) > 2 else 0.0, ) if radius is not None: @@ -1691,8 +3559,8 @@ class EzdxfBackend(AutoCADBackend): elif et == "ARC": c = ent.dxf.center ent.dxf.center = ( - float(cx) if cx is not None else c[0], - float(cy) if cy is not None else c[1], + float(new_cx) if new_cx is not None else c[0], + float(new_cy) if new_cy is not None else c[1], c[2] if len(c) > 2 else 0.0, ) if radius is not None: @@ -1710,6 +3578,155 @@ class EzdxfBackend(AutoCADBackend): return await self._async(_sync) + # ── selection filters (M8 / F1) ────────────────────────────────────────── + + _SELECTION_MODES = ("window", "crossing") + + def _select_shape(self, shape, mode: str, entity_type: str, layer: str) -> dict: + """Run a selection shape over the current space and shape the response.""" + from ezdxf import select + + picker = select.bbox_inside if mode == "window" else select.bbox_overlap + wanted_type = str(entity_type or "").strip().upper() + wanted_layer = str(layer or "").strip() + + handles = [] + for entity in picker(shape, self._msp()): + if wanted_type and entity.dxftype() != wanted_type: + continue + if wanted_layer and entity.dxf.get("layer", "0") != wanted_layer: + continue + handles.append(entity.dxf.handle) + return {"ok": True, "handles": handles, "count": len(handles), "mode": mode} + + def _selection_mode(self, mode: str) -> tuple[str | None, dict | None]: + normalised = str(mode or "").strip().lower() + if normalised not in self._SELECTION_MODES: + return None, { + "ok": False, + "error": ( + f"Unknown mode {mode!r}; valid modes are {', '.join(self._SELECTION_MODES)}" + ), + } + return normalised, None + + async def selection_window( + self, x1, y1, x2, y2, mode: str = "window", entity_type: str = "", layer: str = "" + ) -> dict: + def _sync(): + from ezdxf import select + + resolved, refusal = self._selection_mode(mode) + if refusal: + return refusal + lo_x, hi_x = sorted((float(x1), float(x2))) + lo_y, hi_y = sorted((float(y1), float(y2))) + if lo_x == hi_x or lo_y == hi_y: + # An empty answer here would read as "nothing is there" rather + # than "you asked for a box that cannot contain anything". + return { + "ok": False, + "error": ( + "The selection box has zero area; give two opposite corners that " + "differ in both x and y" + ), + } + return self._select_shape( + select.Window((lo_x, lo_y), (hi_x, hi_y)), resolved, entity_type, layer + ) + + return await self._async(_sync) + + async def selection_polygon( + self, points, mode: str = "window", entity_type: str = "", layer: str = "" + ) -> dict: + def _sync(): + from ezdxf import select + + resolved, refusal = self._selection_mode(mode) + if refusal: + return refusal + vertices = self._plane_points(points) + if vertices is None: + return {"ok": False, "error": "points must be a list of [x, y] pairs"} + if len(vertices) < 3: + return { + "ok": False, + "error": f"A selection polygon needs at least 3 points; got {len(vertices)}", + } + return self._select_shape(select.Polygon(vertices), resolved, entity_type, layer) + + return await self._async(_sync) + + async def selection_filter( + self, + entity_type: str = "", + layer: str = "", + color: int | None = None, + linetype: str = "", + min_area: float | None = None, + ) -> dict: + def _sync(): + from engineering.measure import polygon_area_perimeter + + if min_area is not None and float(min_area) < 0: + return {"ok": False, "error": "min_area cannot be negative; no area is negative"} + + wanted_type = str(entity_type or "").strip().upper() + wanted_layer = str(layer or "").strip() + wanted_linetype = str(linetype or "").strip() + filtered_by = [ + name + for name, active in ( + ("entity_type", bool(wanted_type)), + ("layer", bool(wanted_layer)), + ("color", color is not None), + ("linetype", bool(wanted_linetype)), + ("min_area", min_area is not None), + ) + if active + ] + + def _area(entity) -> float | None: + """None means "this shape has no area", not "its area is zero".""" + dxftype = entity.dxftype() + if dxftype == "CIRCLE": + return math.pi * float(entity.dxf.radius) ** 2 + if dxftype == "LWPOLYLINE" and entity.closed: + points = [[p[0], p[1], p[4]] for p in entity.get_points()] + return polygon_area_perimeter(points, closed=True)[0] + if dxftype in ("SOLID", "TRACE", "HATCH"): + try: + return float(entity.dxf.get("area", 0.0) or 0.0) + except Exception: + return None + return None + + handles = [] + for entity in self._msp(): + if wanted_type and entity.dxftype() != wanted_type: + continue + if wanted_layer and entity.dxf.get("layer", "0") != wanted_layer: + continue + if color is not None and int(entity.dxf.get("color", 256)) != int(color): + continue + if wanted_linetype and entity.dxf.get("linetype", "ByLayer") != wanted_linetype: + continue + if min_area is not None: + area = _area(entity) + if area is None or area < float(min_area): + continue + handles.append(entity.dxf.handle) + + return { + "ok": True, + "handles": handles, + "count": len(handles), + "filtered_by": filtered_by, + } + + return await self._async(_sync) + async def entity_list( self, type_filter=None, @@ -1738,6 +3755,26 @@ class EzdxfBackend(AutoCADBackend): return await self._async(_sync) + async def entity_count(self, type_filter=None, layer_filter=None) -> int: + # Deliberately a mirror of entity_list's filter arms rather than a call + # into it: the whole value of the count is that it never constructs an + # EntityInfo (bounding-box extents alone are the expensive part). The + # comparisons are kept character-for-character identical to the ones + # above so `total` can never be measured against a different set than + # the page it describes. + def _sync(): + msp = self._msp() + count = 0 + for ent in msp: + if type_filter and type_filter.upper() != ent.dxftype().upper(): + continue + if layer_filter and layer_filter.lower() != ent.dxf.get("layer", "0").lower(): + continue + count += 1 + return count + + return await self._async(_sync) + # ── layer management ────────────────────────────────────────────────────── async def layer_list(self) -> list[LayerInfo]: @@ -2038,19 +4075,36 @@ class EzdxfBackend(AutoCADBackend): ) -> dict: def _sync(): doc = self._require_doc() - blk = doc.blocks.new(name=name) - blk.block.dxf.base_point = (float(base_x), float(base_y), 0.0) - count = 0 + # Resolve first. Creating the definition and then discovering every + # handle was a typo left an empty block behind, and reporting + # entity_count without naming what was dropped told the caller three + # of their five entities made it and nothing about the other two. + entities, skipped = [], [] for handle in handles: try: - ent = self._get_entity(handle) - copy = ent.copy() - blk.add_entity(copy) - count += 1 + entities.append(self._get_entity(handle)) except Exception as exc: - log.debug("adding entity %s to block %s: %s", handle, name, exc) + log.debug("resolving %s for block %s: %s", handle, name, exc) + skipped.append(str(handle)) + if not entities: + raise RuntimeError( + f"block_create_from_entities: none of the handles resolved " + f"({', '.join(skipped) or 'no handles given'}), so there is nothing " + f"to put in block {name!r}. No definition was created." + ) + + blk = doc.blocks.new(name=name) + blk.block.dxf.base_point = (float(base_x), float(base_y), 0.0) + for ent in entities: + blk.add_entity(ent.copy()) self._mark_dirty() - return {"ok": True, "name": name, "entity_count": count} + return { + "ok": True, + "name": name, + "entity_count": len(entities), + "skipped": skipped, + "backend": "ezdxf", + } return await self._async(_sync) @@ -2127,6 +4181,401 @@ class EzdxfBackend(AutoCADBackend): async def analysis_measure_area(self, points) -> float: return shoelace_area(points) + async def entity_measure(self, handle, flatten_tolerance: float = 0.001) -> dict: + def _sync(): + return _measure_dxf_entity(self._get_entity(handle), float(flatten_tolerance)) + + return await self._async(_sync) + + # ── boundary tracing (M8 / F3) ─────────────────────────────────────────── + + #: How many seed-adjacent edges to try as loop starts. Bounded because the + #: work is per candidate; unbounded it would degrade on a large drawing + #: without finding a better answer than the nearest edges already give. + _BOUNDARY_SEED_CANDIDATES = 64 + + #: Above this many edges the O(n^2) intersection split is skipped. Reported + #: on the response rather than silently degrading the answer. + _BOUNDARY_SPLIT_LIMIT = 400 + + @classmethod + def _split_edges_at_intersections(cls, edges, gap_tol: float): + """Split straight edges where they cross, so crossings become junctions. + + ``edgeminer`` connects edges by shared *endpoints*. A construction line + drawn across a square touches it at two points that are not vertices of + anything, so in the edge graph the line is not connected to the square + at all and the halves it makes are invisible. AutoCAD's BOUNDARY + computes the intersections; this does the same for straight edges. + + Curved edges are left whole: splitting an arc mid-span needs the arc's + own parameters, and a wrong split would move geometry rather than just + miss a loop. ``split`` on the response says whether this ran. + """ + from ezdxf.math import Vec2, intersection_line_line_2d + + straight = [] + curved = [] + for edge in edges: + payload = getattr(edge, "payload", None) + dxftype = payload.dxftype() if payload is not None else "" + (straight if dxftype in ("LINE", "") else curved).append(edge) + + cuts: dict[int, list] = {index: [] for index in range(len(straight))} + for i, a in enumerate(straight): + for j in range(i + 1, len(straight)): + b = straight[j] + point = intersection_line_line_2d( + (Vec2(a.start), Vec2(a.end)), (Vec2(b.start), Vec2(b.end)), virtual=False + ) + if point is None: + continue + for index, edge in ((i, a), (j, b)): + if (point - Vec2(edge.start)).magnitude > gap_tol and ( + point - Vec2(edge.end) + ).magnitude > gap_tol: + cuts[index].append(point) + + from ezdxf import edgeminer as em + + result = list(curved) + for index, edge in enumerate(straight): + points = cuts[index] + if not points: + result.append(edge) + continue + start, end = Vec2(edge.start), Vec2(edge.end) + ordered = sorted(set(points), key=lambda p: (p - start).magnitude) + previous = start + for point in [*ordered, end]: + if (point - previous).magnitude > gap_tol: + result.append(em.make_edge(previous, point, payload=edge.payload)) + previous = point + return result + + @staticmethod + def _edge_bulge(edge) -> float: + """The bulge that reproduces a curved edge as a polyline segment. + + Without this the traced boundary of two semicircles is a straight line + between two points — the shape is gone and only the area report would + have noticed. + """ + from ezdxf import edgesmith + from ezdxf.math import Vec2 + + payload = getattr(edge, "payload", None) + if payload is None or payload.dxftype() not in ("ARC", "CIRCLE"): + return 0.0 + try: + span = edgesmith.arc_angle_span_deg( + float(payload.dxf.start_angle), float(payload.dxf.end_angle) + ) + bulge = edgesmith.bulge_from_arc_angle(math.radians(span)) + except Exception: + return 0.0 + # The chain may walk the arc backwards; the bulge follows the walk. + center = Vec2(payload.dxf.center) + start_is_arc_start = ( + Vec2(edge.start) + - ( + center + + Vec2.from_deg_angle(float(payload.dxf.start_angle), float(payload.dxf.radius)) + ) + ).magnitude <= 1e-6 + return bulge if start_is_arc_start else -bulge + + @classmethod + def _chain_to_polyline(cls, space, chain, tolerance: float): + """Turn a chain of edges into a genuinely closed LWPOLYLINE. + + ``edgesmith.lwpolyline_from_chain`` emits n+1 points with the last + duplicating the first and ``closed`` left False — a polyline that looks + shut on screen and behaves open to everything that asks: hatch + association, area queries, ``entity_offset``. The duplicate is dropped + and the flag set here instead, and each curved edge keeps its bulge. + """ + from ezdxf.math import Vec2 + + points = [] + for edge in chain: + start = Vec2(edge.start) + points.append((start.x, start.y, 0.0, 0.0, cls._edge_bulge(edge))) + last = Vec2(chain[-1].end) + if (last - Vec2(chain[0].start)).magnitude > max(tolerance, 1e-9): + points.append((last.x, last.y, 0.0, 0.0, 0.0)) + + polyline = space.add_lwpolyline(points, format="xyseb", close=True) + polyline.closed = True + return polyline, points + + @staticmethod + def _loop_area(chain) -> float: + """Unsigned area enclosed by a chain of edges, arcs included. + + Not ``edgesmith.loop_area``: it works off the edge *endpoints*, so two + semicircular arcs closing into a circle measure 0.0 against a true + 314.159 — the curvature is exactly the part it drops. Flattening the + chain through its own path keeps the curvature at a small, bounded cost + (314.12 against 314.159 at a 0.01 sag). + + This is the **ranking** area: `boundary_trace` uses it to pick the + smallest loop enclosing the seed, where a 0.01% error cannot change the + winner. What the caller is told comes from `_polyline_area` instead — + see there for why the two must not be the same number. + """ + from ezdxf import edgesmith + + from engineering.measure import polygon_area_perimeter + + points = [[v.x, v.y, 0.0] for v in edgesmith.path2d_from_chain(chain).flattening(0.01)] + if len(points) < 3: + return 0.0 + return abs(polygon_area_perimeter(points, closed=True)[0]) + + @staticmethod + def _polyline_area(vertices) -> float: + """Unsigned area of the polyline these tools actually stored. + + The reported area used to come from `_loop_area`, a copy of the shape + flattened at a fixed 0.01 sag, while `entity_measure` read the stored + bulges analytically. Same figure, two tools, 139.2177 against 139.2699, + and neither payload said which one was the approximation. The vertices + handed to `add_lwpolyline` carry exact bulges, so the exact answer was + always there for the taking. + """ + from engineering.measure import polygon_area_perimeter + + # Two vertices is not a degenerate case here: a loop of two arcs is a + # circle, and it stores as exactly that. + points = [(x, y, bulge) for x, y, _s, _e, bulge in vertices] + if len(points) < 2: + return 0.0 + # Six decimals, the same as `entity_measure` and + # `analysis_measure_distance`. Agreeing to full float precision and + # then printing different tails is still two answers. + return round(abs(polygon_area_perimeter(points, closed=True)[0]), 6) + + async def boundary_trace(self, x, y, layer=None, tolerance: float = 1e-9) -> dict: + def _sync(): + from ezdxf import edgeminer as em + from ezdxf import edgesmith + from ezdxf.math import Vec2 + + space = self._msp() + candidates = [ + e + for e in space + if (not layer or e.dxf.get("layer", "0") == layer) and e.dxftype() != "POINT" + ] + edges = list(edgesmith.edges_from_entities_2d(candidates)) + if not edges: + return {"ok": False, "error": "No linear geometry to trace a boundary from"} + + gap_tol = max(float(tolerance), 1e-9) + split = len(edges) <= self._BOUNDARY_SPLIT_LIMIT + if split: + edges = self._split_edges_at_intersections(edges, gap_tol) + + seed = Vec2((float(x), float(y))) + deposit = em.Deposit(edges, gap_tol=gap_tol) + # Walk loops outward from the edges nearest the seed. Enumerating + # every loop first would be O(n!); one loop per candidate edge is + # linear in the edges actually tried. + # Every candidate is scored and the SMALLEST enclosing loop wins: + # with nested regions the outer one also contains the seed, and + # stopping at the first hit would hand back the wrong boundary. + # Bounded to the nearest edges so a large drawing stays linear. + best = None + nearest = sorted(edges, key=lambda e: (Vec2(e.start) - seed).magnitude) + for edge in nearest[: self._BOUNDARY_SEED_CANDIDATES]: + for clockwise in (True, False): + try: + chain = em.find_loop_by_edge(deposit, edge, clockwise=clockwise) + except Exception: + continue + if not chain: + continue + ring = [Vec2(link.start) for link in chain] + if len(ring) < 3 or edgesmith.is_point_in_polygon_2d(seed, ring) < 0: + continue + area = self._loop_area(chain) + if area > 0 and (best is None or area < best[1]): + best = (chain, area) + + if best is None: + # "These do not form a loop" is not actionable on its own. If + # an endpoint is dangling, the refusal names where, because + # that is the coordinate the caller has to go and fix. + gap_start, gap_end = self._dangling_endpoint(edges, max(float(tolerance), 1e-9)) + if (gap_start - gap_end).magnitude > max(float(tolerance), 1e-9): + return { + "ok": False, + "error": ( + f"No closed loop encloses ({float(x):g}, {float(y):g}): there is a " + f"gap at ({gap_start.x:g}, {gap_start.y:g}) -> " + f"({gap_end.x:g}, {gap_end.y:g})" + ), + } + return { + "ok": False, + "error": ( + f"No closed loop encloses ({float(x):g}, {float(y):g}); the seed point " + "is outside every closed region" + ), + } + + chain, _ = best + polyline, vertices = self._chain_to_polyline(space, chain, float(tolerance)) + if layer: + polyline.dxf.layer = layer + sources = sorted( + { + handle + for handle in ( + getattr(getattr(link.payload, "dxf", None), "handle", None) + for link in chain + ) + if handle + } + ) + self._mark_dirty() + return { + "ok": True, + "handle": polyline.dxf.handle, + "vertices": len(vertices), + "area": self._polyline_area(vertices), + "closed": True, + "source_handles": sources, + } + + return await self._async(_sync) + + async def boundary_from_entities(self, handles, tolerance: float = 1e-9) -> dict: + def _sync(): + from ezdxf import edgeminer as em + from ezdxf import edgesmith + + if not handles or len(handles) < 2: + return {"ok": False, "error": "At least 2 entity handles are needed to form a loop"} + + doc = self._require_doc() + entities = [] + for raw in handles: + key = str(raw or "").strip().upper() + entity = doc.entitydb.get(key) + if entity is None or not entity.is_alive: + return {"ok": False, "error": f"Entity handle not found: {raw}"} + if not edgesmith.is_pure_2d_entity(entity): + return { + "ok": False, + "error": ( + f"Handle {key} is a {entity.dxftype()}; a boundary can only be " + "chained from 2D linear entities" + ), + } + entities.append(entity) + + edges = list(edgesmith.edges_from_entities_2d(entities)) + gap_tol = max(float(tolerance), 1e-9) + # The handles arrive in drawing order, not chain order — putting + # them in order is the tool's job, so the loop is searched for + # rather than assumed. + try: + chain = em.find_loop(em.Deposit(edges, gap_tol=gap_tol), timeout=5.0) + except Exception: + chain = [] + if not chain or len(chain) != len(edges): + start, end = self._dangling_endpoint(edges, gap_tol) + return { + "ok": False, + "error": ( + f"These entities do not close: there is a gap at ({start.x:g}, " + f"{start.y:g}) -> ({end.x:g}, {end.y:g})" + ), + } + + polyline, vertices = self._chain_to_polyline(self._msp(), chain, gap_tol) + self._mark_dirty() + return { + "ok": True, + "handle": polyline.dxf.handle, + "vertices": len(vertices), + "area": self._polyline_area(vertices), + "closed": True, + } + + return await self._async(_sync) + + @staticmethod + def _dangling_endpoint(edges, gap_tol: float): + """The first endpoint with no partner, so the refusal can name the gap.""" + from ezdxf.math import Vec2 + + endpoints = [] + for edge in edges: + endpoints.extend([Vec2(edge.start), Vec2(edge.end)]) + for point in endpoints: + matches = sum(1 for other in endpoints if (point - other).magnitude <= gap_tol) + if matches < 2: + nearest = min( + (p for p in endpoints if (p - point).magnitude > gap_tol), + key=lambda p: (p - point).magnitude, + default=point, + ) + return point, nearest + return endpoints[0], endpoints[-1] + + async def analysis_list_properties(self, handle: str) -> dict: + def _sync(): + entity = self._get_entity(handle) + dxftype = entity.dxftype() + extrusion = entity.dxf.get("extrusion", (0.0, 0.0, 1.0)) + needs_ocs = dxftype in _OCS_ENTITY_TYPES and not ocs.is_wcs_frame(extrusion) + + def _value(key, raw): + # Vec3 and friends are not JSON-serialisable, and a raw OCS + # coordinate would contradict every other coordinate this + # server reports. `extrusion` itself stays untranslated — it + # is the frame, not a point in it. + if hasattr(raw, "x") and hasattr(raw, "y"): + if key == "extrusion": + return [float(raw.x), float(raw.y), float(getattr(raw, "z", 0.0))] + if needs_ocs: + return ocs.to_wcs_2d(extrusion, raw.x, raw.y, getattr(raw, "z", 0.0)) + return [float(raw.x), float(raw.y), float(getattr(raw, "z", 0.0))] + if isinstance(raw, (list, tuple)): + return [_value(key, item) for item in raw] + if isinstance(raw, (int, float, str, bool)) or raw is None: + return raw + return str(raw) + + dump = {key: _value(key, raw) for key, raw in entity.dxfattribs().items()} + if dxftype == "LWPOLYLINE": + # Every vertex reports its bulge, including the straight ones: + # a points list that drops zero bulges cannot be read back + # positionally. + dump["points"] = [ + [*ocs.to_wcs_2d(extrusion, p[0], p[1]), p[4]] + if needs_ocs + else [float(p[0]), float(p[1]), float(p[4])] + for p in entity.get_points() + ] + if dxftype == "MTEXT": + dump.setdefault("text", entity.text) + + info = _entity_info_dxf(entity) + return { + "ok": True, + "handle": info.handle, + "type": dxftype, + "layer": info.layer, + "properties": info.properties, + "dxf_attributes": dump, + } + + return await self._async(_sync) + async def analysis_bounding_box(self) -> dict: def _sync(): msp = self._msp() @@ -2199,13 +4648,35 @@ class EzdxfBackend(AutoCADBackend): "message": "Zoom window not applicable for ezdxf backend (no display/viewport)", } - async def view_screenshot(self) -> bytes | None: + async def view_screenshot(self, overlay_handles: bool = False) -> bytes | None: """Render drawing to PNG using matplotlib.""" + result = await self.view_screenshot_grounded( + overlay_handles=overlay_handles, max_labels=DEFAULT_MAX_HANDLE_LABELS + ) + return result["png"] + + async def view_screenshot_grounded( + self, + overlay_handles: bool = True, + max_labels: int = DEFAULT_MAX_HANDLE_LABELS, + ) -> dict: + """Render to PNG, optionally labelling each entity with its handle. + + Handle grounding: a screenshot shows what the drawing looks like and + nothing the caller can act on, because every modify tool takes a handle. + Drawing the handle at the entity's own bounding-box centre makes "the + circle at the top-left" and "handle 2F" the same statement, and returns + the label positions so the mapping is machine-readable too. + + Labels are ink on the drawing, so a crowded sheet is capped and the + image says so — labelling a subset silently would let the picture read + as the whole drawing. + """ def _sync(): try: doc = self._require_doc() - msp = self._msp() + space = self._msp() from ezdxf.addons.drawing import Frontend, RenderContext from ezdxf.addons.drawing.matplotlib import MatplotlibBackend @@ -2213,15 +4684,63 @@ class EzdxfBackend(AutoCADBackend): ax = fig.add_axes([0, 0, 1, 1]) ctx = RenderContext(doc) out = MatplotlibBackend(ax) - Frontend(ctx, out).draw_layout(msp, finalize=True) + Frontend(ctx, out).draw_layout(space, finalize=True) + + labels: dict[str, list[float]] = {} + entities = list(space) + total = len(entities) + truncated = False + if overlay_handles and total: + limit = max(0, int(max_labels)) + truncated = total > limit + for ent in entities[:limit]: + anchor = _label_anchor(ent) + if anchor is None: + continue + handle = str(ent.dxf.get("handle", "?")) + labels[handle] = [anchor[0], anchor[1]] + ax.annotate( + handle, + anchor, + color="#d62728", + fontsize=7, + ha="center", + va="center", + bbox={ + "boxstyle": "round,pad=0.15", + "facecolor": "white", + "edgecolor": "#d62728", + "linewidth": 0.4, + "alpha": 0.85, + }, + annotation_clip=False, + ) + if truncated: + ax.set_title( + f"handles shown for {len(labels)} of {total} entities", + color="#d62728", + fontsize=9, + ) buf = io.BytesIO() fig.savefig(buf, format="png", bbox_inches="tight", dpi=100) buf.seek(0) - return buf.read() + return { + "png": buf.read(), + "labels": labels, + "labelled": len(labels), + "total": total, + "truncated": truncated, + } except ImportError: log.warning("matplotlib not installed – screenshot unavailable") - return None + return { + "png": None, + "labels": {}, + "labelled": 0, + "total": 0, + "truncated": False, + } return await self._async(_sync) diff --git a/backends/ocs.py b/backends/ocs.py new file mode 100644 index 0000000..253741b --- /dev/null +++ b/backends/ocs.py @@ -0,0 +1,100 @@ +"""OCS (Object Coordinate System) to WCS translation, shared by both backends. + +DXF does not store 2D geometry in world coordinates. CIRCLE, ARC, LWPOLYLINE, +TEXT, INSERT and a dimension's text position live in a per-entity frame derived +from the entity's ``extrusion`` vector by the Arbitrary Axis Algorithm. Mirroring +flips extrusion to ``(0, 0, -1)`` and leaves the stored x *unnegated*, so a raw +``dxf.center`` read reports a point the entity is not at — by 60 mm in the +measured case, while the same response's bounding box (which ezdxf computes +through the renderer, in WCS) said otherwise. + +The rule this module exists to enforce: **everything crossing the MCP boundary is +WCS.** OCS never leaves the backends. + +Both engines share this. ``ezdxf`` is a core dependency, not an extra, so the COM +backend may import it too — and should, rather than hand-rolling the Arbitrary +Axis Algorithm a second time. The functions take a raw extrusion/normal triple +rather than an entity, because a COM object exposes ``.Normal`` where an ezdxf +one exposes ``.dxf.extrusion``. + +Not every entity that *carries* extrusion is affected: ezdxf makes ``ocs()`` a +pass-through for LINE, MTEXT, ELLIPSE, SPLINE and POINT, which are WCS-native. +Keying any fix off "has an extrusion attribute" breaks those. +""" + +from __future__ import annotations + +import math + +from ezdxf.math import OCS, Vec3 + +_IDENTITY = (0.0, 0.0, 1.0) + + +def is_wcs_frame(extrusion) -> bool: + """True when the entity's frame is the identity — nothing to translate.""" + try: + return OCS(Vec3(extrusion)).uz.isclose(_IDENTITY) + except Exception: + return True # an unreadable extrusion is not evidence of a rotated frame + + +def is_flat_frame(extrusion) -> bool: + """True when the entity's plane is parallel to WCS XY (extrusion is +/-Z). + + Only these frames admit an exact 2D write: given a tilted plane, an ``(x, y)`` + pair does not determine a point in it, so writing one would displace the + entity out of its own plane by an amount nobody supplied. + """ + try: + uz = OCS(Vec3(extrusion)).uz + except Exception: + return True + return abs(uz.x) < 1e-12 and abs(uz.y) < 1e-12 + + +def to_wcs_2d(extrusion, x, y, z=0.0) -> list[float]: + """OCS ``(x, y, z)`` to WCS ``[x, y]``. + + ``z`` is ``dxf.elevation`` for an LWPOLYLINE (whose vertices carry no z of + their own) and the point's own z otherwise. + """ + v = OCS(Vec3(extrusion)).to_wcs(Vec3(float(x), float(y), float(z))) + return [float(v.x), float(v.y)] + + +def from_wcs(extrusion, x, y, z=0.0) -> tuple[float, float, float]: + """WCS ``(x, y, z)`` to OCS ``(x, y, z)`` — the inverse of :func:`to_wcs_2d`. + + Needed on every write path. The read and write halves used to be *equally* + wrong, which made a read-then-write round trip an accidental no-op; fixing + reads alone would have converted that no-op into a real move. + """ + v = OCS(Vec3(extrusion)).from_wcs(Vec3(float(x), float(y), float(z))) + return (float(v.x), float(v.y), float(v.z)) + + +def wcs_arc_angles(extrusion, center_ocs, radius, start_deg, end_deg) -> tuple[float, float]: + """OCS sweep angles to CCW-in-WCS angles about the WCS centre. + + Computed by mapping the two endpoints through the frame rather than by + arithmetic on the angles, so it stays right for any frame. A left-handed + frame (``uz.z < 0``, which is what a mirror produces) reverses the direction + of the sweep, so the endpoints swap roles. + """ + ocs = OCS(Vec3(extrusion)) + c0 = Vec3(center_ocs) + centre = ocs.to_wcs(c0) + angles = [] + for angle in (float(start_deg), float(end_deg)): + point = ocs.to_wcs(Vec3.from_deg_angle(angle, abs(float(radius))) + c0) + angles.append(math.degrees(math.atan2(point.y - centre.y, point.x - centre.x)) % 360.0) + if ocs.uz.z < 0: + angles.reverse() + return angles[0], angles[1] + + +def plane_normal(extrusion) -> list[float]: + """The entity's plane normal, for reporting a frame that xy cannot express.""" + uz = OCS(Vec3(extrusion)).uz + return [float(uz.x), float(uz.y), float(uz.z)] diff --git a/benchmarks/README.md b/benchmarks/README.md index 5df4130..5a7ded9 100644 --- a/benchmarks/README.md +++ b/benchmarks/README.md @@ -11,7 +11,7 @@ This repository publishes two deliberately separate forms of evidence: delivery, backend reach, engineering production, tests, and security. The dated data lives in [`source_review.json`](source_review.json), and the README graphic is generated—not hand-edited—from that file. -2. **Fixed-task runtime benchmark.** Adapters execute the same ten tasks and +2. **Fixed-task runtime benchmark.** Adapters execute the same task matrix and return `pass`, `partial`, `unsupported`, `fail`, `timeout`, or `not_run`. AutoCAD MCP Pro currently has the reference adapter; other projects do not receive runtime scores until an adapter actually runs their public interface. @@ -26,38 +26,80 @@ python -m benchmarks.render_chart The source-review chart is not presented as a shared live AutoCAD run. Review dates, evidence grades, project URLs, and the boundary are stored with the data. -## Fixed-task runtime runner v2 +## Fixed-task runtime runner -The v2 runner separates runtime evidence from the source-review rubric. Every -adapter receives the same ten tasks and produces the same closed result enum: +The runner separates runtime evidence from the source-review rubric. Every +adapter receives the same tasks and produces the same closed result enum: `pass`, `partial`, `unsupported`, `fail`, `timeout`, or `not_run`. A timeout is -reported per task. Execution is in-process, so adapters that delegate blocking -work must provide cancellation-safe task implementations or be wrapped by an -external process supervisor. Reports include commit SHA, Python/platform -details, backend capability claims, durations, coverage, and hashes for returned -artifacts. Unsupported tasks remain in the fixed-matrix denominator with score -zero, preventing partial implementations from receiving an inflated score. +reported per task, and a capability refusal is recorded `unsupported` rather +than `fail` — "cannot reach this" and "got this wrong" are different findings. +Execution is in-process, so adapters that delegate blocking work must provide +cancellation-safe task implementations or be wrapped by an external process +supervisor. Reports include commit SHA, Python/platform details, backend +capability claims, durations, coverage, and hashes for returned artifacts. +Unsupported tasks remain in the fixed-matrix denominator with score zero, +preventing partial implementations from receiving an inflated score. ```bash -python -m benchmarks.run_competitors --list -python -m benchmarks.run_competitors --server autocad-mcp-pro --backend ezdxf --json -python -m benchmarks.run_competitors --task table_mleader --task auditable_delivery --json +python -m benchmarks.run_competitors --list --matrix v3 +python -m benchmarks.run_competitors --server autocad-mcp-pro --backend ezdxf --matrix v3 --json +python -m benchmarks.run_competitors --task table_mleader --task hatch_islands --json ``` -The release-machine ezdxf self-check is **10/10 (100.0)**. Repository stars and -raw tool counts do not contribute to the score. Adapter registration lives in -`competitors.yaml`. +The release-machine ezdxf self-check is **15/15 (100.0)** on the v3 matrix. +Repository stars and raw tool counts do not contribute to the score. Adapter +registration lives in `competitors.yaml`. + +### Matrix v3 (v1.5.0) — five tasks that can fail + +v2 scored this server 10/10, and that number carried no information: every task +in it exercised something the server was designed around. `tasks_v3.py` keeps +the v2 ten unchanged — their ids and weights key the published v1.4 reports — +and adds five chosen because they *can* fail. Three of them did, while being +written: + +| Task | Category | Verified against | +|---|---|---| +| `tool_discovery` | discovery | six AutoCAD command names, each ranking #1 of 154 tools | +| `token_budget` | efficiency | 40,305 → 356 tokens advertised, against a 2,000 ceiling fixed in advance | +| `hatch_islands` | hatch | 300 filled with the island, 400 ignoring it | +| `selection_filter` | selection | window 1, crossing 2, bounding box 3, polygon 1 | +| `measure_from_handle` | measurement | 139.2699 against the 100.0 a vertex shoelace gives | + +Token counts come from the offline ratio estimator and the report labels them +`ratio/v1 (estimate)`; `token_suite.py --tokenizer anthropic` counts for real. + +**Two renames from the release plan, and they are the point.** +`tool_discovery_bilingual` → `tool_discovery`: a Turkish query normalizer was +written and then removed, because this repository is English throughout and the +Turkish was inferred from the author's chat language rather than required by the +product. `measurement_massprops` → `measure_from_handle`: no centroid or +moment-of-inertia tool ships. + +**The competitor reports are not re-scored.** Those servers are pinned at v1.4 +commits and were run against the v2 matrix; they have never been asked these +five questions. Their scores stand over the ten they ran, and the matrix chart +leaves the five new rows blank (`not run`) instead of scoring them zero — a +zero we invented would be indistinguishable from a zero we measured. + +`--matrix v2` stays available so a v1.4 report can be reproduced rather than +only described, and `--publish` writes the artifact-path-free file that +`results/published/` holds. ## Live competitor lane (v1.4) Two competitor adapters now execute the exact same task matrix, black-box over MCP stdio, against commits pinned in `competitors.yaml`: -| Server | Pinned | Score | Pass | Coverage | -|---|---|---:|---:|---:| -| autocad-mcp-pro (reference) | working tree | 100.0 | 10/10 | 100% | -| beiming183-cloud/AutoCAD-MCP | `11f7c47e` | 50.0 | 5/10 | 50% | -| puran-water/autocad-mcp | `95476a33` | 45.0 | 4/10 | 50% | +| Server | Matrix | Pinned | Score | Pass | Coverage | +|---|---|---|---:|---:|---:| +| autocad-mcp-pro (reference) | v3 (15) | working tree | 100.0 | 15/15 | 100% | +| beiming183-cloud/AutoCAD-MCP | v2 (10) | `11f7c47e` | 50.0 | 5/10 | 50% | +| puran-water/autocad-mcp | v2 (10) | `95476a33` | 45.0 | 4/10 | 50% | + +The competitor rows are the v1.4 runs, unchanged. Read the matrix column +before the score column: the denominators differ because the five v3 tasks +were added after those runs and have not been put to them. Method and boundaries: @@ -84,12 +126,16 @@ live under [`results/published/`](results/published/); the README charts are regenerated from them: ```bash -python -m benchmarks.run_competitors --server puran-water-autocad-mcp --backend ezdxf -python -m benchmarks.run_competitors --server beiming183-autocad-mcp --backend ezdxf +python -m benchmarks.run_competitors --server puran-water-autocad-mcp --backend ezdxf --matrix v2 +python -m benchmarks.run_competitors --server beiming183-autocad-mcp --backend ezdxf --matrix v2 python -m benchmarks.render_live_chart # score bars python -m benchmarks.render_matrix_chart # tasks x servers status heatmap ``` +`--publish ` writes the same report with artifact paths reduced to +filenames, which is how `results/published/` is produced; the sha256 stays, +because that is what makes an artifact checkable. + ## Headless performance lane `perf_suite.py` measures wall time on fixed workloads through the same backend @@ -107,6 +153,36 @@ python -m benchmarks.perf_suite --out benchmarks/results/published/perf-ezdxf.js python -m benchmarks.render_perf_chart ``` +### v1.4.0 → v1.5.0, same interpreter + +The published v1.4 report was recorded on CPython 3.14 and the v1.5 one on +3.11.15, so comparing the two files directly shows an apparent 2–6× speedup +that is almost entirely the interpreter. The honest comparison re-runs v1.4.0 +in a `git worktree` with the *current* suite file — same instrument, different +code under test, one interpreter. Median of three runs each: + +| Workload | v1.4.0 | v1.5.0 | Change | +|---|---:|---:|---| +| `create_lines_2k` | 245 ms | 315 ms | **28% slower** | +| `roundtrip_10k` | 1,765 ms | 2,146 ms | **22% slower** | +| `region_query_10k` | 1,090 ms | 1,039 ms | within spread | +| `premium_pass` | 375 ms | 44 ms | faster; the v1.4 spread (279–772 ms) is too wide to quantify | + +Attributed rather than reported: `EZDXF_CALL_TIMEOUT=0` puts v1.5.0 back to +244.8 ms and 1,679 ms, which are v1.4.0's numbers. The entire cost is the +per-call `asyncio.wait_for` wrapped around every headless call in this release, +so a single hung ezdxf call can no longer wedge a server whose document lock is +one `asyncio.Lock`. Reproduce: + +```bash +git worktree add /tmp/wt-v140 v1.4.0 +cp benchmarks/perf_suite.py /tmp/wt-v140/benchmarks/ +(cd /tmp/wt-v140 && PYTHONPATH=. python benchmarks/perf_suite.py --out /tmp/v140.json) +python -m benchmarks.perf_suite --out /tmp/v150.json +EZDXF_CALL_TIMEOUT=0 python -m benchmarks.perf_suite --out /tmp/v150-notimeout.json +git worktree remove --force /tmp/wt-v140 +``` + ## Correctness A/B suite `correctness_suite.py` is a set of deterministic, headless (ezdxf-backend) checks @@ -123,19 +199,36 @@ python benchmarks/compare_versions.py v1.0.0 # vs a tag/ref python benchmarks/compare_versions.py --json results.json ``` -### Result — v1.4.0 vs v1.3.0 (release gate) +### Result — v1.5.0 vs v1.4.0 (release gate) -21 checks, ezdxf backend, one subprocess per check. Machine-readable report: +24 checks, ezdxf backend, one subprocess per check. Machine-readable report: +[`results/published/ab-v1.4.0-vs-v1.5.0.json`](results/published/ab-v1.4.0-vs-v1.5.0.json). + +| Version | Checks passing | Pass rate | Fixed | Regressed | +|---------|----------------|-----------|-------|-----------| +| **v1.4.0** (baseline) | 21 / 24 | 87.5 % | — | — | +| **v1.5.0** (this release) | 24 / 24 | 100 % | 3 | **0** | + +Read the three "fixed" carefully: they are **new capability, not repaired +regressions**. v1.4.0 has no `entity_measure` and no boundary tracing, so it +misses `hatch_area_subtracts_its_island`, `boundary_area_agrees_with_measure` +and `two_vertex_circle_has_area` by not having the method rather than by +getting it wrong. They are in the suite because all three broke during v1.5.0 +development and nothing outside their own unit tests would have caught it. + +The 21 pre-existing checks are unchanged and all still pass: the discovery +layer, `cad_batch`, the 19-module contract split, the layout/viewport family +and Wave A landed with **zero correctness regressions**. + +### Result — v1.4.0 vs v1.3.0 + +21 checks. Report: [`results/published/ab-v1.3.0-vs-v1.4.0.json`](results/published/ab-v1.3.0-vs-v1.4.0.json). | Version | Checks passing | Pass rate | Fixed | Regressed | |---------|----------------|-----------|-------|-----------| -| **v1.3.0** (baseline) | 21 / 21 | 100 % | — | — | -| **v1.4.0** (this release) | 21 / 21 | 100 % | 0 | **0** | - -The v1.4.0 feature and infrastructure work (CI, packaging, competitor lane, -tool profiles, paper space, ISO 286 fits, opt-in solids) introduced **zero -correctness regressions**. +| **v1.3.0** (baseline) | 21 / 21 | 100 % | — | — | +| **v1.4.0** | 21 / 21 | 100 % | 0 | **0** | ### Result — v1.1.0 vs v1.0.0 (`origin/main`, commit 15fa2bc) diff --git a/benchmarks/adapters/autocad_mcp_pro.py b/benchmarks/adapters/autocad_mcp_pro.py index 8df9162..fd8cc2c 100644 --- a/benchmarks/adapters/autocad_mcp_pro.py +++ b/benchmarks/adapters/autocad_mcp_pro.py @@ -1,15 +1,45 @@ -"""Reference v2 adapter for this repository.""" +"""Reference adapter for this repository, covering the v3 task matrix. + +Every task verifies against a value worked out independently of the code under +test — a closed-form area, a count of entities placed on purpose, a token +ceiling fixed before the measurement. A task that only checks the server agrees +with itself is a task that cannot fail. +""" from __future__ import annotations +import math from pathlib import Path from benchmarks.adapters.base import BenchmarkAdapter, TaskResult -from benchmarks.tasks_v2 import TaskSpec +from benchmarks.tasks_v3 import TaskSpec from engineering.delivery import deliver_drawing from engineering.layers import ensure_engineering_layers, ensure_standard_linetypes from engineering.refiner import refine_drawing +#: AutoCAD command names a drafter types, none of which appeared in any tool +#: name or description before v1.5.0 — measured `df = 0` against the stock BM25 +#: index, so no amount of tuning could have surfaced them. +#: Each command is answered by a *set*, because more than one tool can be a +#: correct answer -- QSELECT is both "filter by properties" and "select +#: similar", and picking a winner between them would test the tie-break rather +#: than the discovery. +DISCOVERY_QUERIES: tuple[tuple[str, frozenset[str]], ...] = ( + ("FILLET", frozenset({"entity_fillet"})), + ("BPOLY", frozenset({"boundary_trace"})), + ("QSELECT", frozenset({"selection_filter", "entity_select_smart"})), + ("WBLOCK", frozenset({"block_create_from_entities"})), + ("OVERKILL", frozenset({"drawing_refine"})), + ("CHSPACE", frozenset({"entity_change_space"})), +) + +#: How far down the ranking still counts as found. A drafter reads the top few. +DISCOVERY_TOP_N = 3 + +#: The advertised catalog must stay under this in discovery mode. Fixed here +#: rather than derived from the measurement, so the measurement can miss it. +TOKEN_CEILING = 2_000 + class AutoCADMCPProAdapter(BenchmarkAdapter): name = "autocad-mcp-pro" @@ -138,6 +168,202 @@ class AutoCADMCPProAdapter(BenchmarkAdapter): await reopened.disconnect() return count == 1, {"entity_count": count}, [str(path)] + # ── v3 (v1.5.0) ───────────────────────────────────────────────────────── + + async def _task_tool_discovery(self): + """Can a drafter find the tool by typing the command they know? + + Ranked through the real transform, not a stub: the corpus is what makes + these commands findable at all, and testing anything else would test + the fixture. + """ + import server + from discovery.transform import CadSearchTransform + + tools = list(await server.mcp._list_tools()) + transform = CadSearchTransform() + + ranks: dict[str, int | None] = {} + for command, accepted in DISCOVERY_QUERIES: + hits = [ + tool.name for tool in transform.rank(tools, command, limit=DISCOVERY_TOP_N).hits + ] + found = next((index for index, name in enumerate(hits) if name in accepted), None) + ranks[command] = None if found is None else found + 1 + + passed = all(rank is not None for rank in ranks.values()) + return passed, {"ranks": ranks, "top_n": DISCOVERY_TOP_N}, [] + + async def _task_token_budget(self): + """What the client pays before it has asked for anything. + + The ceiling is a constant, so a regression that doubles the catalog + fails this rather than quietly reporting a bigger number. + """ + import server + from benchmarks.token_suite import ( + FORMAT_JSON_SCHEMA, + _client, + _wire_payload, + build_counter, + ) + + async def _advertised(mode: str) -> tuple[int, int]: + """Tokens on the wire for `tools/list`, and how many tools that is. + + Measured through a real client rather than the registry: the + registry is what the transform hides, so counting it would make + discovery mode look free. + """ + server._apply_discovery_mode(mode) + async with _client() as client: + tools = await client.list_tools() + return counter.count(_wire_payload(tools), FORMAT_JSON_SCHEMA), len(tools) + + counter = build_counter("ratio") + try: + full, catalog_count = await _advertised("off") + discovery, advertised_count = await _advertised("search") + finally: + server._apply_discovery_mode("off") + + passed = discovery < TOKEN_CEILING and discovery < full + return ( + passed, + { + "catalog_tokens": full, + "discovery_tokens": discovery, + "ceiling": TOKEN_CEILING, + "reduction_x": round(full / discovery, 1) if discovery else None, + "catalog_tools": catalog_count, + "advertised_tools": advertised_count, + # Named, because "40,305 tokens" reads like a count and is not + # one. `benchmarks/token_suite.py --tokenizer anthropic` counts + # for real; this lane has to stay offline and deterministic. + "tokenizer": f"{counter.name} (estimate)" if counter.estimated else counter.name, + }, + [], + ) + + async def _task_hatch_islands(self): + """A section view is mostly holes, so the island is the measurement. + + 20x20 outer square with a 10x10 island: the filled area is 300, and a + server that records the island without subtracting it reports 400 while + looking entirely correct on screen. + """ + await ensure_engineering_layers(self.backend) + hatch = await self.backend.entity_create_hatch( + "ANSI31", [[0, 0], [20, 0], [20, 20], [0, 20]], layer="HATCH" + ) + island = [ + {"type": "line", "start": [5, 5], "end": [15, 5]}, + {"type": "line", "start": [15, 5], "end": [15, 15]}, + {"type": "line", "start": [15, 15], "end": [5, 15]}, + {"type": "line", "start": [5, 15], "end": [5, 5]}, + ] + added = await self.backend.hatch_add_boundary(hatch.handle, island) + measured = await self.backend.entity_measure(hatch.handle) + ignored = await self.backend.hatch_edit(hatch.handle, style="ignore") + over_island = await self.backend.entity_measure(hatch.handle) + + passed = ( + added["path_count"] == 2 + and abs(measured["area"] - 300.0) < 1e-6 + and measured["hatch_style"] == "outer" # what entity_create_hatch writes + and ignored["changed"] == ["style"] + and abs(over_island["area"] - 400.0) < 1e-6 + ) + return ( + passed, + { + "path_count": added["path_count"], + "filled_area": measured["area"], + "area_ignoring_islands": over_island["area"], + "expected_filled": 300.0, + "hatch_style": measured["hatch_style"], + }, + [], + ) + + async def _task_selection_filter(self): + """Window is not crossing, and a polygon is not its bounding box. + + Three circles placed so each distinction has exactly one right answer: + one wholly inside, one straddling the edge, one out in the corner that + the bounding box catches and the triangle does not. + """ + await ensure_engineering_layers(self.backend) + await self.backend.entity_create_circle(5, 5, 1, layer="GEOMETRY") + await self.backend.entity_create_circle(10, 5, 3, layer="GEOMETRY") + await self.backend.entity_create_circle(18, 18, 0.5, layer="GEOMETRY") + await self.backend.entity_create_circle(40, 40, 1, layer="TEXT") + + window = await self.backend.selection_window(0, 0, 10, 10, mode="window") + crossing = await self.backend.selection_window(0, 0, 10, 10, mode="crossing") + bbox = await self.backend.selection_window(0, 0, 20, 20, mode="window") + triangle = await self.backend.selection_polygon([[0, 0], [20, 0], [0, 20]], mode="window") + by_layer = await self.backend.selection_filter(entity_type="CIRCLE", layer="TEXT") + + passed = ( + window["count"] == 1 + and crossing["count"] == 2 + and bbox["count"] == 3 + and triangle["count"] == 1 + and by_layer["count"] == 1 + and by_layer["filtered_by"] == ["entity_type", "layer"] + ) + return ( + passed, + { + "window": window["count"], + "crossing": crossing["count"], + "bounding_box": bbox["count"], + "polygon": triangle["count"], + "by_layer": by_layer["count"], + }, + [], + ) + + async def _task_measure_from_handle(self): + """The 28.2% that reading coordinates back loses. + + A 10x10 square whose top edge is a semicircle encloses 139.2699. Chain + the loose edges into a boundary, then measure that boundary by handle; + shoelacing the four corners the model would have remembered gives 100. + """ + await ensure_engineering_layers(self.backend) + await self.backend.entity_create_line(0, 0, 10, 0, layer="GEOMETRY") + await self.backend.entity_create_line(10, 0, 10, 10, layer="GEOMETRY") + await self.backend.entity_create_line(0, 10, 0, 0, layer="GEOMETRY") + await self.backend.entity_create_arc(5, 10, 5, 0, 180, layer="GEOMETRY") + + loop = await self.backend.boundary_trace(5.0, 5.0) + measured = await self.backend.entity_measure(loop["handle"]) + naive = await self.backend.analysis_measure_area([[0, 0], [10, 0], [10, 10], [0, 10]]) + + true_area = 100.0 + math.pi * 25.0 / 2.0 + lost = (true_area - naive) / true_area + passed = ( + loop["ok"] is True + and abs(measured["area"] - true_area) < 1e-5 + and abs(loop["area"] - measured["area"]) < 1e-9 + and measured["method"] == "analytic_bulge" + and measured["exact"] is True + and 0.28 < lost < 0.29 + ) + return ( + passed, + { + "measured_area": measured["area"], + "true_area": round(true_area, 6), + "vertex_shoelace_area": naive, + "fraction_lost_by_reading_points_back": round(lost, 4), + "boundary_agrees": abs(loop["area"] - measured["area"]) < 1e-9, + }, + [], + ) + async def _task_auditable_delivery(self): await ensure_engineering_layers(self.backend) await self.backend.entity_create_line(0, 0, 10, 0, layer="GEOMETRY") diff --git a/benchmarks/correctness_suite.py b/benchmarks/correctness_suite.py index 2e74b98..ae246df 100644 --- a/benchmarks/correctness_suite.py +++ b/benchmarks/correctness_suite.py @@ -223,6 +223,52 @@ async def center_linetype_applied(): return info.linetype.upper() == "CENTER" +# ── Measurement (v1.5.0) ─────────────────────────────────────────────────────── +# +# These three are NEW capability, not repaired regressions: v1.4.0 has no +# `entity_measure` and no boundary tracing, so it misses them by not having the +# method rather than by getting it wrong. They are here because all three broke +# during v1.5.0 development and nothing outside their own unit tests would have +# noticed. + + +async def hatch_area_subtracts_its_island(): + """A section view is mostly holes; counting them is a 33% error here.""" + b = await _b() + h = await b.entity_create_hatch("ANSI31", [[0, 0], [20, 0], [20, 20], [0, 20]]) + await b.hatch_add_boundary( + h.handle, + [ + {"type": "line", "start": [5, 5], "end": [15, 5]}, + {"type": "line", "start": [15, 5], "end": [15, 15]}, + {"type": "line", "start": [15, 15], "end": [5, 15]}, + {"type": "line", "start": [5, 15], "end": [5, 5]}, + ], + ) + return abs((await b.entity_measure(h.handle))["area"] - 300.0) < 1e-6 + + +async def boundary_area_agrees_with_measure(): + """Two tools, one shape, one number.""" + b = await _b() + await b.entity_create_line(0, 0, 10, 0) + await b.entity_create_line(10, 0, 10, 10) + await b.entity_create_line(0, 10, 0, 0) + await b.entity_create_arc(5, 10, 5, 0, 180) + loop = await b.boundary_trace(5.0, 5.0) + measured = await b.entity_measure(loop["handle"]) + true_area = 100.0 + math.pi * 25.0 / 2.0 + return abs(loop["area"] - measured["area"]) < 1e-9 and abs(measured["area"] - true_area) < 1e-5 + + +async def two_vertex_circle_has_area(): + """DXF's most compact circle. The shoelace over its two points is 0.""" + from engineering.measure import polygon_area_perimeter + + area, _ = polygon_area_perimeter([(-10.0, 0.0, 1.0), (10.0, 0.0, 1.0)], closed=True) + return abs(area - math.pi * 100.0) < 1e-9 + + CHECKS = { "core_line_length": (core_line_length, "Core"), "core_circle_radius": (core_circle_radius, "Core"), @@ -245,6 +291,9 @@ CHECKS = { "construction_left_iso_caught": (construction_left_iso_caught, "Quality gate"), "gear_no_self_overlap": (gear_no_self_overlap, "Engineering"), "center_linetype_applied": (center_linetype_applied, "Entities"), + "hatch_area_subtracts_its_island": (hatch_area_subtracts_its_island, "Measurement"), + "boundary_area_agrees_with_measure": (boundary_area_agrees_with_measure, "Measurement"), + "two_vertex_circle_has_area": (two_vertex_circle_has_area, "Measurement"), } diff --git a/benchmarks/render_matrix_chart.py b/benchmarks/render_matrix_chart.py index 961cb2a..770b387 100644 --- a/benchmarks/render_matrix_chart.py +++ b/benchmarks/render_matrix_chart.py @@ -13,7 +13,7 @@ import json from pathlib import Path from typing import Any -from benchmarks.tasks_v2 import TASKS_V2 +from benchmarks.tasks_v3 import TASKS_V3 ROOT = Path(__file__).resolve().parents[1] DEFAULT_INPUT_DIR = ROOT / "benchmarks" / "results" / "published" @@ -66,8 +66,8 @@ def render_chart(reports: list[dict[str, Any]], output: Path) -> None: matplotlib.rcParams["svg.hashsalt"] = "autocad-mcp-taskmatrix-v1" - task_ids = [task.task_id for task in TASKS_V2] - task_labels = [f"{task.task_id} ({task.category})" for task in TASKS_V2] + task_ids = [task.task_id for task in TASKS_V3] + task_labels = [f"{task.task_id} ({task.category})" for task in TASKS_V3] servers = [report["adapter"] for report in reports] status_by_server = { report["adapter"]: {item["task_id"]: item["status"] for item in report["results"]} @@ -132,12 +132,17 @@ def render_chart(reports: list[dict[str, Any]], output: Path) -> None: Patch(facecolor=STATUS_COLORS["partial"], label="partial"), Patch(facecolor=STATUS_COLORS["fail"], label="fail"), Patch(facecolor=STATUS_COLORS["unsupported"], label="unsupported (no equivalent)"), + # Real rows now, not a theoretical state: the competitor reports were + # pinned and run against the v2 matrix, so the five v3 tasks were + # never put to them. An invented zero would look identical to a + # measured one, so they stay blank and say why. + Patch(facecolor=STATUS_COLORS["not_run"], edgecolor="#30363d", label="not run"), ] ax.legend( handles=legend_handles, loc="upper left", bbox_to_anchor=(0, -0.02), - ncol=4, + ncol=5, frameon=False, fontsize=9, labelcolor="#e6edf3", diff --git a/benchmarks/results/published/ab-v1.4.0-vs-v1.5.0.json b/benchmarks/results/published/ab-v1.4.0-vs-v1.5.0.json new file mode 100644 index 0000000..892c79b --- /dev/null +++ b/benchmarks/results/published/ab-v1.4.0-vs-v1.5.0.json @@ -0,0 +1,158 @@ +{ + "summary": { + "baseline_ref": "v1.4.0", + "total": 24, + "old_pass": 21, + "new_pass": 24, + "old_pct": 87.5, + "new_pct": 100.0, + "fixed": 3, + "regressed": 0 + }, + "rows": [ + { + "check": "core_line_length", + "category": "Core", + "old": "pass", + "new": "pass" + }, + { + "check": "core_circle_radius", + "category": "Core", + "old": "pass", + "new": "pass" + }, + { + "check": "core_layer_create", + "category": "Core", + "old": "pass", + "new": "pass" + }, + { + "check": "core_polyline_closed", + "category": "Core", + "old": "pass", + "new": "pass" + }, + { + "check": "core_linear_dim", + "category": "Core", + "old": "pass", + "new": "pass" + }, + { + "check": "core_save_dxf_roundtrip", + "category": "Core", + "old": "pass", + "new": "pass" + }, + { + "check": "dim_aligned_no_error", + "category": "Dimensions", + "old": "pass", + "new": "pass" + }, + { + "check": "dim_angular_no_error", + "category": "Dimensions", + "old": "pass", + "new": "pass" + }, + { + "check": "array_polar_360_distinct", + "category": "Modify", + "old": "pass", + "new": "pass" + }, + { + "check": "point_intersection_line_line", + "category": "Geometry", + "old": "pass", + "new": "pass" + }, + { + "check": "point_tangent_external", + "category": "Geometry", + "old": "pass", + "new": "pass" + }, + { + "check": "arc_has_length", + "category": "Query", + "old": "pass", + "new": "pass" + }, + { + "check": "arc_select_by_length", + "category": "Query", + "old": "pass", + "new": "pass" + }, + { + "check": "ezdxf_bounding_box", + "category": "Query", + "old": "pass", + "new": "pass" + }, + { + "check": "mtext_rotation_roundtrip", + "category": "Entities", + "old": "pass", + "new": "pass" + }, + { + "check": "screenshot_png", + "category": "Render", + "old": "pass", + "new": "pass" + }, + { + "check": "dim_overlap_critique_fires", + "category": "Quality gate", + "old": "pass", + "new": "pass" + }, + { + "check": "iso13567_dim_layer", + "category": "Quality gate", + "old": "pass", + "new": "pass" + }, + { + "check": "construction_left_iso_caught", + "category": "Quality gate", + "old": "pass", + "new": "pass" + }, + { + "check": "gear_no_self_overlap", + "category": "Engineering", + "old": "pass", + "new": "pass" + }, + { + "check": "center_linetype_applied", + "category": "Entities", + "old": "pass", + "new": "pass" + }, + { + "check": "hatch_area_subtracts_its_island", + "category": "Measurement", + "old": "miss", + "new": "pass" + }, + { + "check": "boundary_area_agrees_with_measure", + "category": "Measurement", + "old": "miss", + "new": "pass" + }, + { + "check": "two_vertex_circle_has_area", + "category": "Measurement", + "old": "miss", + "new": "pass" + } + ] +} \ No newline at end of file diff --git a/benchmarks/results/published/autocad-mcp-pro.json b/benchmarks/results/published/autocad-mcp-pro.json index c940240..e94e570 100644 --- a/benchmarks/results/published/autocad-mcp-pro.json +++ b/benchmarks/results/published/autocad-mcp-pro.json @@ -5,6 +5,21 @@ "capabilities": { "backend": "ezdxf", "features": { + "audit_detail": { + "mode": "native", + "reason": null, + "supported": true + }, + "boundary_trace": { + "mode": "computed", + "reason": "straight_edges_split_at_intersections;curved_edges_not_split", + "supported": true + }, + "chspace": { + "mode": "computed", + "reason": "top_view_untwisted_viewports_only;dimensions_refused_unless_frozen;acis_proxy_table_refused;viewport_clipping_reported_not_applied", + "supported": true + }, "delivery": { "mode": "shared", "reason": null, @@ -15,26 +30,61 @@ "reason": null, "supported": true }, + "dwg": { + "mode": null, + "reason": "no_dwg_writer_requires_live_autocad", + "supported": false + }, "dxf": { "mode": "native", "reason": null, "supported": true }, + "handle_overlay": { + "mode": "rendered", + "reason": null, + "supported": true + }, + "hatch_edge_paths": { + "mode": "native", + "reason": null, + "supported": true + }, "lisp": { "mode": null, "reason": "live_com_only", "supported": false }, + "measure_area_acis": { + "mode": null, + "reason": "acis_evaluation_requires_live_autocad", + "supported": false + }, "mleader": { "mode": "composite", "reason": null, "supported": true }, - "paper_space": { + "mtext_background_color": { + "mode": "native", + "reason": null, + "supported": true + }, + "ocs_normalized": { + "mode": "wcs", + "reason": "reported_xy_is_wcs;z_component_not_reported;text_rotation_stays_in_entity_frame;hatch_boundaries_not_reported", + "supported": true + }, + "ocs_tilted_plane": { "mode": null, - "reason": "planned_1.4", + "reason": "2d_xy_cannot_address_a_tilted_plane", "supported": false }, + "paper_space": { + "mode": "native", + "reason": null, + "supported": true + }, "pdf": { "mode": "rendered", "reason": null, @@ -55,9 +105,14 @@ "reason": null, "supported": true }, + "revcloud": { + "mode": "native", + "reason": "ezdxf_revcloud_helper", + "supported": true + }, "solid_3d": { "mode": null, - "reason": "planned_1.5", + "reason": "acis_generation_requires_live_autocad", "supported": false }, "table": { @@ -69,6 +124,21 @@ "mode": "snapshot", "reason": null, "supported": true + }, + "undo_history": { + "mode": null, + "reason": "set_EZDXF_UNDO_DEPTH_to_enable", + "supported": false + }, + "viewport_render": { + "mode": "rendered", + "reason": "viewport_borders_not_drawn", + "supported": true + }, + "wipeout": { + "mode": "native", + "reason": null, + "supported": true } } } @@ -76,15 +146,16 @@ "environment": { "implementation": "CPython", "machine": "AMD64", - "platform": "Windows-11-10.0.26200-SP0", - "python": "3.14.0" + "platform": "Windows-10-10.0.26200-SP0", + "python": "3.11.15" }, - "generated_at": "2026-07-23T11:39:04.277175+00:00", - "git_sha": "4a2e9d658f051cb756f92cb8fae945c64b8cc7d4", + "generated_at": "2026-08-06T06:27:59.080169+00:00", + "git_sha": "d5a0ff5109ddbd37d88371c10a1ed087201de4b5", + "matrix": "v3", "results": [ { "artifacts": [], - "duration_ms": 8.14, + "duration_ms": 2.49, "message": "", "metrics": { "line_length": 5.0 @@ -97,7 +168,7 @@ }, { "artifacts": [], - "duration_ms": 3.39, + "duration_ms": 1.41, "message": "", "metrics": {}, "score": 100.0, @@ -108,7 +179,7 @@ }, { "artifacts": [], - "duration_ms": 8.11, + "duration_ms": 3.31, "message": "", "metrics": {}, "score": 100.0, @@ -119,7 +190,7 @@ }, { "artifacts": [], - "duration_ms": 348.74, + "duration_ms": 17.55, "message": "", "metrics": { "entity_type": "DIMENSION" @@ -132,7 +203,7 @@ }, { "artifacts": [], - "duration_ms": 15.74, + "duration_ms": 3.7, "message": "", "metrics": { "representations": [ @@ -148,7 +219,7 @@ }, { "artifacts": [], - "duration_ms": 28.95, + "duration_ms": 29.09, "message": "", "metrics": { "entity_count": 0 @@ -161,7 +232,7 @@ }, { "artifacts": [], - "duration_ms": 2.5, + "duration_ms": 1.2, "message": "", "metrics": {}, "score": 100.0, @@ -172,7 +243,7 @@ }, { "artifacts": [], - "duration_ms": 29.19, + "duration_ms": 8.42, "message": "", "metrics": { "final_score": 100.0, @@ -215,11 +286,11 @@ "artifacts": [ { "filename": "roundtrip.dxf", - "sha256": "6413204a5baec19a5fbf551418eb56e30b1488ca5bb755e6a8709ac8e8ab0b22", - "size_bytes": 18442 + "sha256": "d11dfce5eddaf849075c62854a8526efaaa3bbd779d374e4c287de77f361caf7", + "size_bytes": 18440 } ], - "duration_ms": 16.66, + "duration_ms": 32.29, "message": "", "metrics": { "entity_count": 1 @@ -234,11 +305,11 @@ "artifacts": [ { "filename": "manifest.json", - "sha256": "38c2ee819b49b9c66b2311b952e856eff9e3a7565194cce2edea60a3211d233b", - "size_bytes": 3581 + "sha256": "a74b2e96dbe7ea373717f26a8d26e58fe079a61370eb5d646f04a09f1b7cca2f", + "size_bytes": 5662 } ], - "duration_ms": 4380.8, + "duration_ms": 305.81, "message": "", "metrics": { "score": { @@ -255,16 +326,107 @@ "stderr_summary": "", "stdout_summary": "", "task_id": "auditable_delivery" + }, + { + "artifacts": [], + "duration_ms": 1149.77, + "message": "", + "metrics": { + "ranks": { + "BPOLY": 1, + "CHSPACE": 1, + "FILLET": 1, + "OVERKILL": 1, + "QSELECT": 1, + "WBLOCK": 1 + }, + "top_n": 3 + }, + "score": 100.0, + "status": "pass", + "stderr_summary": "", + "stdout_summary": "", + "task_id": "tool_discovery" + }, + { + "artifacts": [], + "duration_ms": 53.97, + "message": "", + "metrics": { + "advertised_tools": 2, + "catalog_tokens": 40305, + "catalog_tools": 149, + "ceiling": 2000, + "discovery_tokens": 356, + "reduction_x": 113.2, + "tokenizer": "ratio/v1 (estimate)" + }, + "score": 100.0, + "status": "pass", + "stderr_summary": "", + "stdout_summary": "", + "task_id": "token_budget" + }, + { + "artifacts": [], + "duration_ms": 10.6, + "message": "", + "metrics": { + "area_ignoring_islands": 400.0, + "expected_filled": 300.0, + "filled_area": 300.0, + "hatch_style": "outer", + "path_count": 2 + }, + "score": 100.0, + "status": "pass", + "stderr_summary": "", + "stdout_summary": "", + "task_id": "hatch_islands" + }, + { + "artifacts": [], + "duration_ms": 8.0, + "message": "", + "metrics": { + "bounding_box": 3, + "by_layer": 1, + "crossing": 2, + "polygon": 1, + "window": 1 + }, + "score": 100.0, + "status": "pass", + "stderr_summary": "", + "stdout_summary": "", + "task_id": "selection_filter" + }, + { + "artifacts": [], + "duration_ms": 5.36, + "message": "", + "metrics": { + "boundary_agrees": true, + "fraction_lost_by_reading_points_back": 0.282, + "measured_area": 139.269908, + "true_area": 139.269908, + "vertex_shoelace_area": 100.0 + }, + "score": 100.0, + "status": "pass", + "stderr_summary": "", + "stdout_summary": "", + "task_id": "measure_from_handle" } ], "schema_version": "2.0", "summary": { - "attempted": 10, + "attempted": 15, "coverage_percent": 100.0, - "passed": 10, + "passed": 15, "score": 100.0, - "supported": 10, + "supported": 15, "unsupported": 0 }, - "timeout_seconds": 30.0 -} \ No newline at end of file + "timeout_seconds": 180.0 +} diff --git a/benchmarks/results/published/perf-ezdxf.json b/benchmarks/results/published/perf-ezdxf.json index ebdc1e6..171a6f2 100644 --- a/benchmarks/results/published/perf-ezdxf.json +++ b/benchmarks/results/published/perf-ezdxf.json @@ -3,8 +3,8 @@ "environment": { "implementation": "CPython", "machine": "AMD64", - "platform": "Windows-11-10.0.26200-SP0", - "python": "3.14.0" + "platform": "Windows-10-10.0.26200-SP0", + "python": "3.11.15" }, "schema_version": "perf/1.0", "workloads": [ @@ -14,22 +14,22 @@ "entity_count": 2000 }, "metric": "entities_per_second", - "metric_value": 3148.0, - "wall_ms": 635.3, + "metric_value": 6343.1, + "wall_ms": 315.3, "workload_id": "create_lines_2k" }, { "description": "Create 10,000 lines, export DXF, reopen and verify", "detail": { - "build_ms": 10884.9, + "build_ms": 1574.1, "entity_count": 10000, - "export_ms": 1439.2, - "file_bytes": 1369662, - "reopen_ms": 1607.5 + "export_ms": 261.8, + "file_bytes": 1369663, + "reopen_ms": 320.2 }, "metric": "entities_per_second", - "metric_value": 717.8, - "wall_ms": 13931.6, + "metric_value": 4637.9, + "wall_ms": 2156.1, "workload_id": "roundtrip_10k" }, { @@ -39,7 +39,7 @@ }, "metric": "entities_matched", "metric_value": 2500.0, - "wall_ms": 3434.2, + "wall_ms": 890.8, "workload_id": "region_query_10k" }, { @@ -49,7 +49,7 @@ }, "metric": "critique_issues", "metric_value": 0.0, - "wall_ms": 315.9, + "wall_ms": 176.5, "workload_id": "premium_pass" } ] diff --git a/benchmarks/run_competitors.py b/benchmarks/run_competitors.py index 28e37c8..78f1784 100644 --- a/benchmarks/run_competitors.py +++ b/benchmarks/run_competitors.py @@ -14,8 +14,9 @@ import time from datetime import UTC, datetime from pathlib import Path +from backends.capability import UnsupportedCapabilityError from benchmarks.adapters.base import BenchmarkAdapter, TaskResult -from benchmarks.tasks_v2 import TASKS_V2, TaskSpec, task_by_id +from benchmarks.tasks_v3 import DEFAULT_MATRIX, MATRICES, TaskSpec, task_by_id def _load_registry() -> dict[str, dict]: @@ -78,6 +79,7 @@ async def run_tasks( artifact_dir: str | Path, *, timeout: float = 30.0, + matrix: str = DEFAULT_MATRIX, ) -> dict: destination = Path(artifact_dir).resolve() destination.mkdir(parents=True, exist_ok=True) @@ -90,6 +92,12 @@ async def run_tasks( result = await asyncio.wait_for(adapter.run_task(task), timeout=timeout) except TimeoutError: result = TaskResult(task.task_id, "timeout", 0.0, f"Exceeded {timeout}s") + except UnsupportedCapabilityError as exc: + # "Your server got this wrong" and "your engine cannot reach + # this" are different findings about a competitor. The enum has + # carried `unsupported` since v2 for exactly this, but a + # refusal is still an exception and used to land as `fail`. + result = TaskResult(task.task_id, "unsupported", 0.0, f"[{exc.capability}] {exc}") except Exception as exc: result = TaskResult(task.task_id, "fail", 0.0, str(exc), stderr_summary=str(exc)) result.duration_ms = round((time.perf_counter() - started) * 1000, 2) @@ -108,7 +116,11 @@ async def run_tasks( weight_by_id[item.task_id] * item.score for item in results if item.status != "unsupported" ) return { + # The *report* schema is unchanged — same keys, same enum — so the + # chart renderers still read v1.4's published files. `matrix` says + # which task set ran, which is the thing that actually moved. "schema_version": "2.0", + "matrix": matrix, "adapter": adapter.name, "adapter_metadata": adapter.metadata(), "git_sha": _git_sha(), @@ -137,6 +149,12 @@ async def run_tasks( def _parser(registry: dict[str, dict]) -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--list", action="store_true", help="List the fixed benchmark tasks") + parser.add_argument( + "--matrix", + default=DEFAULT_MATRIX, + choices=sorted(MATRICES), + help="Task set to run. v2 stays addressable so a v1.4 report can be reproduced.", + ) parser.add_argument( "--server", default="autocad-mcp-pro", @@ -148,14 +166,35 @@ def _parser(registry: dict[str, dict]) -> argparse.ArgumentParser: parser.add_argument("--artifact-dir", default="benchmarks/results/latest") parser.add_argument("--timeout", type=float, default=30.0) parser.add_argument("--json", action="store_true", help="Print machine-readable JSON") + parser.add_argument( + "--publish", + type=Path, + help=( + "Also write the report here with artifact paths reduced to filenames. " + "How results/published/*.json are produced — it was a manual edit before." + ), + ) return parser +def _sanitize_for_publication(report: dict) -> dict: + """Drop absolute paths from artifacts, keep the hash that identifies them. + + A published report should not carry the run machine's directory layout; + the sha256 is what makes the artifact checkable, and that stays. + """ + published = json.loads(json.dumps(report)) + for result in published["results"]: + for artifact in result["artifacts"]: + artifact.pop("path", None) + return published + + def main() -> None: registry = _load_registry() args = _parser(registry).parse_args() if args.list: - for task in TASKS_V2: + for task in MATRICES[args.matrix]: print(f"{task.task_id}\t{task.category}\t{task.description}") return @@ -165,10 +204,27 @@ def main() -> None: raise SystemExit( f"{args.server} supports backends {supported_backends}, not {args.backend!r}" ) - tasks = [task_by_id(task_id) for task_id in args.task] if args.task else list(TASKS_V2) + tasks = ( + [task_by_id(task_id, args.matrix) for task_id in args.task] + if args.task + else list(MATRICES[args.matrix]) + ) adapter = _make_adapter(entry, args.backend) - report = asyncio.run(run_tasks(adapter, tasks, args.artifact_dir, timeout=args.timeout)) + report = asyncio.run( + run_tasks(adapter, tasks, args.artifact_dir, timeout=args.timeout, matrix=args.matrix) + ) rendered = json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + if args.publish: + args.publish.parent.mkdir(parents=True, exist_ok=True) + args.publish.write_text( + json.dumps( + _sanitize_for_publication(report), ensure_ascii=False, indent=2, sort_keys=True + ) + + "\n", + encoding="utf-8", + newline="\n", + ) + print(f"Published: {args.publish}", file=sys.stderr) print(rendered) diff --git a/benchmarks/tasks_v3.py b/benchmarks/tasks_v3.py new file mode 100644 index 0000000..8a6d851 --- /dev/null +++ b/benchmarks/tasks_v3.py @@ -0,0 +1,84 @@ +"""The v3 task matrix: the v2 ten, plus five the v2 ten could not see. + +v2 scored this server 10/10, and that number was structurally uninformative: +every task in it exercised something the server was already built around. A +matrix that cannot fail is a matrix that measures nothing, so the five added +here were picked to cover capability the v2 set never touches — discovery, +idle token cost, hatch islands, selection semantics, and measuring an entity +that is already in the drawing. + +Two of the five are renamed from the release plan, and the renames are the +point rather than bookkeeping: + +``tool_discovery_bilingual`` -> ``tool_discovery`` + The plan assumed a Turkish query normalizer. One was written and then + removed: this repository is English throughout, and the Turkish was an + inference from the author's chat language, not a product requirement. A + task named "bilingual" would advertise a capability that does not exist, + which is the class of claim this release spent four milestones deleting. + +``measurement_massprops`` -> ``measure_from_handle`` + No mass properties ship. The plan's eight-tool measurement family (F2) was + cut to one after measurement showed the repo already carried eight + ``analysis_*`` tools; centroids and moments of inertia were not among the + survivors. What did ship is measuring a *handle* instead of a caller's + remembered coordinates, so that is what the task is called. + +The v2 ids and weights are untouched. The published competitor reports were +run against the v2 matrix at v1.4 and are not re-scored here — those servers +have never been asked these five questions, and inventing a result for them +would be worse than the gap. The task-matrix chart renders them ``not_run``. +""" + +from __future__ import annotations + +from benchmarks.tasks_v2 import TASKS_V2, TaskSpec + +#: Added in v1.5.0. Every one of these can fail on this server. +NEW_TASKS_V3: tuple[TaskSpec, ...] = ( + TaskSpec( + "tool_discovery", + "discovery", + "Resolve AutoCAD command names to tools through the search layer", + 1.0, + ), + TaskSpec( + "token_budget", + "efficiency", + "Keep the idle catalog under a fixed token ceiling", + 1.0, + ), + TaskSpec( + "hatch_islands", + "hatch", + "Hatch an area with an island and measure what it fills", + 1.0, + ), + TaskSpec( + "selection_filter", + "selection", + "Separate window from crossing, and a polygon from its bounding box", + 1.0, + ), + TaskSpec( + "measure_from_handle", + "measurement", + "Measure a curved boundary from its handle, bulges included", + 1.0, + ), +) + +TASKS_V3: tuple[TaskSpec, ...] = TASKS_V2 + NEW_TASKS_V3 + +#: Runner-selectable task sets. v2 stays addressable so a v2 report can be +#: reproduced exactly rather than only described. +MATRICES: dict[str, tuple[TaskSpec, ...]] = {"v2": TASKS_V2, "v3": TASKS_V3} + +DEFAULT_MATRIX = "v3" + + +def task_by_id(task_id: str, matrix: str = DEFAULT_MATRIX) -> TaskSpec: + for task in MATRICES[matrix]: + if task.task_id == task_id: + return task + raise KeyError(f"Unknown benchmark task: {task_id}") diff --git a/benchmarks/token_suite.py b/benchmarks/token_suite.py new file mode 100644 index 0000000..4f85724 --- /dev/null +++ b/benchmarks/token_suite.py @@ -0,0 +1,1117 @@ +"""Token-cost suite (v1.5.0 lane): what this server's surface costs a client, measured. + +Every token claim in the release has to come from here rather than from an +estimate typed into a README. Three costs are measured against the **real +in-memory server** (a `fastmcp.Client` speaking to `server.mcp`, same code path +a stdio client drives): + +1. **Idle cost** - the serialized ``tools/list`` payload a client pays on + connect, for the default surface, ``TOOL_PROFILE=lean`` and + ``DISCOVERY_MODE=search``. +2. **Per-discovery cost** - one ``search_tools`` round trip, ours against stock + :class:`~fastmcp.server.transforms.search.BM25SearchTransform` *configured + with the same compact serializer ours uses*. Stock-on-its-defaults is also + measured, and labelled ``strawman: true``, because comparing against it is + how you accidentally credit the renderer for the ranker's work. +3. **Result-side cost** - what a realistic ``entity_list`` actually returns from + a drawing of a few hundred entities. This is the half nobody had measured. + +Counting rules +-------------- +**Characters are the primary unit.** They are exact, tokenizer-independent and +reproducible on any machine; ``chars`` and ``bytes_utf8`` are what this harness +*measures*. + +**Tokens are an estimate unless a real tokenizer is present, and they are always +labelled.** Two token methods exist: + +``--tokenizer ratio`` (default) + A **per-format** chars/token divisor (:data:`CHARS_PER_TOKEN`). Per-format, + not flat: JSON schema text runs ~4.19 chars/token while the compact pipe + cards of ``discovery/serialize.py`` run ~3.29. Those two divisors differ by + 27%, so one flat number (the ubiquitous chars/4) silently flatters whichever + format it is wrong about - by more than several of the differences this + suite reports. Each ratio carries its provenance and confidence into the + report, and the one that is *borrowed* rather than measured says so. + +``--tokenizer anthropic`` + Real counts from ``POST /v1/messages/count_tokens``. Needs the ``anthropic`` + package plus credentials, neither of which is in this repo's lock, so it is + opt-in; when it is unavailable the harness **raises** rather than quietly + falling back to the divisors. + +``tiktoken`` is deliberately *not* supported, even though it is a real +tokenizer: it is OpenAI's BPE, and Anthropic's own guidance is that it +undercounts Claude tokens by ~15-20% on prose and by more on code and JSON. +Wiring it in would reintroduce exactly the error this module exists to avoid, +only wearing a tokenizer's clothes. + +**Cached and uncached are both reported.** A tool prefix that never mutates is +prompt-cacheable and every real client caches it, so a single uncached headline +is not a number anyone reproduces in practice. Cache reads are priced at 0.1x +input and the first write at 1.25x (5-minute ephemeral TTL); a payload below a +model's minimum cacheable prefix cannot cache at all, which the idle lane +reports per model tier. + +Honesty boundaries +------------------ +- ezdxf only. The suite pins ``AUTOCAD_MCP_BACKEND=ezdxf`` and *verifies* it via + ``system_status`` before drawing anything - measuring (and mutating) a live + AutoCAD document by accident would be both wrong and rude. +- Self-measurement only: no competitor server is timed or sized here. +- The suite mutates a shared singleton (tool profile, discovery transform) and + restores every global it touches. + +Run:: + + python -m benchmarks.token_suite # human summary + JSON report + python -m benchmarks.token_suite --json # machine-readable on stdout + python -m benchmarks.token_suite --entities 500 # bigger result-lane drawing +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import platform +import sys +from collections import Counter +from collections.abc import Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import config + +ROOT = Path(__file__).resolve().parents[1] + +SCHEMA_VERSION = "token/1.0" +DEFAULT_OUTPUT = ROOT / "benchmarks" / "results" / "token-cost.json" + +#: The env var ``server._make_backend`` actually reads. Note it reads the *env*, +#: not ``config.settings.backend`` - setting the settings attribute alone leaves +#: a Windows box with AutoCAD open talking to live COM. +BACKEND_ENV = "AUTOCAD_MCP_BACKEND" + +#: Entities built for the result lane. A few hundred is what a real plate or +#: assembly drawing holds by the time it is dimensioned. +DEFAULT_ENTITIES = 300 + +#: ``entity_list`` limits worth pricing: the tool's default, a page a caller +#: would plausibly ask for, and the largest the schema permits. +LIST_LIMITS = (25, 100, 1000) + +#: ``limit`` is declared ``Field(..., ge=1, le=1000)`` on the tool, so the +#: ``MAX_LIST_LIMIT`` cap (default 5000) cannot be reached through it. +SCHEMA_MAX_LIMIT = 1000 + +#: Turns in the modelled session. Cost per request is the honest per-turn +#: number; a session total is what a user actually pays. +SESSION_TURNS = 30 + + +# --------------------------------------------------------------------------- +# Counting +# --------------------------------------------------------------------------- + +FORMAT_JSON_SCHEMA = "json_schema" +FORMAT_COMPACT_TEXT = "compact_text" +FORMAT_JSON_DATA = "json_data" + +#: How a ratio came to be. ``borrowed`` means no measurement exists for that +#: format yet and a neighbouring format's divisor is standing in - a weaker +#: estimate, and marked as one everywhere it is used. +RATIO_CONFIDENCE = ("carried-over-measurement", "borrowed") + + +@dataclass(frozen=True, slots=True) +class FormatRatio: + """One format's chars/token divisor, with where it came from.""" + + chars_per_token: float + provenance: str + confidence: str + + +CHARS_PER_TOKEN: dict[str, FormatRatio] = { + FORMAT_JSON_SCHEMA: FormatRatio( + 4.19, + "v1.5.0 design-pass measurement over this server's own tools/list payload " + "(JSON Schema text: short ASCII keys, punctuation and prose descriptions). " + "Not re-derived here - re-derive with --tokenizer anthropic.", + "carried-over-measurement", + ), + FORMAT_COMPACT_TEXT: FormatRatio( + 3.29, + "v1.5.0 design-pass measurement over the compact pipe cards emitted by " + "discovery/serialize.py. Denser than schema text, hence the lower divisor - " + "the whole reason a flat divisor is wrong.", + "carried-over-measurement", + ), + FORMAT_JSON_DATA: FormatRatio( + 4.19, + "Borrowed from json_schema; no measurement exists for dense numeric JSON. " + "Float-heavy JSON plausibly tokenizes worse (fewer chars per token), so the " + "token figures on result-side payloads are best read as a floor.", + "borrowed", + ), +} + +#: Anthropic's default reference model for the opt-in real-tokenizer path. +DEFAULT_TOKENIZER_MODEL = "claude-opus-5" + + +class RatioTokenCounter: + """Per-format chars/token estimator. Always available; always an estimate.""" + + name = "ratio/v1" + estimated = True + note = ( + "tokens = characters / a per-format divisor (see counting.ratios); " + "estimates, not counts. Characters are the measured unit." + ) + + def chars_per_token(self, fmt: str) -> float: + return CHARS_PER_TOKEN[fmt].chars_per_token + + def confidence(self, fmt: str) -> str: + return CHARS_PER_TOKEN[fmt].confidence + + def count(self, text: str, fmt: str) -> int: + """Estimate tokens. An unknown format raises rather than guessing. + + Floored at one token for any non-empty text: dividing a 2-character + payload ("[]", which stock BM25 returns when it finds nothing) by 4.19 + and rounding gives zero, and a free payload is not a thing. + """ + if not text: + return 0 + return max(1, int(round(len(text) / CHARS_PER_TOKEN[fmt].chars_per_token))) + + +class AnthropicTokenCounter: + """Real counts from ``POST /v1/messages/count_tokens``. + + The endpoint counts a *message*, while most payloads here are really a + ``tools`` array or a tool result, so the count carries a small fixed + message-envelope overhead. That is a known, bounded approximation and is + recorded in the report's ``method_note`` - unlike a wrong tokenizer, it does + not scale with payload size. + """ + + estimated = False + note = ( + "tokens counted by POST /v1/messages/count_tokens for the recorded model; " + "payloads are counted as a single user message, so each count carries the " + "fixed message-envelope overhead." + ) + + def __init__(self, client: Any, model: str) -> None: + self._client = client + self._model = model + self.name = f"anthropic-count-tokens/{model}" + + def chars_per_token(self, fmt: str) -> float | None: + CHARS_PER_TOKEN[fmt] # validate the format label even when unused + return None + + def confidence(self, fmt: str) -> str: + CHARS_PER_TOKEN[fmt] + return "measured" + + def count(self, text: str, fmt: str) -> int: + CHARS_PER_TOKEN[fmt] + response = self._client.messages.count_tokens( + model=self._model, + messages=[{"role": "user", "content": text}], + ) + return int(response.input_tokens) + + +def _anthropic_client() -> Any | None: + """The Anthropic SDK client, or None when the package/credentials are absent.""" + try: + import anthropic + except ImportError: + return None + try: + return anthropic.Anthropic() + except Exception: # missing credentials, bad profile, ... + return None + + +def build_counter(tokenizer: str = "ratio", *, model: str = DEFAULT_TOKENIZER_MODEL) -> Any: + """Build the requested token counter, or refuse. + + The refusal is the point: silently degrading ``--tokenizer anthropic`` into + the ratio estimator would put unlabelled estimates under a heading that + promised counts. + """ + if tokenizer == "ratio": + return RatioTokenCounter() + if tokenizer == "anthropic": + client = _anthropic_client() + if client is None: + raise RuntimeError( + "--tokenizer anthropic needs the `anthropic` package and working " + "credentials; neither is in this repo's lock. Refusing to fall back " + "to the ratio estimator silently - re-run with --tokenizer ratio and " + "read the token columns as estimates." + ) + return AnthropicTokenCounter(client, model) + raise ValueError(f"unknown tokenizer {tokenizer!r} (expected 'ratio' or 'anthropic')") + + +# --------------------------------------------------------------------------- +# Cache pricing +# --------------------------------------------------------------------------- + +#: A cache read costs ~0.1x base input; the first write ~1.25x at the default +#: 5-minute ephemeral TTL. +CACHE_READ_MULTIPLIER = 0.1 +CACHE_WRITE_MULTIPLIER = 1.25 + +#: Minimum cacheable prefix, per model tier. Below it, a prefix silently does +#: not cache - no error, just no cache entry. +PROMPT_CACHE_MINIMUM_TOKENS: dict[int, tuple[str, ...]] = { + 512: ("claude-opus-5", "claude-fable-5", "claude-mythos-5"), + 1024: ("claude-opus-4-8", "claude-sonnet-5", "claude-sonnet-4-6", "claude-sonnet-4-5"), + 2048: ("claude-opus-4-7", "claude-haiku-3-5"), + 4096: ("claude-opus-4-6", "claude-opus-4-5", "claude-haiku-4-5"), +} + + +def cost_model(tokens: float, turns: int = SESSION_TURNS) -> dict[str, Any]: + """Price one re-sent span both ways, per request and over a session. + + Applies to any span a client re-sends on every request: the tool prefix + (which never mutates) and, once it lands, a tool result sitting in history. + Uncached, the span is paid in full every turn. Cached, it is written once at + 1.25x and read at 0.1x thereafter. + """ + turns = max(1, int(turns)) + session_cached = tokens * CACHE_WRITE_MULTIPLIER + tokens * CACHE_READ_MULTIPLIER * (turns - 1) + session_uncached = tokens * turns + return { + "unit": "input tokens (cached figures are price-equivalent: read at 0.1x, write at 1.25x)", + "uncached_input_tokens_per_request": round(tokens, 1), + "cached_input_tokens_per_request": round(tokens * CACHE_READ_MULTIPLIER, 1), + "turns": turns, + "session_uncached": round(session_uncached, 1), + "session_cached": round(session_cached, 1), + "session_saving_ratio": ( + round(1.0 - session_cached / session_uncached, 3) if session_uncached else 0.0 + ), + } + + +def cacheability(tokens: float) -> dict[str, Any]: + """Which model tiers can cache a prefix this size at all.""" + return { + "cacheable": { + str(minimum): tokens >= minimum for minimum in sorted(PROMPT_CACHE_MINIMUM_TOKENS) + }, + "models_by_minimum": { + str(minimum): list(models) + for minimum, models in sorted(PROMPT_CACHE_MINIMUM_TOKENS.items()) + }, + "note": ( + "Minimum cacheable prefix by model tier. A prefix below the minimum does " + "not cache and reports no error, so its cached column is unreachable in " + "practice." + ), + } + + +# --------------------------------------------------------------------------- +# Measurement +# --------------------------------------------------------------------------- + + +def _dumps(obj: Any) -> str: + """Compact JSON, matching what a JSON-RPC transport puts on the wire.""" + return json.dumps(obj, separators=(",", ":"), ensure_ascii=False) + + +def measure(text: str, fmt: str, counter: Any, *, turns: int = SESSION_TURNS) -> dict[str, Any]: + """One payload, priced: exact characters, labelled tokens, both cost columns.""" + tokens = counter.count(text, fmt) + return { + "chars": len(text), + "bytes_utf8": len(text.encode("utf-8")), + "tokens": { + "value": tokens, + "format": fmt, + "method": counter.name, + "estimated": bool(counter.estimated), + "chars_per_token": ( + counter.chars_per_token(fmt) + if counter.estimated + else (round(len(text) / tokens, 2) if tokens else None) + ), + "confidence": counter.confidence(fmt), + }, + "cost": cost_model(tokens, turns), + } + + +def counting_block(counter: Any) -> dict[str, Any]: + """The method statement that has to travel with the numbers.""" + return { + "primary_unit": "characters", + "token_method": counter.name, + "tokens_are_estimates": bool(counter.estimated), + "method_note": counter.note, + "rejected_methods": { + "flat_chars_per_token": ( + "One divisor for every format mis-states every cross-format " + "comparison in this report - the schema-text and compact-text " + "divisors differ by 27%. Within a format it would cancel; the " + "comparisons here are between formats." + ), + "tiktoken": ( + "A real tokenizer, but OpenAI's: it undercounts Claude tokens by " + "~15-20% on prose and more on code/JSON, so it would reintroduce the " + "same class of error. Not wired in on purpose." + ), + }, + "ratios": { + name: { + "chars_per_token": ratio.chars_per_token, + "confidence": ratio.confidence, + "provenance": ratio.provenance, + } + for name, ratio in CHARS_PER_TOKEN.items() + }, + } + + +# --------------------------------------------------------------------------- +# Server access +# --------------------------------------------------------------------------- + + +def _load_server(): + """Import ``server`` lazily. + + Same reason server.py imports its backends lazily: pulling in fastmcp and + ezdxf costs a second, and ``--help`` should not pay it. + """ + import server + + return server + + +def require_ezdxf(status: dict[str, Any]) -> None: + """Abort unless the connected backend is the headless engine. + + ``server._make_backend`` reads the *environment*, not ``config.settings``, + so a harness that forgot the env var would happily build a few hundred + entities inside the operator's live AutoCAD document - and measure a + different server while doing it. + """ + backend = str(status.get("backend", "unknown")) + if backend != "ezdxf": + raise RuntimeError( + f"refusing to measure against the {backend!r} backend: this suite is " + f"ezdxf-only. Set {BACKEND_ENV}=ezdxf (the env var is what " + "server._make_backend reads; config.settings.backend is not)." + ) + + +def _client(): + """An unconnected in-memory client for the real server. ``async with`` it.""" + from fastmcp import Client + + return Client(_load_server().mcp) + + +# --------------------------------------------------------------------------- +# Lane 1: idle cost (tools/list) +# --------------------------------------------------------------------------- + + +def _wire_payload(tools: Sequence[Any]) -> str: + """The ``tools/list`` result exactly as the MCP SDK serializes it. + + ``mcp.shared.session`` dumps every response with + ``model_dump(by_alias=True, mode="json", exclude_none=True)``, so this is the + payload minus the few dozen bytes of JSON-RPC envelope. + """ + payload = { + "tools": [tool.model_dump(by_alias=True, mode="json", exclude_none=True) for tool in tools] + } + return _dumps(payload) + + +def _prompt_payload(tools: Sequence[Any]) -> str: + """The subset an MCP client forwards to a model API as its ``tools`` array. + + Name, description and input schema - no annotations, titles, output schemas + or ``_meta``. Clients differ; this is the floor of what any of them sends. + """ + rows = [ + { + "name": tool.name, + "description": tool.description or "", + "input_schema": tool.inputSchema, + } + for tool in tools + ] + return _dumps(rows) + + +async def _idle_row(variant: str, note: str, counter: Any, turns: int) -> dict[str, Any]: + client = _client() + async with client: + tools = await client.list_tools() + wire = _wire_payload(tools) + prompt = _prompt_payload(tools) + row = measure(wire, FORMAT_JSON_SCHEMA, counter, turns=turns) + row.update( + { + "variant": variant, + "note": note, + "advertised_tools": len(tools), + "wire_chars": len(wire), + "prompt_chars": len(prompt), + "prompt_payload": measure(prompt, FORMAT_JSON_SCHEMA, counter, turns=turns), + "prompt_cache": cacheability(row["tokens"]["value"]), + } + ) + return row + + +async def idle_lane(counter: Any, turns: int = SESSION_TURNS) -> list[dict[str, Any]]: + """What a client pays on connect, for each advertised surface.""" + srv = _load_server() + previous_profile = config.settings.tool_profile + rows: list[dict[str, Any]] = [] + try: + config.settings.tool_profile = "full" + srv._apply_discovery_mode("off") + rows.append( + await _idle_row( + "default", + "TOOL_PROFILE=full, DISCOVERY_MODE=off - the shipped default.", + counter, + turns, + ) + ) + config.settings.tool_profile = "lean" + rows.append( + await _idle_row( + "lean", + "TOOL_PROFILE=lean - the curated drafting core.", + counter, + turns, + ) + ) + config.settings.tool_profile = "full" + srv._apply_discovery_mode("search") + rows.append( + await _idle_row( + "search", + "DISCOVERY_MODE=search - search_tools plus the call_tool proxy; the " + "catalog is reached through the search tool instead of advertised.", + counter, + turns, + ) + ) + finally: + srv._apply_discovery_mode("off") + config.settings.tool_profile = previous_profile + await srv._apply_tool_profile(previous_profile) + return rows + + +# --------------------------------------------------------------------------- +# Lane 2: per-discovery cost (one search_tools round trip) +# --------------------------------------------------------------------------- + +#: Published, not cherry-picked: plain-English drafting intents plus the AutoCAD +#: command names a drafter actually types. WBLOCK is in deliberately - it is a +#: df=0 case where stock BM25 returns nothing at all, i.e. the cheapest possible +#: payload and the worst possible answer. +#: +#: BPOLY held that slot until v1.5.0 (M8). `boundary_trace` now names BOUNDARY +#: and BPOLY in its own docstring, so stock BM25 finds it and BPOLY is no +#: longer a df=0 case. Keeping it as the zero-hit example would have measured a +#: gap that had closed. +QUERIES = ( + "round the corner", + "WBLOCK", + "BPOLY", + "how many entities are on the GEOMETRY layer", + "draw a hole in the plate", + "dimension everything with a chain", + "save the drawing as a pdf", + "put a leader note on the chamfer", + "select every circle on the GEOMETRY layer", +) + +#: Hits per search. The transform's own default. +SEARCH_LIMIT = 5 + + +async def _catalog() -> list[Any]: + """The visible catalog a search transform would rank, untransformed.""" + srv = _load_server() + srv._apply_discovery_mode("off") + await srv._apply_tool_profile("full") + return list(await srv.mcp.list_tools()) + + +async def discovery_lane(counter: Any, turns: int = SESSION_TURNS) -> dict[str, Any]: + """One search round trip, ours against a *configured* stock BM25 transform.""" + from fastmcp.server.transforms.search import BM25SearchTransform + from fastmcp.server.transforms.search.base import serialize_tools_for_output_json + + from discovery.serialize import serialize_search_results + from discovery.transform import CadSearchTransform + + catalog = await _catalog() + ours = CadSearchTransform(max_results=SEARCH_LIMIT) + stock_compact = BM25SearchTransform( + max_results=SEARCH_LIMIT, search_result_serializer=serialize_search_results + ) + stock_default = BM25SearchTransform(max_results=SEARCH_LIMIT) + + arms: dict[str, dict[str, Any]] = { + "cad_search": { + "arm": "cad_search", + "transform": "discovery.transform.CadSearchTransform", + "serializer": "discovery.serialize.serialize_search_results", + "strawman": False, + "format": FORMAT_COMPACT_TEXT, + "note": "Ours: alias-aware BM25 plus the risk filter, compact cards.", + "queries": [], + }, + "stock_bm25_compact": { + "arm": "stock_bm25_compact", + "transform": "fastmcp.server.transforms.search.BM25SearchTransform", + "serializer": "discovery.serialize.serialize_search_results", + "strawman": False, + "format": FORMAT_COMPACT_TEXT, + "note": ( + "Stock BM25 wired to the same compact serializer - the fair A/B. Any " + "size difference against ours is ranking, not rendering." + ), + "queries": [], + }, + "stock_bm25_default": { + "arm": "stock_bm25_default", + "transform": "fastmcp.server.transforms.search.BM25SearchTransform", + "serializer": "fastmcp...serialize_tools_for_output_json", + "strawman": True, + "format": FORMAT_JSON_SCHEMA, + "note": ( + "Stock BM25 on its default serializer, which re-emits each hit's full " + "list_tools entry. Recorded to size the renderer's contribution, NOT " + "as a baseline any claim should be made against." + ), + "queries": [], + }, + } + + # ``_search`` / ``_render_results`` are FastMCP internals, and deliberately so: + # they are the seam where the ranker hands off to the serializer, which is the + # only place the two arms can be compared with the renderer held constant. + # discovery/transform.py already builds on the same seam. + for query in QUERIES: + outcome = ours.rank(catalog, query, limit=SEARCH_LIMIT) + payload = await ours.render(outcome) + arms["cad_search"]["queries"].append( + _query_row(query, outcome.hits, str(payload), FORMAT_COMPACT_TEXT, counter, turns) + ) + + hits = list(await stock_compact._search(catalog, query)) + rendered = await stock_compact._render_results(hits) + arms["stock_bm25_compact"]["queries"].append( + _query_row(query, hits, str(rendered), FORMAT_COMPACT_TEXT, counter, turns) + ) + + hits_default = list(await stock_default._search(catalog, query)) + raw = await stock_default._render_results(hits_default) + text = ( + raw if isinstance(raw, str) else _dumps(serialize_tools_for_output_json(hits_default)) + ) + arms["stock_bm25_default"]["queries"].append( + _query_row(query, hits_default, text, FORMAT_JSON_SCHEMA, counter, turns) + ) + + return { + "limit": SEARCH_LIMIT, + "catalog_tools": len(catalog), + "arms": list(arms.values()), + "summary": _discovery_summary(arms), + } + + +def _query_row( + query: str, + hits: Sequence[Any], + payload: str, + fmt: str, + counter: Any, + turns: int, +) -> dict[str, Any]: + row = measure(payload, fmt, counter, turns=turns) + row.update({"query": query, "hits": len(hits), "top": [tool.name for tool in hits[:3]]}) + return row + + +def _discovery_summary(arms: dict[str, dict[str, Any]]) -> dict[str, Any]: + """Mean payload per arm over queries every arm actually answered. + + Averaging a zero-hit answer in would score "found nothing" as the cheapest + result, which is precisely backwards: a search that returns nothing costs + the caller another turn. + """ + by_query: dict[str, dict[str, dict[str, Any]]] = {} + for name, arm in arms.items(): + for row in arm["queries"]: + by_query.setdefault(row["query"], {})[name] = row + comparable = [q for q, rows in by_query.items() if all(r["hits"] > 0 for r in rows.values())] + + def _mean(name: str, key: str) -> float: + values = [by_query[q][name][key] for q in comparable] + return round(sum(values) / len(values), 1) if values else 0.0 + + return { + "queries": len(by_query), + "comparable_queries": len(comparable), + "excluded_queries": sorted(set(by_query) - set(comparable)), + "mean_chars": {name: _mean(name, "chars") for name in arms}, + "mean_tokens": { + name: round( + sum(by_query[q][name]["tokens"]["value"] for q in comparable) / len(comparable), 1 + ) + if comparable + else 0.0 + for name in arms + }, + "hit_rate": { + name: round( + sum(1 for row in arms[name]["queries"] if row["hits"] > 0) + / len(arms[name]["queries"]), + 3, + ) + for name in arms + }, + "note": ( + "Means cover only queries every arm answered; zero-hit answers are " + "excluded from size means and reported in hit_rate instead. The compact " + "serializer, not the ranker, is what makes a search answer small - stock " + "BM25 with the same serializer lands within a few percent. Ours differs " + "in finding the tool at all." + ), + } + + +# --------------------------------------------------------------------------- +# Lane 3: result-side cost (what entity_list actually returns) +# --------------------------------------------------------------------------- + + +async def _build_drawing(client: Any, entities: int) -> None: + """A mixed drawing: lines, circles, arcs, text and polylines on ISO layers.""" + await client.call_tool("drawing_new", {}) + for index in range(entities): + x = float(index % 20) * 10.0 + y = float(index // 20) * 10.0 + shape = index % 5 + if shape == 0: + await client.call_tool( + "entity_create_line", + {"x1": x, "y1": y, "x2": x + 8.0, "y2": y + 6.0, "layer": "GEOMETRY"}, + ) + elif shape == 1: + await client.call_tool( + "entity_create_circle", + {"cx": x + 4.0, "cy": y + 3.0, "radius": 1.7, "layer": "GEOMETRY"}, + ) + elif shape == 2: + await client.call_tool( + "entity_create_arc", + { + "cx": x, + "cy": y, + "radius": 2.5, + "start_angle": 15.0, + "end_angle": 200.0, + "layer": "HIDDEN", + }, + ) + elif shape == 3: + await client.call_tool( + "entity_create_text", + {"text": f"P{index:03d}", "x": x, "y": y + 7.0, "height": 2.5, "layer": "TEXT"}, + ) + else: + await client.call_tool( + "entity_create_polyline", + { + "points": [[x, y], [x + 8.0, y], [x + 8.0, y + 6.0]], + "closed": True, + "layer": "GEOMETRY", + }, + ) + + +def _without_bounding_box(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + stripped = [] + for row in rows: + copy = dict(row) + properties = dict(copy.get("properties") or {}) + properties.pop("bounding_box", None) + copy["properties"] = properties + stripped.append(copy) + return stripped + + +def _round_floats(value: Any, digits: int) -> Any: + if isinstance(value, float): + return round(value, digits) + if isinstance(value, dict): + return {key: _round_floats(item, digits) for key, item in value.items()} + if isinstance(value, list): + return [_round_floats(item, digits) for item in value] + return value + + +def _counterfactual( + label: str, text: str, baseline_chars: int, counter: Any, turns: int, note: str +) -> dict[str, Any]: + row = measure(text, FORMAT_JSON_DATA, counter, turns=turns) + row.update( + { + "counterfactual": True, + "label": label, + "note": note, + "saved_chars": baseline_chars - len(text), + "saved_pct": ( + round(100.0 * (baseline_chars - len(text)) / baseline_chars, 1) + if baseline_chars + else 0.0 + ), + } + ) + return row + + +async def result_lane( + counter: Any, turns: int = SESSION_TURNS, entities: int = DEFAULT_ENTITIES +) -> dict[str, Any]: + """What one realistic ``entity_list`` call puts back into the context window.""" + client = _client() + scenarios: list[dict[str, Any]] = [] + async with client: + status = (await client.call_tool("system_status", {})).structured_content or {} + require_ezdxf(status) + await _build_drawing(client, entities) + info = (await client.call_tool("drawing_info", {})).structured_content or {} + entity_types: Counter[str] = Counter() + + for limit in LIST_LIMITS: + result = await client.call_tool("entity_list", {"limit": limit}) + text = result.content[0].text + structured = _dumps(result.structured_content) + rows = json.loads(text) + if len(rows) >= sum(entity_types.values()): + # The mix of the *drawing*, read off the widest listing - summing + # across scenarios would count the same entities several times. + entity_types = Counter(row["type"] for row in rows) + + row = measure(text, FORMAT_JSON_DATA, counter, turns=turns) + baseline = len(text) + row.update( + { + "scenario": f"entity_list(limit={limit})", + "limit": limit, + "rows_returned": len(rows), + "text_chars": len(text), + "structured_chars": len(structured), + "wire_chars": len(text) + len(structured), + "chars_per_entity": round(baseline / len(rows), 1) if rows else 0.0, + "note": ( + "chars/tokens price the text block - what a model client feeds " + "the model. FastMCP also returns the same rows as " + "structuredContent, so the transport carries the payload twice; " + "wire_chars is that total." + ), + "counterfactuals": { + "without_bounding_box": _counterfactual( + "without_bounding_box", + _dumps(_without_bounding_box(rows)), + baseline, + counter, + turns, + "Every EntityInfo carries a nested properties.bounding_box; " + "this is what dropping it would save.", + ), + "floats_rounded_6dp": _counterfactual( + "floats_rounded_6dp", + _dumps(_round_floats(rows, 6)), + baseline, + counter, + turns, + "Full-precision repr (14.142135623730951) versus 6 decimals - " + "drawing units are mm, so the extra digits are noise.", + ), + }, + } + ) + scenarios.append(row) + + widest = max(scenarios, key=lambda row: row["rows_returned"], default=None) + per_entity = widest["chars_per_entity"] if widest else 0.0 + return { + "backend": str(status.get("backend", "unknown")), + "entities_in_drawing": int(info.get("entity_count", 0)), + "entity_types": dict(sorted(entity_types.items())), + "scenarios": scenarios, + "projection": { + "chars_per_entity": per_entity, + "schema_max_limit": SCHEMA_MAX_LIMIT, + "config_max_list_limit": config.settings.max_list_limit, + "extrapolated": True, + "extrapolated_chars_at_schema_max": round(per_entity * SCHEMA_MAX_LIMIT), + "extrapolated_chars_at_config_cap": round(per_entity * config.settings.max_list_limit), + "note": ( + "entity_list declares limit as Field(ge=1, le=1000), so the " + "MAX_LIST_LIMIT cap (default 5000) is unreachable through the tool's " + "schema - the config-cap row is an extrapolation from measured " + "chars/entity, not a payload anyone can actually elicit." + ), + }, + } + + +# --------------------------------------------------------------------------- +# Suite +# --------------------------------------------------------------------------- + + +def _package_version() -> str | None: + try: + import tomllib + + with (ROOT / "pyproject.toml").open("rb") as stream: + return str(tomllib.load(stream)["project"]["version"]) + except Exception: + return None + + +def _environment() -> dict[str, Any]: + import ezdxf + import fastmcp + + return { + "python": sys.version.split()[0], + "implementation": platform.python_implementation(), + "platform": platform.platform(), + "machine": platform.machine(), + "fastmcp": fastmcp.__version__, + "ezdxf": ezdxf.__version__, + "package_version": _package_version(), + } + + +async def run_suite( + *, + entities: int = DEFAULT_ENTITIES, + turns: int = SESSION_TURNS, + counter: Any | None = None, +) -> dict[str, Any]: + """Run all three lanes and hand back the report document. + + Restores every global it touches: the forced backend env var, the tool + profile and the discovery transform all go back the way they were, whatever + happens in between. + """ + srv = _load_server() + counter = counter or RatioTokenCounter() + previous_env = os.environ.get(BACKEND_ENV) + previous_profile = config.settings.tool_profile + os.environ[BACKEND_ENV] = "ezdxf" + try: + idle = await idle_lane(counter, turns) + discovery = await discovery_lane(counter, turns) + result = await result_lane(counter, turns, entities) + finally: + srv._apply_discovery_mode("off") + config.settings.tool_profile = previous_profile + await srv._apply_tool_profile(previous_profile) + if previous_env is None: + os.environ.pop(BACKEND_ENV, None) + else: + os.environ[BACKEND_ENV] = previous_env + + return { + "schema_version": SCHEMA_VERSION, + "backend": result["backend"], + "environment": _environment(), + "counting": counting_block(counter), + "session_model": { + "turns": turns, + "cache_read_multiplier": CACHE_READ_MULTIPLIER, + "cache_write_multiplier": CACHE_WRITE_MULTIPLIER, + "note": ( + "Uncached: the span is re-sent at full price every turn. Cached: one " + "write at 1.25x, then reads at 0.1x. A tool prefix never mutates, so " + "the cached column is the one a real client experiences." + ), + }, + "idle": idle, + "discovery": discovery, + "result": result, + } + + +# --------------------------------------------------------------------------- +# Human summary +# --------------------------------------------------------------------------- + + +def summarize(report: dict[str, Any]) -> str: + """A short ASCII summary; the console this runs on is cp1254.""" + lines: list[str] = [] + counting = report["counting"] + env = report["environment"] + lines.append("AutoCAD MCP Pro - token cost") + lines.append(f" backend={report['backend']} python={env['python']} fastmcp={env['fastmcp']}") + lines.append( + " unit: characters (measured exactly); tokens are an " + f"{'estimate' if counting['tokens_are_estimates'] else 'exact count'} " + f"via {counting['token_method']}" + ) + lines.append("") + + turns = report["session_model"]["turns"] + lines.append("1. Idle cost - tools/list on connect") + lines.append( + f" {'variant':8} {'tools':>5} {'chars':>9} {'tokens':>8} " + f"{'uncached/' + str(turns) + 'turns':>16} {'cached/' + str(turns) + 'turns':>15} caches?" + ) + for row in report["idle"]: + cost = row["cost"] + smallest = min(int(key) for key in row["prompt_cache"]["cacheable"]) + caches = ( + "yes" if row["prompt_cache"]["cacheable"][str(smallest)] else "NO (below min prefix)" + ) + lines.append( + f" {row['variant']:8} {row['advertised_tools']:5d} {row['chars']:9,d} " + f"{row['tokens']['value']:8,d} {cost['session_uncached']:16,.0f} " + f"{cost['session_cached']:15,.0f} {caches}" + ) + lines.append( + f" tokens are the uncached per-request cost; over {turns} turns prompt caching " + f"cuts it ~{1 / (1 - report['idle'][0]['cost']['session_saving_ratio']):.1f}x - " + "unless the prefix is too small to cache at all." + ) + lines.append("") + + summary = report["discovery"]["summary"] + lines.append( + f"2. Per-discovery cost - one search_tools answer, limit={report['discovery']['limit']}" + ) + for arm in report["discovery"]["arms"]: + name = arm["arm"] + tag = " (strawman)" if arm["strawman"] else "" + lines.append( + f" {name:19} {summary['mean_chars'][name]:9,.0f} chars " + f"{summary['mean_tokens'][name]:7,.0f} tokens " + f"hit rate {summary['hit_rate'][name]:.0%}{tag}" + ) + lines.append( + f" means over {summary['comparable_queries']}/{summary['queries']} queries " + f"every arm answered; excluded: {', '.join(summary['excluded_queries']) or 'none'}" + ) + lines.append("") + + result = report["result"] + lines.append(f"3. Result-side cost - entity_list over {result['entities_in_drawing']} entities") + for row in result["scenarios"]: + bbox = row["counterfactuals"]["without_bounding_box"]["saved_pct"] + lines.append( + f" limit={row['limit']:<5} rows={row['rows_returned']:<5} " + f"{row['chars']:8,d} chars {row['tokens']['value']:7,d} tokens " + f"wire {row['wire_chars']:8,d} chars (sent twice) " + f"bounding_box {bbox:.0f}%" + ) + floats = result["scenarios"][-1]["counterfactuals"]["floats_rounded_6dp"]["saved_pct"] + lines.append( + f" where the bytes go: bounding_box " + f"{result['scenarios'][-1]['counterfactuals']['without_bounding_box']['saved_pct']:.0f}%, " + f"float precision beyond 6dp {floats:.0f}%" + ) + projection = result["projection"] + lines.append( + f" {projection['chars_per_entity']:.0f} chars/entity; " + f"MAX_LIST_LIMIT={projection['config_max_list_limit']} would be " + f"{projection['extrapolated_chars_at_config_cap']:,} chars, but the tool's " + f"schema caps limit at {projection['schema_max_limit']}" + ) + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description="Measure this server's token cost.") + parser.add_argument("--json", action="store_true", help="print the JSON report on stdout") + parser.add_argument( + "--out", + type=Path, + default=DEFAULT_OUTPUT, + help=f"where to write the JSON report (default: {DEFAULT_OUTPUT.relative_to(ROOT)})", + ) + parser.add_argument( + "--entities", type=int, default=DEFAULT_ENTITIES, help="entities in the result-lane drawing" + ) + parser.add_argument( + "--turns", type=int, default=SESSION_TURNS, help="requests in the modelled session" + ) + parser.add_argument( + "--tokenizer", + choices=("ratio", "anthropic"), + default="ratio", + help="'ratio' (default, estimates) or 'anthropic' (real counts, needs credentials)", + ) + parser.add_argument( + "--model", default=DEFAULT_TOKENIZER_MODEL, help="model for --tokenizer anthropic" + ) + return parser + + +def main(argv: list[str] | None = None) -> int: + args = build_parser().parse_args(argv) + try: + counter = build_counter(args.tokenizer, model=args.model) + except (RuntimeError, ValueError) as exc: + # A refused tokenizer is a usage error, not a crash: say what is missing + # rather than printing a traceback over an unavailable dependency. + print(f"token_suite: {exc}", file=sys.stderr) + return 2 + report = asyncio.run(run_suite(entities=args.entities, turns=args.turns, counter=counter)) + rendered = json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + if args.out is not None: + args.out.parent.mkdir(parents=True, exist_ok=True) + args.out.write_text(rendered + "\n", encoding="utf-8", newline="\n") + if args.json: + print(rendered) + else: + print(summarize(report)) + if args.out is not None: + print(f"\nJSON report: {args.out}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/config.py b/config.py index a260e66..31c9eef 100644 --- a/config.py +++ b/config.py @@ -5,12 +5,48 @@ from __future__ import annotations import os from pathlib import Path +#: ProgID the live backend attaches to when ``CAD_PROGID`` is unset. The only +#: CAD this project is developed and tested against. +DEFAULT_CAD_PROGID = "AutoCAD.Application" + class Settings: """Server configuration loaded from environment variables.""" + @property + def backend(self) -> str: + """Which engine to run: ``ezdxf``, ``com`` or ``auto``. + + Read live rather than captured in ``__init__``, and deliberately + read-only. ``_make_backend`` used to read ``os.environ`` itself while + this attribute had no production reader at all, so + ``monkeypatch.setattr(settings, "backend", "ezdxf")`` looked like a + guard and was a no-op — on a machine with AutoCAD open that meant a test + attaching to the operator's live drawing. Late binding is what keeps + ``monkeypatch.setenv`` working for the call sites that pin the headless + backend correctly; the missing setter is what makes the broken spelling + fail loudly instead of silently. + """ + return os.environ.get("AUTOCAD_MCP_BACKEND", "auto").lower().strip() + + @property + def cad_progid(self) -> str: + """COM ProgID the live backend attaches to. + + Read live and deliberately read-only for the same reasons as + ``backend``: late binding keeps ``monkeypatch.setenv`` working, and the + missing setter makes a wrong spelling fail loudly rather than look like + a guard that does nothing. + + Stripped but **not** lowercased — the value is echoed back in error + messages and ``system_status``, where ``gstarcad.application`` reads + like a typo we introduced. Never validated against what is actually + registered on the machine: an unrecognised ProgID is not "invalid", it + is unverifiable by us, and only AutoCAD is tested. + """ + return os.environ.get("CAD_PROGID", "").strip() or DEFAULT_CAD_PROGID + def __init__(self): - self.backend: str = os.environ.get("AUTOCAD_MCP_BACKEND", "auto").lower().strip() self.log_level: str = os.environ.get("LOG_LEVEL", "INFO").upper().strip() raw_paths = os.environ.get("ALLOWED_PATHS", "").strip() @@ -37,18 +73,46 @@ class Settings: # otherwise block the single-thread STA executor forever. self.com_call_timeout: float = float(os.environ.get("COM_CALL_TIMEOUT", "60")) + # Per-call timeout for the headless ezdxf backend. Every ezdxf method + # runs under one document lock, so a single hung call (pathological DXF, + # a render that never returns) would otherwise hold that lock forever + # and deadlock every later tool call. Higher than com_call_timeout + # because matplotlib PDF/PNG rendering of a large drawing is legitimately + # slow. Set to 0 to disable the check. + self.ezdxf_call_timeout: float = float(os.environ.get("EZDXF_CALL_TIMEOUT", "120")) + + # How many undo steps the headless backend keeps. 0 = off, which is the + # default because it is not free: ezdxf has no journal, so a step is a + # full DXF snapshot. Measured — 10 entities 3.1 ms / 19 KB, 1000 + # entities 28 ms / 146 KB, 5000 entities 130 ms / 658 KB — and the cost + # is paid on every mutating call, so switching it on for a large drawing + # is a real trade the user should make deliberately rather than inherit. + self.ezdxf_undo_depth: int = max(0, int(os.environ.get("EZDXF_UNDO_DEPTH", "0"))) + # HTTP transport: refuse non-loopback bind unless explicitly opted in. self.allow_remote_http: bool = ( os.environ.get("ALLOW_REMOTE_HTTP", "false").lower().strip() == "true" ) self.mcp_auth_token: str = os.environ.get("MCP_AUTH_TOKEN", "").strip() - # Exposed tool profile: "full" (default, everything), "core" (hides the - # raw escape hatches and long-tail tools) or "lean" (a curated ~46-tool - # drafting/inspection core for clients with tight tool caps). Invalid - # values fall back to "full" with a warning at startup. + # Exposed tool profile: "full" (default, everything) or "lean" (a + # curated ~47-tool drafting/inspection core for clients with tight tool + # caps). Invalid values fall back to "full" with a warning at startup; + # so does the "core" profile removed in v1.5.0 (see + # server.REMOVED_TOOL_PROFILES). self.tool_profile: str = os.environ.get("TOOL_PROFILE", "full").lower().strip() + # Tool discovery: "off" (default) advertises the full catalog; "search" + # replaces it with a search tool plus a call_tool proxy (see + # discovery/transform.py and server._apply_discovery_mode). + # + # Default "off" deliberately. "search" is the intended v1.x default, + # but flipping it changes what every connected client sees on + # list_tools — a breaking wire-level change that needs its own + # migration note and README pass, neither of which ships in v1.5.0. + # Invalid values fall back to "off" with a warning at startup. + self.discovery_mode: str = os.environ.get("DISCOVERY_MODE", "off").lower().strip() + # Opt-in 3D solids (COM backend only). Disabled by default because the # headless backend cannot produce ACIS solids and live 3D modifies real # documents; enable deliberately. diff --git a/discovery/__init__.py b/discovery/__init__.py new file mode 100644 index 0000000..2a8134c --- /dev/null +++ b/discovery/__init__.py @@ -0,0 +1,7 @@ +"""Tool-discovery layer: searchable tool metadata and the search transform. + +Kept deliberately free of ``fastmcp`` and ``ezdxf`` imports at module scope so +the discovery data stays cheap to import and testable in isolation. +""" + +__all__: list[str] = [] diff --git a/discovery/aliases.py b/discovery/aliases.py new file mode 100644 index 0000000..f1f0020 --- /dev/null +++ b/discovery/aliases.py @@ -0,0 +1,1342 @@ +"""AutoCAD command / natural-language alias corpus for tool discovery. + +FastMCP's ``BM25SearchTransform`` builds its index from tool name, tool +description, parameter names and parameter descriptions only (see +``fastmcp/server/transforms/search/base.py::_extract_searchable_text``). Not one +of this server's ~131 tool descriptions mentions an AutoCAD command name, so a +drafter who searches the way drafters actually think -- ``BPOLY``, ``PEDIT``, +``WBLOCK``, ``QSELECT``, ``MATCHPROP`` -- scores df=0 and gets nothing back. +There are no synonyms either: "hole" never reaches ``entity_create_circle`` and +"bill of materials" reaches nothing at all. + +That is a *data* gap, not a tuning gap: no serializer and no BM25 parameter can +invent vocabulary that is absent from the index. This module supplies the +missing vocabulary as plain data so a search transform can fold it into the +indexed text. + +Field contract +-------------- +``acad`` + AutoCAD command name(s) a drafter would actually type for this operation -- + uppercase, no spaces, two characters or more (single-letter aliases like + ``L`` or ``C`` are omitted as pure noise). **Empty when there is genuinely + no AutoCAD equivalent.** A wrong alias actively misroutes a search, so + accuracy beats volume here: engineering meta-tools, the premium planning + and critique gates, and the transaction wrappers all ship an empty ``acad`` + on purpose. +``synonyms`` + Lowercase natural-language phrases a user would search for -- shop-floor + vocabulary ("hole", "bore", "round the corner", "parts list", "title + block"), not restatements of the tool name. + +Deliberately kept free of I/O, ``fastmcp``, ``ezdxf`` and config parsing so it +stays trivially importable and testable in isolation. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +__all__ = [ + "SHARED_ACAD_COMMANDS", + "TOOL_ALIASES", + "ToolAliases", + "alias_text", + "aliases_for", +] + + +@dataclass(frozen=True, slots=True) +class ToolAliases: + """Search vocabulary for a single registered tool.""" + + acad: tuple[str, ...] = () + synonyms: tuple[str, ...] = () + + +# AutoCAD commands that legitimately route to more than one tool. +# +# ARRAY, LAYER, LAYOUT, MEASUREGEOM, ZOOM -- option/dialog-driven umbrella +# commands in AutoCAD itself; this server splits each into discrete tools. +# SETVAR -- one command both reads and writes a system variable; the server +# splits read and write. +# ERASE, INSERT -- the server genuinely exposes one operation through two +# tools (single vs. batch delete; block insert vs. block-reference +# creation), so both are correct destinations. +# QSELECT -- AutoCAD's Quick Select dialog is both "filter by properties" +# (selection_filter) and "find things like this one" +# (entity_select_smart); a drafter typing it could mean either. +# +# Every other AutoCAD command must map to exactly one tool. +SHARED_ACAD_COMMANDS: frozenset[str] = frozenset( + { + "ARRAY", + "ERASE", + "INSERT", + "LAYER", + "LAYOUT", + "MEASUREGEOM", + "QSELECT", + "SETVAR", + "ZOOM", + } +) + + +TOOL_ALIASES: dict[str, ToolAliases] = { + # ── Analysis & query ──────────────────────────────────────────────────── + "analysis_bounding_box": ToolAliases( + synonyms=( + "bounding box", + "drawing extents", + "overall size", + "how big is the drawing", + "outer envelope", + ), + ), + "analysis_entity_stats": ToolAliases( + synonyms=( + "entity count", + "how many objects", + "drawing statistics", + "breakdown by type", + "object inventory", + ), + ), + "analysis_find_in_region": ToolAliases( + synonyms=( + "crossing window", + "what is in this area", + "entities inside a rectangle", + "window selection", + "objects in a region", + ), + ), + "analysis_layer_stats": ToolAliases( + synonyms=( + "layer statistics", + "entities per layer", + "which layers are used", + "layer usage report", + ), + ), + # AREA/MEASUREGEOM belong to the ENTITY tool: a drafter typing AREA picks + # objects on screen, they do not retype coordinates. Routing those commands + # at the points-based tool sent "what is the area of this polyline" to the + # one tool that cannot look at the polyline. + "analysis_measure_area": ToolAliases( + synonyms=( + "area of a polygon i will type out", + "area from coordinates", + "shoelace", + "area of a list of points", + ), + ), + "analysis_measure_entity": ToolAliases( + acad=("AREA", "MEASUREGEOM"), + synonyms=( + "area", + "surface area", + "how much area", + "square millimetres", + "polygon area", + "area of this entity", + "area of that polyline", + "perimeter", + "circumference", + "how long is this polyline", + "measure the part i just drew", + ), + ), + "analysis_measure_distance": ToolAliases( + acad=("DIST", "DI", "MEASUREGEOM"), + synonyms=( + "distance", + "how far apart", + "length between two points", + "measure a gap", + "spacing", + ), + ), + "analysis_select_by_layer": ToolAliases( + synonyms=( + "everything on a layer", + "entities on a layer", + "select by layer", + "layer contents", + ), + ), + "analysis_select_by_type": ToolAliases( + synonyms=( + "all the circles", + "all the lines", + "entities of one type", + "select by object type", + "filter by type", + ), + ), + # ── Batch execution ───────────────────────────────────────────────────── + # + # No ``acad``: AutoCAD's nearest equivalent is SCRIPT, which runs a file of + # command lines and maps to nothing this server exposes. Claiming it would + # misroute every SCRIPT search into a tool that cannot open a .scr file. + "cad_batch": ToolAliases( + synonyms=( + "run several tools at once", + "do it all in one call", + "chain tool calls", + "sequence of operations", + "one round trip", + "macro", + ), + ), + # ── Blocks ────────────────────────────────────────────────────────────── + "block_create_from_entities": ToolAliases( + acad=("BLOCK", "BMAKE", "WBLOCK"), + synonyms=( + "make a block", + "define a block", + "group entities into a symbol", + "save a selection as a block", + "reusable symbol", + ), + ), + "block_explode": ToolAliases( + acad=("EXPLODE",), + synonyms=( + "explode", + "break a block apart", + "ungroup", + "split a block into entities", + "flatten a symbol", + ), + ), + "block_find_references": ToolAliases( + acad=("BCOUNT",), + synonyms=( + "where is this block used", + "block instances", + "find the inserts", + "count block references", + ), + ), + "block_get_attributes": ToolAliases( + synonyms=( + "read block attributes", + "attribute values", + "title block fields", + "tag values", + ), + ), + "block_insert": ToolAliases( + acad=("INSERT",), + synonyms=( + "insert a block", + "place a symbol", + "stamp a block", + "add a block instance", + ), + ), + "block_list": ToolAliases( + synonyms=( + "list blocks", + "block definitions", + "what blocks exist", + "available symbols", + ), + ), + "block_set_attributes": ToolAliases( + acad=("EATTEDIT", "ATTEDIT"), + synonyms=( + "edit block attributes", + "fill in the title block", + "set attribute values", + "update the tags", + ), + ), + # ── Construction geometry ─────────────────────────────────────────────── + "construction_clear": ToolAliases( + synonyms=( + "delete the construction lines", + "remove the scaffolding", + "clean the construction layer", + "wipe the guides", + ), + ), + "construction_xline": ToolAliases( + acad=("XLINE", "XL"), + synonyms=( + "construction line", + "infinite line", + "guide line", + "reference axis", + "scaffolding line", + ), + ), + # ── GD&T ──────────────────────────────────────────────────────────────── + "datum_feature": ToolAliases( + synonyms=( + "datum", + "datum symbol", + "datum triangle", + "datum reference", + "reference feature", + ), + ), + "gd_frame": ToolAliases( + acad=("TOLERANCE",), + synonyms=( + "feature control frame", + "geometric tolerance", + "gd&t", + "flatness", + "perpendicularity", + "true position", + ), + ), + # ── Dimensions ────────────────────────────────────────────────────────── + "dimension_aligned": ToolAliases( + acad=("DIMALIGNED", "DAL"), + synonyms=( + "aligned dimension", + "true length dimension", + "dimension along a slope", + "slanted dimension", + ), + ), + "dimension_angular": ToolAliases( + acad=("DIMANGULAR", "DAN"), + synonyms=( + "angle dimension", + "angular dimension", + "measure an angle", + "included angle", + ), + ), + "dimension_auto": ToolAliases( + acad=("QDIM", "DIMCONTINUE", "DIMBASELINE", "DIMORDINATE"), + synonyms=( + "dimension everything", + "chain dimensions", + "baseline dimensions", + "ordinate dimensions", + "quick dimension", + ), + ), + "dimension_diameter": ToolAliases( + acad=("DIMDIAMETER", "DDI"), + synonyms=( + "diameter dimension", + "hole size", + "bore diameter", + "dimension a hole", + ), + ), + "dimension_linear": ToolAliases( + acad=("DIMLINEAR", "DLI"), + synonyms=( + "dimension", + "linear dimension", + "horizontal dimension", + "vertical dimension", + "size tolerance", + "h7 fit", + ), + ), + "dimension_radius": ToolAliases( + acad=("DIMRADIUS", "DRA"), + synonyms=( + "radius dimension", + "corner radius callout", + "dimension an arc", + "fillet radius", + ), + ), + # ── Drawing management ────────────────────────────────────────────────── + "drawing_apply_iso_layers": ToolAliases( + synonyms=( + "iso layers", + "standard layer set", + "set up drafting layers", + "layer standard", + "bootstrap the layers", + ), + ), + "drawing_audit": ToolAliases( + acad=("AUDIT",), + synonyms=( + "audit", + "fix drawing errors", + "integrity check", + "repair the file", + "corrupt drawing", + ), + ), + "drawing_close": ToolAliases( + acad=("CLOSE",), + synonyms=("close the drawing", "shut the file", "finish with this drawing"), + ), + "drawing_critique": ToolAliases( + synonyms=( + "review the drawing", + "quality review", + "what is wrong with this drawing", + "drafting mistakes", + "check before finalising", + ), + ), + "drawing_deliver": ToolAliases( + synonyms=( + "delivery bundle", + "package the drawing", + "hand off the drawing", + "final deliverable", + ), + ), + "drawing_export_dxf": ToolAliases( + acad=("DXFOUT",), + synonyms=("export dxf", "save as dxf", "interchange file", "dxf output"), + ), + "drawing_export_pdf": ToolAliases( + acad=("PLOT", "PRINT", "EXPORTPDF"), + synonyms=("print", "plot", "export pdf", "pdf output", "paper copy"), + ), + "drawing_finalize": ToolAliases( + synonyms=( + "finish the drawing", + "validate and save", + "final check", + "completion gate", + "the drawing is done", + ), + ), + "drawing_info": ToolAliases( + acad=("DWGPROPS", "STATUS"), + synonyms=( + "drawing info", + "file properties", + "drawing metadata", + "what is in this drawing", + ), + ), + "drawing_new": ToolAliases( + acad=("NEW", "QNEW"), + synonyms=("new drawing", "start a drawing", "blank sheet", "create a file"), + ), + "drawing_open": ToolAliases( + acad=("OPEN",), + synonyms=("open a drawing", "load a dwg", "read a dxf file", "open a file"), + ), + "drawing_plan": ToolAliases( + synonyms=( + "plan the drawing", + "drawing plan", + "decide what to draw", + "drawing intent", + ), + ), + "drawing_preflight": ToolAliases( + synonyms=( + "preflight", + "check the requirements", + "validate the brief", + "normalise the requirements", + ), + ), + "drawing_purge": ToolAliases( + acad=("PURGE", "PU"), + synonyms=( + "purge", + "remove unused layers", + "clean out unused blocks", + "slim the file", + ), + ), + "drawing_redo": ToolAliases( + acad=("REDO", "MREDO"), + synonyms=("redo", "reapply the change", "undo the undo"), + ), + "drawing_refine": ToolAliases( + acad=("OVERKILL",), + synonyms=( + "fix the issues", + "auto repair", + "clean up the drawing", + "remove duplicate entities", + "repair loop", + ), + ), + "drawing_save": ToolAliases( + acad=("QSAVE", "SAVE"), + synonyms=("save", "save the drawing", "write the file", "keep my changes"), + ), + "drawing_save_as": ToolAliases( + acad=("SAVEAS",), + synonyms=( + "save as", + "save a copy", + "write to a new file", + "save as a template", + ), + ), + "drawing_settings": ToolAliases( + acad=("UNITS", "DDUNITS", "OSNAP", "DSETTINGS", "LTSCALE", "DIMSCALE"), + synonyms=( + "drawing units", + "millimetres or inches", + "decimal precision", + "dimension scale", + "linetype scale", + "object snap settings", + ), + ), + "drawing_undo": ToolAliases( + acad=("UNDO",), + synonyms=("undo", "take that back", "revert the last change", "step back"), + ), + # ── Entity creation ───────────────────────────────────────────────────── + "entity_create_arc": ToolAliases( + acad=("ARC",), + synonyms=("arc", "curved segment", "part of a circle", "bend", "sweep"), + ), + "entity_create_block_ref": ToolAliases( + acad=("INSERT",), + synonyms=( + "block reference", + "place a block", + "insert a symbol instance", + "drop in a symbol", + ), + ), + "entity_create_circle": ToolAliases( + acad=("CIRCLE",), + synonyms=("circle", "hole", "bore", "round hole", "pitch circle", "disc"), + ), + "entity_create_ellipse": ToolAliases( + acad=("ELLIPSE", "EL"), + synonyms=("ellipse", "oval", "elliptical shape", "squashed circle"), + ), + "entity_create_hatch": ToolAliases( + acad=("HATCH", "BHATCH"), + synonyms=( + "hatch", + "fill a region", + "section hatching", + "crosshatch", + "shade an area", + "ansi31", + ), + ), + "entity_create_line": ToolAliases( + acad=("LINE",), + synonyms=("line", "straight segment", "edge", "connect two points"), + ), + "entity_create_mtext": ToolAliases( + acad=("MTEXT",), + synonyms=( + "paragraph text", + "multiline text", + "note block", + "wrapped text", + "general notes", + ), + ), + "entity_create_point": ToolAliases( + acad=("POINT", "PO"), + synonyms=("point", "node", "marker", "dot", "reference point"), + ), + "entity_create_polyline": ToolAliases( + # BOUNDARY/BPOLY moved to boundary_trace in v1.5.0 — they were parked + # here only because nothing traced a boundary yet. + acad=("PLINE", "PL"), + synonyms=( + "polyline", + "connected segments", + "outline", + "closed profile", + "boundary", + "contour", + ), + ), + "entity_create_rectangle": ToolAliases( + acad=("RECTANG", "REC"), + synonyms=("rectangle", "box", "square", "plate outline", "four sided shape"), + ), + "entity_create_spline": ToolAliases( + acad=("SPLINE", "SPL"), + synonyms=("spline", "smooth curve", "freeform curve", "nurbs", "fit points"), + ), + "entity_create_table": ToolAliases( + acad=("TABLE", "TB"), + synonyms=( + "table", + "parts list", + "bill of materials", + "bom", + "schedule", + "revision table", + ), + ), + "entity_create_text": ToolAliases( + acad=("TEXT", "DTEXT"), + synonyms=("text", "label", "single line text", "caption", "annotate"), + ), + "entity_batch_create": ToolAliases( + synonyms=( + "create many entities", + "bulk create", + "draw several things at once", + "batch draw", + ), + ), + "entity_batch_modify": ToolAliases( + synonyms=( + "bulk edit", + "modify many entities", + "batch changes", + "apply edits at once", + ), + ), + # ── Entity modification ───────────────────────────────────────────────── + "entity_array_polar": ToolAliases( + acad=("ARRAYPOLAR", "ARRAY"), + synonyms=( + "polar array", + "circular pattern", + "bolt circle", + "repeat around a centre", + "radial pattern", + ), + ), + "entity_array_rectangular": ToolAliases( + acad=("ARRAYRECT", "ARRAY"), + synonyms=( + "rectangular array", + "grid of copies", + "rows and columns", + "repeat in a grid", + ), + ), + "entity_chamfer": ToolAliases( + acad=("CHAMFER", "CHA"), + synonyms=( + "chamfer", + "bevel a corner", + "break the edge", + "45 degree corner", + "cut the corner off", + ), + ), + "entity_copy": ToolAliases( + acad=("COPY", "CO", "CP"), + synonyms=("copy", "duplicate", "clone an entity", "make another one"), + ), + "entity_delete": ToolAliases( + acad=("ERASE",), + synonyms=("delete", "erase", "remove an entity", "get rid of it"), + ), + "entity_delete_many": ToolAliases( + acad=("ERASE",), + synonyms=( + "delete several", + "erase many", + "bulk delete", + "remove a list of entities", + ), + ), + "entity_edit_geometry": ToolAliases( + acad=("LENGTHEN", "PEDIT"), + synonyms=( + "change the radius", + "move an endpoint", + "resize a circle", + "edit geometry in place", + "adjust the arc angles", + ), + ), + "entity_edit_text": ToolAliases( + acad=("DDEDIT", "TEXTEDIT"), + synonyms=( + "change the text", + "rename a label", + "edit the wording", + "fix a typo", + "retitle", + ), + ), + "entity_extend": ToolAliases( + acad=("EXTEND", "EX"), + synonyms=( + "extend", + "lengthen to meet", + "stretch to the boundary", + "reach the other line", + ), + ), + "entity_fillet": ToolAliases( + acad=("FILLET",), + synonyms=( + "fillet", + "round the corner", + "rounded corner", + "radius the corner", + "tangent arc corner", + ), + ), + "entity_mirror": ToolAliases( + acad=("MIRROR", "MI"), + synonyms=("mirror", "flip", "reflect", "symmetry", "mirrored copy"), + ), + "entity_move": ToolAliases( + acad=("MOVE",), + synonyms=("move", "shift", "translate", "relocate", "nudge"), + ), + "entity_offset": ToolAliases( + acad=("OFFSET",), + synonyms=( + "offset", + "parallel copy", + "wall thickness", + "concentric copy", + "inset", + ), + ), + "entity_rotate": ToolAliases( + acad=("ROTATE", "RO"), + synonyms=("rotate", "turn", "spin", "reorient", "set the angle"), + ), + "entity_scale": ToolAliases( + acad=("SCALE", "SC"), + synonyms=("scale", "resize", "enlarge", "shrink", "make it bigger"), + ), + "entity_set_properties": ToolAliases( + acad=("PROPERTIES", "CHPROP", "CHANGE", "MATCHPROP", "PR"), + synonyms=( + "change the colour", + "move to another layer", + "set the linetype", + "set the lineweight", + "match properties", + "hide an entity", + ), + ), + "entity_trim": ToolAliases( + acad=("TRIM", "TR"), + synonyms=( + "trim", + "cut back", + "clean up the overshoot", + "cut to the intersection", + "remove the excess", + ), + ), + # ── Entity query & selection ──────────────────────────────────────────── + "entity_get": ToolAliases( + acad=("LI",), # LIST moved to analysis_list_properties, which is the real dump + synonyms=( + "entity properties", + "what is this object", + "inspect an entity", + "details for a handle", + ), + ), + "entity_list": ToolAliases( + synonyms=( + "list the entities", + "what is in the drawing", + "browse the objects", + "all the handles", + ), + ), + "entity_select_smart": ToolAliases( + acad=("QSELECT", "FILTER", "SELECTSIMILAR"), + synonyms=( + "select by property", + "find every matching entity", + "smart selection", + "quick select", + "pick everything that is", + ), + ), + "selection_get": ToolAliases( + synonyms=( + "current selection", + "what the user selected", + "picked entities", + "highlighted objects", + "pickfirst", + ), + ), + # ── Engineering primitives ────────────────────────────────────────────── + "gear_draw_helical_front_view": ToolAliases( + synonyms=( + "helical gear", + "gear front view", + "involute teeth", + "toothed wheel", + "helix gear", + ), + ), + "gear_draw_section_aa": ToolAliases( + synonyms=( + "gear section", + "section a-a", + "cross section of a gear", + "cut view of a gear", + ), + ), + "gear_draw_spur_front_view": ToolAliases( + synonyms=( + "spur gear", + "straight tooth gear", + "involute gear", + "gear front view", + ), + ), + "keyway_draw_keyed_bore": ToolAliases( + synonyms=( + "keyway", + "keyed bore", + "key slot", + "shaft keyway", + "din 6885 key", + "hub bore", + ), + ), + "keyway_draw_section": ToolAliases( + synonyms=( + "keyway section", + "keyed bore side view", + "key slot cross section", + ), + ), + "titleblock_apply_iso_a3": ToolAliases( + synonyms=( + "title block", + "drawing frame", + "sheet border", + "iso 7200", + "a3 sheet", + "drawing header", + ), + ), + # ── Layers & linetypes ────────────────────────────────────────────────── + "layer_create": ToolAliases( + acad=("LAYER", "LA"), + synonyms=("new layer", "add a layer", "create a layer", "layer with a colour"), + ), + "layer_delete": ToolAliases( + acad=("LAYDEL",), + synonyms=("delete a layer", "remove a layer", "get rid of a layer"), + ), + "layer_freeze": ToolAliases( + acad=("LAYFRZ",), + synonyms=("freeze a layer", "freeze", "stop regenerating a layer"), + ), + "layer_hide": ToolAliases( + acad=("LAYOFF",), + synonyms=("turn a layer off", "hide a layer", "make a layer invisible"), + ), + "layer_isolate": ToolAliases( + acad=("LAYISO",), + synonyms=( + "isolate a layer", + "show only one layer", + "hide everything else", + "layer isolation", + ), + ), + "layer_list": ToolAliases( + acad=("LAYER",), + synonyms=("list the layers", "what layers exist", "layer table", "layer overview"), + ), + "layer_lock": ToolAliases( + acad=("LAYLCK",), + synonyms=("lock a layer", "protect a layer", "make a layer read only"), + ), + "layer_modify": ToolAliases( + acad=("LAYER",), + synonyms=( + "change a layer colour", + "edit a layer", + "set the layer linetype", + "set the layer lineweight", + ), + ), + "layer_set_current": ToolAliases( + acad=("CLAYER", "LAYMCUR"), + synonyms=( + "current layer", + "draw on this layer", + "set the active layer", + "switch layer", + ), + ), + "layer_show": ToolAliases( + acad=("LAYON",), + synonyms=("turn a layer on", "show a layer", "unhide a layer"), + ), + "layer_thaw": ToolAliases( + acad=("LAYTHW",), + synonyms=("thaw a layer", "unfreeze a layer", "bring a layer back"), + ), + "layer_unlock": ToolAliases( + acad=("LAYULK",), + synonyms=("unlock a layer", "allow editing on a layer"), + ), + "linetype_list": ToolAliases( + synonyms=( + "list the linetypes", + "which linetypes are loaded", + "available dash patterns", + ), + ), + "linetype_load": ToolAliases( + acad=("LINETYPE", "LTYPE", "LT"), + synonyms=( + "load a linetype", + "dashed line", + "centre line linetype", + "hidden linetype", + "phantom linetype", + ), + ), + "template_apply_layers": ToolAliases( + acad=("LAYTRANS",), + synonyms=( + "apply a layer template", + "standard layers", + "architectural layers", + "mechanical layers", + "translate the layers", + ), + ), + "template_list": ToolAliases( + synonyms=( + "available templates", + "what layer templates exist", + "list the templates", + ), + ), + # ── Layouts & paper space ─────────────────────────────────────────────── + "layout_create": ToolAliases( + acad=("LAYOUT",), + synonyms=("new layout", "paper space tab", "create a sheet", "add a layout"), + ), + "layout_list": ToolAliases( + synonyms=( + "list the layouts", + "what sheets exist", + "layout tabs", + "model and paper space", + ), + ), + "layout_set_current": ToolAliases( + acad=("LAYOUT", "PSPACE"), + synonyms=( + "switch to a layout", + "activate a sheet", + "go to paper space", + "open a layout tab", + ), + ), + "selection_window": ToolAliases( + acad=("SSGET",), + synonyms=( + "select in a box", + "window selection", + "crossing selection", + "pick everything in this rectangle", + ), + ), + "selection_polygon": ToolAliases( + acad=("WPOLYGON",), + synonyms=( + "select inside a polygon", + "lasso selection", + "pick everything in this shape", + ), + ), + "selection_filter": ToolAliases( + acad=("QSELECT",), + synonyms=( + "select by properties", + "find all the red circles", + "everything on this layer", + "quick select", + ), + ), + "boundary_trace": ToolAliases( + acad=("BOUNDARY", "BPOLY"), + synonyms=( + "trace the boundary", + "outline this area", + "closed polyline around this point", + "pick an internal point", + ), + ), + "boundary_from_entities": ToolAliases( + synonyms=( + "join these into a closed shape", + "make a loop from these lines", + "chain entities into a boundary", + ), + ), + "analysis_list_properties": ToolAliases( + # LIST is the property *dump*; entity_get keeps LI, and PROPERTIES + # stays with the tool that writes them. + acad=("LIST",), + synonyms=( + "list the properties", + "show me everything about this entity", + "dump the dxf attributes", + "what are this object's properties", + ), + ), + "hatch_set_gradient": ToolAliases( + acad=("GRADIENT",), + synonyms=( + "gradient fill", + "fade from one colour to another", + "colour ramp fill", + ), + ), + "hatch_edit": ToolAliases( + acad=("HATCHEDIT",), + synonyms=( + "change the hatch pattern", + "rescale a hatch", + "island detection style", + "edit a hatch", + ), + ), + "hatch_add_boundary": ToolAliases( + synonyms=( + "add a boundary to a hatch", + "hatch boundary with arcs", + "another island in the hatch", + ), + ), + "entity_create_wipeout": ToolAliases( + acad=("WIPEOUT",), + synonyms=( + "wipeout", + "mask what is behind", + "hide the drawing underneath", + "white out an area", + ), + ), + "entity_create_revcloud": ToolAliases( + acad=("REVCLOUD",), + synonyms=( + "revision cloud", + "cloud this area", + "mark a revision", + "circle the change", + ), + ), + "text_set_background": ToolAliases( + acad=("BACKGROUNDMASK",), + synonyms=( + "text background mask", + "make the text readable over hatch", + "opaque box behind text", + "background fill for mtext", + ), + ), + "text_find_replace": ToolAliases( + acad=("FIND",), + synonyms=( + "find and replace text", + "replace text everywhere", + "search and replace", + "change all the labels", + ), + ), + "layout_delete": ToolAliases( + acad=("LAYOUT",), + synonyms=( + "delete a layout", + "remove a sheet tab", + "get rid of a layout", + "drop a paper space tab", + ), + ), + "layout_rename": ToolAliases( + acad=("LAYOUT", "RENAME"), + synonyms=( + "rename a layout", + "rename a sheet tab", + "change the layout name", + "retitle a sheet", + ), + ), + "layout_copy": ToolAliases( + acad=("LAYOUT",), + synonyms=( + "copy a layout", + "duplicate a sheet", + "clone a layout tab", + "another sheet like this one", + ), + ), + "viewport_create": ToolAliases( + acad=("MVIEW", "MV", "VPORTS"), + synonyms=( + "viewport", + "scaled window on a sheet", + "model view on paper", + "paper space window", + ), + ), + # No AutoCAD command lists viewports: the drafter reads them off the sheet + # or the Properties palette. Synonyms only, like layout_list. + "viewport_list": ToolAliases( + synonyms=( + "list the viewports", + "what viewports are on this sheet", + "viewport scales", + "show the windows on the layout", + ), + ), + "viewport_set_scale": ToolAliases( + # The classic way to scale a viewport is ZOOM nXP from inside it. + acad=("ZOOM",), + synonyms=( + "set the viewport scale", + "make this viewport 1:50", + "rescale a viewport", + "zoom xp", + ), + ), + "viewport_lock": ToolAliases( + synonyms=( + "lock a viewport", + "unlock a viewport", + "stop the viewport scale changing", + "display locked", + ), + ), + "viewport_delete": ToolAliases( + synonyms=( + "delete a viewport", + "remove a viewport", + "get rid of the window on the sheet", + ), + ), + "entity_change_space": ToolAliases( + acad=("CHSPACE",), + synonyms=( + "change space", + "move to paper space", + "move to model space", + "move this onto the sheet", + ), + ), + # ── Leaders ───────────────────────────────────────────────────────────── + "leader_create_mleader": ToolAliases( + acad=("MLEADER", "MLD", "QLEADER", "LEADER"), + synonyms=( + "leader", + "callout", + "arrow with a note", + "pointer note", + "balloon", + ), + ), + # ── Snap points ───────────────────────────────────────────────────────── + "point_from_snap": ToolAliases( + synonyms=( + "endpoint", + "midpoint", + "centre point", + "quadrant", + "perpendicular foot", + "object snap", + "osnap", + "exact coordinate", + ), + ), + "point_intersection": ToolAliases( + synonyms=( + "intersection", + "where two lines cross", + "crossing point", + "meeting point", + ), + ), + "point_tangent": ToolAliases( + synonyms=( + "tangent point", + "tangency", + "touch point on a circle", + "tangent from a point", + ), + ), + # ── 3D solids ─────────────────────────────────────────────────────────── + "solid_boolean": ToolAliases( + acad=("UNION", "SUBTRACT", "INTERSECT"), + synonyms=( + "boolean", + "union of solids", + "subtract a solid", + "cut a pocket", + "combine solids", + ), + ), + "solid_box": ToolAliases( + acad=("BOX",), + synonyms=("3d box", "cuboid", "solid block", "rectangular solid"), + ), + "solid_cylinder": ToolAliases( + acad=("CYLINDER",), + synonyms=("3d cylinder", "solid shaft", "round solid", "pin"), + ), + "solid_extrude": ToolAliases( + acad=("EXTRUDE",), + synonyms=( + "extrude", + "pull a profile into 3d", + "give it thickness", + "solid from a profile", + ), + ), + "solid_revolve": ToolAliases( + acad=("REVOLVE",), + synonyms=( + "revolve", + "turn a profile around an axis", + "lathe", + "solid of revolution", + ), + ), + # ── System ────────────────────────────────────────────────────────────── + "system_about": ToolAliases( + acad=("ABOUT",), + synonyms=( + "about", + "what can this server do", + "capabilities overview", + "version information", + ), + ), + "system_capabilities": ToolAliases( + synonyms=( + "what is supported", + "backend capabilities", + "feature support", + "can it do this", + ), + ), + "system_get_variable": ToolAliases( + acad=("SETVAR",), + synonyms=( + "read a system variable", + "get a sysvar", + "current variable value", + ), + ), + "system_run_command": ToolAliases( + synonyms=( + "run an autocad command", + "command line", + "send a command string", + "macro", + "raw command", + ), + ), + "system_run_lisp": ToolAliases( + synonyms=("autolisp", "lisp", "run a lisp expression", "vlisp", "script it"), + ), + "system_set_variable": ToolAliases( + acad=("SETVAR",), + synonyms=( + "set a system variable", + "change a sysvar", + "configure an autocad variable", + ), + ), + "system_status": ToolAliases( + synonyms=( + "server status", + "is autocad connected", + "backend status", + "health check", + ), + ), + # ── Transactions ──────────────────────────────────────────────────────── + "transaction_begin": ToolAliases( + synonyms=( + "start a transaction", + "undo mark", + "checkpoint", + "savepoint", + "begin a safe edit", + ), + ), + "transaction_commit": ToolAliases( + synonyms=("commit", "keep the changes", "end the transaction", "accept the edits"), + ), + "transaction_rollback": ToolAliases( + synonyms=( + "rollback", + "discard the changes", + "revert the transaction", + "undo back to the checkpoint", + ), + ), + # ── Validation ────────────────────────────────────────────────────────── + "validation_check": ToolAliases( + synonyms=( + "quality check", + "find duplicates", + "empty layers", + "zero length lines", + "sanity check the drawing", + ), + ), + # ── View & screenshot ─────────────────────────────────────────────────── + "view_screenshot": ToolAliases( + synonyms=( + "screenshot", + "picture of the drawing", + "render an image", + "preview", + "what does it look like", + ), + ), + "view_zoom_and_screenshot": ToolAliases( + acad=("ZOOM",), + synonyms=( + "show me the drawing", + "zoom then capture", + "fit and screenshot", + "visual check", + ), + ), + "view_zoom_extents": ToolAliases( + acad=("ZOOM",), + synonyms=("zoom to fit", "fit the drawing on screen", "see everything"), + ), + "view_zoom_window": ToolAliases( + acad=("ZOOM",), + synonyms=("zoom into a region", "close up of an area", "magnify a rectangle"), + ), +} + + +def aliases_for(tool_name: str) -> ToolAliases | None: + """Return the alias record for ``tool_name``, or ``None`` if it has none.""" + return TOOL_ALIASES.get(tool_name) + + +def alias_text(tool_name: str) -> str: + """Return the alias vocabulary for ``tool_name`` as one whitespace-joined string. + + Suitable for appending to the text a search index builds from a tool. Returns + an empty string for unknown tools so callers can concatenate unconditionally. + """ + record = TOOL_ALIASES.get(tool_name) + if record is None: + return "" + return " ".join((*record.acad, *record.synonyms)) diff --git a/discovery/serialize.py b/discovery/serialize.py new file mode 100644 index 0000000..cdb24d2 --- /dev/null +++ b/discovery/serialize.py @@ -0,0 +1,114 @@ +"""Compact one-line cards for tool-search results. + +Passed to a search transform as ``search_result_serializer``. FastMCP's default +(``serialize_tools_for_output_json``) re-emits each hit's full ``list_tools`` +entry, JSON Schema and all — for this server that is roughly 1.5 kB per hit, +and a five-hit answer costs more than the crowded catalog the search was meant +to replace. + +One line per hit:: + + entity_fillet (write) | Round a corner between two entities. | req: handle_a, handle_b, radius | acad: FILLET, F + +``req:`` lists *required* parameters only. Optional parameters and types are +deliberately absent — the honest cost of this format is that a caller must +either already know the optional arguments or read the error from a failed +call. That trade is worth it at five hits per search; it would not be at one. + +Scope note: the compact payload is a real saving but it is **not** what makes +this discovery layer better than stock. Stock BM25 with this same serializer +gets the same size win. The differentiators are the alias corpus (which lets a +query find anything at all) and the risk filter — not bytes. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from fastmcp.tools.base import Tool + +from discovery.aliases import aliases_for + +__all__ = ["MAX_SUMMARY_CHARS", "serialize_search_results"] + +# Long enough for every first docstring line in the live catalog (measured max +# 96 characters), short enough that a rambling one cannot dominate the payload. +MAX_SUMMARY_CHARS = 110 + +# Beyond three, AutoCAD command names stop disambiguating and start padding. +MAX_ACAD_COMMANDS = 3 + +EMPTY_RESULT = "No tools matched the query." + + +def _risk(tool: Tool) -> str: + """Same derivation as ``discovery.transform.tool_risk``, inlined. + + Imported the other way round would make ``discovery.serialize`` depend on + the transform, and the transform already depends on this module for its + default serializer. + """ + annotations = tool.annotations + if annotations is not None: + if annotations.destructiveHint: + return "destructive" + if annotations.readOnlyHint: + return "read" + return "write" + + +def _truncate(text: str) -> str: + if len(text) <= MAX_SUMMARY_CHARS: + return text + clipped = text[:MAX_SUMMARY_CHARS].rsplit(" ", 1)[0].rstrip(" ,;:.") + return f"{clipped}..." + + +def _summary(tool: Tool) -> str: + """One line describing the tool. + + Prefers the hand-written ``@cad_tool`` summary in the MCP ``meta`` channel; + falls back to the first line of the docstring, which for this server is + already written as a standalone sentence. + """ + cad = (tool.meta or {}).get("cad") + if isinstance(cad, dict) and cad.get("summary"): + return _truncate(str(cad["summary"]).strip()) + for line in (tool.description or "").strip().splitlines(): + if line.strip(): + return _truncate(line.strip()) + return "(no description)" + + +def _required(tool: Tool) -> str: + schema = tool.parameters or {} + required = schema.get("required") + if not isinstance(required, list) or not required: + return "(none)" + return ", ".join(str(name) for name in required) + + +def _acad(tool: Tool) -> str: + record = aliases_for(tool.name) + if record is None or not record.acad: + return "" + return ", ".join(record.acad[:MAX_ACAD_COMMANDS]) + + +def _card(tool: Tool) -> str: + parts = [ + f"{tool.name} ({_risk(tool)})", + _summary(tool), + f"req: {_required(tool)}", + ] + acad = _acad(tool) + if acad: + parts.append(f"acad: {acad}") + return " | ".join(parts) + + +def serialize_search_results(tools: Sequence[Tool]) -> str: + """Render search hits as one compact card per line.""" + if not tools: + return EMPTY_RESULT + return "\n".join(_card(tool) for tool in tools) diff --git a/discovery/transform.py b/discovery/transform.py new file mode 100644 index 0000000..60cc075 --- /dev/null +++ b/discovery/transform.py @@ -0,0 +1,402 @@ +"""Tool-search transform that speaks AutoCAD. + +Subclasses FastMCP's :class:`BM25SearchTransform` rather than replacing it, so +the ``call_tool`` proxy, the auth-filtered catalog accessor +(``_get_visible_tools``) and the re-entrancy bypass all come for free. Two +things are added on top, and only two: + +**1. Vocabulary.** Stock BM25 indexes tool name + description + parameter names ++ parameter descriptions. Measured against this server's live catalog, that +leaves ``BPOLY``, ``PEDIT``, ``WBLOCK``, ``OVERKILL``, ``LAYTRANS``, +``MATCHPROP`` and ``QSELECT`` at df=0 — a search for any of them returns +nothing at all. :func:`discovery.aliases.alias_text` supplies the missing +AutoCAD command names and shop-floor synonyms; this module folds them into the +indexed document. (``FILLET`` is *not* in that list: it is already indexed, +because the tool is literally named ``entity_fillet``.) + +**2. Two additive boosts**, so an exact alias match outranks an incidental +description word. See :data:`ACAD_MATCH_BOOST` / :data:`SYNONYM_MATCH_BOOST`. + +BM25 itself is not reimplemented: the index object the base class already +built is reused, only with different documents. +""" + +from __future__ import annotations + +import hashlib +import re +from collections.abc import Sequence +from dataclasses import dataclass +from typing import Annotated, Any, Literal + +from fastmcp.server.context import Context +from fastmcp.server.transforms.search import BM25SearchTransform +from fastmcp.server.transforms.search.base import SearchResultSerializer +from fastmcp.server.transforms.search.base import ( + _extract_searchable_text as _stock_searchable_text, +) +from fastmcp.tools.base import Tool +from pydantic import Field + +from discovery.aliases import alias_text, aliases_for +from discovery.serialize import serialize_search_results + +__all__ = [ + "ACAD_MATCH_BOOST", + "MAX_SEARCH_LIMIT", + "RISK_CEILINGS", + "SYNONYM_MATCH_BOOST", + "CadSearchTransform", + "SearchOutcome", + "tool_risk", +] + + +# --------------------------------------------------------------------------- +# Scoring +# --------------------------------------------------------------------------- +# +# ``_BM25Index.query()`` hands back a *ranking*, not scores, so the two signals +# are fused on a rank scale instead of a raw-score scale: BM25's nth hit is +# worth ``1 / (1 + n)``. That makes the boosts below trivially explainable in +# the only units that matter here — positions. +# +# ACAD_MATCH_BOOST the query names an AutoCAD command this tool owns, +# *written as a command* — see _names_a_command below. +# Worth exactly as much as being BM25's top hit, so a +# command match pulls a tool past any number of tools +# that merely share a description word. +# SYNONYM_MATCH_BOOST the query contains one of the tool's synonym phrases +# verbatim ("bill of materials", "round the corner"). +# Worth BM25's second position: strong, but it loses to +# an outright command match. +# +# That is the whole scorer. Both boosts are flat and apply at most once, so a +# tool with a long synonym list cannot out-shout a tool with a short one. +ACAD_MATCH_BOOST = 1.0 +SYNONYM_MATCH_BOOST = 0.5 + +_WORD_RE = re.compile(r"[a-z0-9]+") +_RAW_WORD_RE = re.compile(r"[A-Za-z0-9]+") + +# Dropped from both sides before phrase matching, so the synonym "delete a +# layer" still matches "delete the layer called GEOMETRY". Articles only — +# a general stop-word list would start matching phrases that mean other things. +_ARTICLES = frozenset({"a", "an", "the"}) + + +def _words(text: str) -> list[str]: + """Lowercase word list. Unlike BM25's tokenizer, single characters survive. + + ``_tokenize`` drops ``len <= 1`` tokens, which is right for an inverted + index and wrong for phrase matching: dropping the "a" out of "round the + corner" would silently change which phrases can match. + """ + return _WORD_RE.findall(text.lower()) + + +def _phrase_words(text: str) -> list[str]: + return [word for word in _words(text) if word not in _ARTICLES] + + +def _names_a_command(query: str, command: str) -> bool: + """True when ``query`` uses ``command`` as a command, not as English. + + Several AutoCAD command names are also ordinary nouns — LAYER, TABLE, LINE, + POINT, TEXT, BOX, ARRAY. Boosting on a bare lowercase "layer" put + ``layer_create`` / ``layer_modify`` above ``layer_delete`` for "delete the + layer called GEOMETRY", which is exactly backwards. A drafter reaching for + the command types it in capitals, or types nothing else: + + "BPOLY", "trace it with BPOLY" -> command + "bpoly" -> command (nothing else in the query) + "delete the layer ..." -> English; BM25 handles it + """ + words = _words(query) + if len(words) == 1 and words[0] == command.lower(): + return True + return command in _RAW_WORD_RE.findall(query) + + +def _contains_phrase(haystack: Sequence[str], needle: Sequence[str]) -> bool: + """True when ``needle`` appears as a contiguous run of words in ``haystack``. + + Word-level rather than substring, so the synonym "line" does not match + "polyline" or "linear". + """ + n = len(needle) + if not n or n > len(haystack): + return False + first = needle[0] + for i, word in enumerate(haystack): + if word == first and list(haystack[i : i + n]) == list(needle): + return True + return False + + +# --------------------------------------------------------------------------- +# Risk +# --------------------------------------------------------------------------- +# +# Derived from the MCP annotations each tool already carries, so there is no +# second risk table to drift out of step with the registrations. The ceiling a +# caller may ask for, most permissive first: +# +# any everything (default — a search must not quietly narrow itself) +# write hide tools that delete or overwrite (entity_delete_many, layer_delete, +# transaction_rollback, drawing_finalize, ...) +# read only read-only query tools — the right ceiling for "how many ...?" +RISK_CEILINGS = ("any", "write", "read") + +MAX_SEARCH_LIMIT = 25 + + +def tool_risk(tool: Tool) -> str: + """``"read"`` | ``"write"`` | ``"destructive"`` for one tool. + + ``destructiveHint`` wins over ``readOnlyHint``: no tool in this catalog + sets both, and if one ever did, the safe reading is the destructive one. + Tools that annotate neither are treated as ``"write"`` — the honest default + for an unannotated tool on a server whose job is mutating drawings. + """ + annotations = tool.annotations + if annotations is not None: + if annotations.destructiveHint: + return "destructive" + if annotations.readOnlyHint: + return "read" + return "write" + + +def _risk_allowed(ceiling: str, level: str) -> bool: + if ceiling == "any": + return True + if ceiling == "write": + return level != "destructive" + return level == "read" + + +@dataclass(frozen=True, slots=True) +class SearchOutcome: + """One search: the tools shown, plus what the risk ceiling took away. + + ``hidden`` holds only the tools the caller would otherwise have *seen* — + blocked hits inside the unfiltered top-``limit`` — not every blocked tool + in the catalog. That keeps the notice a fact about this answer. + """ + + hits: tuple[Tool, ...] = () + hidden: tuple[Tool, ...] = () + risk: str = "any" + + def notice(self) -> str: + """One line naming what the risk ceiling removed, or ``""``. + + A filter that silently halves a mixed intent ("list the layers then + delete GEOMETRY") is worse than no filter: the caller cannot tell the + difference between "no such tool" and "not shown to you". + """ + if not self.hidden: + return "" + names = ", ".join(tool.name for tool in self.hidden) + return ( + f"Risk filter: {len(self.hidden)} higher-risk match(es) hidden by " + f'risk="{self.risk}" ({names}). Re-run with risk="any" to include them.' + ) + + +class CadSearchTransform(BM25SearchTransform): + """BM25 tool search with the AutoCAD alias corpus folded into the index. + + Args: + max_results: Default number of tools returned per search. + bm25_b: BM25 length-normalisation, left at the 0.75 textbook default — + the value every measured number in this module was obtained with. + Lowering it is defensible in principle (these "documents" are + metadata records of deliberately unequal verbosity, and a tool with + six synonyms is not a broader tool than one with two), but it was + not what shipped, so re-measure the golden set before changing it. + """ + + def __init__( + self, + *, + max_results: int = 5, + bm25_b: float = 0.75, + always_visible: list[str] | None = None, + search_tool_name: str = "search_tools", + call_tool_name: str = "call_tool", + search_result_serializer: SearchResultSerializer | None = None, + ) -> None: + super().__init__( + max_results=max_results, + always_visible=always_visible, + search_tool_name=search_tool_name, + call_tool_name=call_tool_name, + search_result_serializer=search_result_serializer or serialize_search_results, + ) + self._bm25_k1 = self._index.k1 + self._bm25_b = bm25_b + + # ------------------------------------------------------------------ + # Indexed document + # ------------------------------------------------------------------ + + def _extract_searchable_text(self, tool: Tool) -> str: + """Stock searchable text plus this tool's alias vocabulary. + + The narrowest available hook: upstream's ``_extract_searchable_text`` + is a module-level function, not a method, so it cannot be overridden in + place — it is called and extended instead. Tools with no alias record + (anything registered outside this server) keep the stock text + unchanged. + """ + extra = alias_text(tool.name) + stock = _stock_searchable_text(tool) + return f"{stock} {extra}" if extra else stock + + # ------------------------------------------------------------------ + # Index lifecycle + # ------------------------------------------------------------------ + + def _ensure_index(self, tools: Sequence[Tool]) -> None: + """Rebuild the index when the catalog's indexed text changes. + + The hash is order-sensitive on purpose: results are mapped back to + tools positionally, so a reordered catalog with identical text is still + a different index. + + No lock: this and its only caller (:meth:`rank`) are synchronous and + await nothing, so two concurrent searches on one event loop cannot + interleave a rebuild with a query. + """ + documents = [self._extract_searchable_text(t) for t in tools] + digest = hashlib.sha256("\x00".join(documents).encode()).hexdigest() + if digest == self._last_hash: + return + # Built via type() rather than importing the private _BM25Index name: + # the base class already owns an instance, and reusing its class keeps + # this from depending on a private module path. + index = type(self._index)(self._bm25_k1, self._bm25_b) + index.build(documents) + self._index = index + self._indexed_tools = tuple(tools) + self._last_hash = digest + + # ------------------------------------------------------------------ + # Ranking + # ------------------------------------------------------------------ + + def _alias_boost(self, tool: Tool, query: str, phrase_words: Sequence[str]) -> float: + record = aliases_for(tool.name) + if record is None: + return 0.0 + boost = 0.0 + if any(_names_a_command(query, command) for command in record.acad): + boost += ACAD_MATCH_BOOST + if any(_contains_phrase(phrase_words, _phrase_words(phrase)) for phrase in record.synonyms): + boost += SYNONYM_MATCH_BOOST + return boost + + def rank( + self, + tools: Sequence[Tool], + query: str, + *, + limit: int | None = None, + risk: str = "any", + ) -> SearchOutcome: + """Rank ``tools`` against ``query``. Pure — no Context, no I/O.""" + if risk not in RISK_CEILINGS: + raise ValueError(f"risk must be one of {RISK_CEILINGS}, got {risk!r}") + cutoff = self._max_results if limit is None else max(1, min(limit, MAX_SEARCH_LIMIT)) + tools = list(tools) + if not tools: + return SearchOutcome(risk=risk) + self._ensure_index(tools) + + ranked = self._index.query(query, len(tools)) + bm25_points = {position: 1.0 / (1.0 + rank) for rank, position in enumerate(ranked)} + phrase_words = _phrase_words(query) + + scored: list[tuple[float, str, Tool]] = [] + for position, tool in enumerate(tools): + score = bm25_points.get(position, 0.0) + self._alias_boost(tool, query, phrase_words) + if score > 0.0: + scored.append((score, tool.name, tool)) + scored.sort(key=lambda row: (-row[0], row[1])) + matches = [tool for _, _, tool in scored] + + # Filter before truncating, so a blocked hit frees its slot for the + # next safe tool instead of just shortening the answer. + allowed = [tool for tool in matches if _risk_allowed(risk, tool_risk(tool))] + hidden = tuple( + tool for tool in matches[:cutoff] if not _risk_allowed(risk, tool_risk(tool)) + ) + return SearchOutcome(hits=tuple(allowed[:cutoff]), hidden=hidden, risk=risk) + + async def _search(self, tools: Sequence[Tool], query: str) -> Sequence[Tool]: + return list(self.rank(tools, query).hits) + + # ------------------------------------------------------------------ + # The synthetic search tool + # ------------------------------------------------------------------ + + async def render(self, outcome: SearchOutcome) -> Any: + """Serialize an outcome, keeping the risk notice on its own line. + + With the default (string) serializer the notice is appended as a + trailing line. A serializer that returns structured data instead gets + the notice as a sibling key, because there is no line to append to. + """ + rendered = await self._render_results(outcome.hits) + notice = outcome.notice() + if not notice: + return rendered + if isinstance(rendered, str): + return f"{rendered}\n\n{notice}" + return {"results": rendered, "notice": notice} + + def _make_search_tool(self) -> Tool: + transform = self + default_limit = self._max_results + + async def search_tools( + query: Annotated[ + str, + Field( + description=( + "What you want to do, in plain English or as an AutoCAD " + "command name (FILLET, BPOLY, QSELECT, DLI)." + ) + ), + ], + limit: Annotated[ + int, + Field(description=(f"How many tools to return. Clamped to 1..{MAX_SEARCH_LIMIT}.")), + ] = default_limit, + risk: Annotated[ + Literal["any", "write", "read"], + Field( + description=( + "Highest risk level to return. 'any' (default) is everything; " + "'write' hides tools that delete or overwrite; 'read' returns " + "only read-only query tools — use it for questions like 'how " + "many ...?'. Anything filtered out is reported in the result." + ) + ), + ] = "any", + ctx: Context = None, # type: ignore[assignment] + ) -> Any: + """Find the AutoCAD tools that do a job. + + Accepts AutoCAD command names as well as English. Returns one + compact line per tool: name, risk level, what it does, its required + arguments and the AutoCAD command it corresponds to. Call a result + with call_tool. + """ + visible = await transform._get_visible_tools(ctx) + outcome = transform.rank(visible, query, limit=limit, risk=risk) + return await transform.render(outcome) + + return Tool.from_function(fn=search_tools, name=self._search_tool_name) diff --git a/docs/assets/autocad-mcp-livebench.svg b/docs/assets/autocad-mcp-livebench.svg index a8d9e48..e2dd1d6 100644 --- a/docs/assets/autocad-mcp-livebench.svg +++ b/docs/assets/autocad-mcp-livebench.svg @@ -29,19 +29,19 @@ z - - + @@ -50,14 +50,14 @@ L 0 3.5 " style="stroke: #8b949e; stroke-width: 0.8"/> - + - + - - + - + - + - + - - - + + - + - + - + - - - + + - + - + - + - - - + + - + - + - + - - - + + - + - + - + - - - - + + + - + - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -836,9 +843,9 @@ z - + - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + @@ -911,9 +918,9 @@ z - + - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + @@ -1001,55 +1008,55 @@ z - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - +" clip-path="url(#pc55fa67d32)" style="fill: #484f58"/> - +" clip-path="url(#pc55fa67d32)" style="fill: #484f58"/> - +" clip-path="url(#pc55fa67d32)" style="fill: #58a6ff"/> - + - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + diff --git a/docs/assets/autocad-mcp-perf.svg b/docs/assets/autocad-mcp-perf.svg index 21b7d09..6b91d0a 100644 --- a/docs/assets/autocad-mcp-perf.svg +++ b/docs/assets/autocad-mcp-perf.svg @@ -29,19 +29,19 @@ z - - + @@ -50,14 +50,14 @@ L 0 3.5 " style="stroke: #8b949e; stroke-width: 0.8"/> - + - + - - - - - - + + + - + - + - + - - - - + + + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - + - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - +" clip-path="url(#p16cc1d88db)" style="fill: #58a6ff"/> - +" clip-path="url(#p16cc1d88db)" style="fill: #58a6ff"/> - +" clip-path="url(#p16cc1d88db)" style="fill: #58a6ff"/> - +" clip-path="url(#p16cc1d88db)" style="fill: #58a6ff"/> - - - + + + - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + diff --git a/docs/assets/autocad-mcp-taskmatrix.svg b/docs/assets/autocad-mcp-taskmatrix.svg index ae6cfd9..d5eca7e 100644 --- a/docs/assets/autocad-mcp-taskmatrix.svg +++ b/docs/assets/autocad-mcp-taskmatrix.svg @@ -1,7 +1,7 @@ - + @@ -20,8 +20,8 @@ - - - +" clip-path="url(#paa6d2ff80c)" style="fill: #238636; stroke: #30363d; stroke-width: 0.8; stroke-linejoin: miter"/> - +" clip-path="url(#paa6d2ff80c)" style="fill: #238636; stroke: #30363d; stroke-width: 0.8; stroke-linejoin: miter"/> - +" clip-path="url(#paa6d2ff80c)" style="fill: #238636; stroke: #30363d; stroke-width: 0.8; stroke-linejoin: miter"/> - +" clip-path="url(#paa6d2ff80c)" style="fill: #238636; stroke: #30363d; stroke-width: 0.8; stroke-linejoin: miter"/> - +" clip-path="url(#paa6d2ff80c)" style="fill: #238636; stroke: #30363d; stroke-width: 0.8; stroke-linejoin: miter"/> - +" clip-path="url(#paa6d2ff80c)" style="fill: #238636; stroke: #30363d; stroke-width: 0.8; stroke-linejoin: miter"/> - +" clip-path="url(#paa6d2ff80c)" style="fill: #238636; stroke: #30363d; stroke-width: 0.8; stroke-linejoin: miter"/> - +" clip-path="url(#paa6d2ff80c)" style="fill: #238636; stroke: #30363d; stroke-width: 0.8; stroke-linejoin: miter"/> - +" clip-path="url(#paa6d2ff80c)" style="fill: #d29922; stroke: #30363d; stroke-width: 0.8; stroke-linejoin: miter"/> - +" clip-path="url(#paa6d2ff80c)" style="fill: #238636; stroke: #30363d; stroke-width: 0.8; stroke-linejoin: miter"/> - +" clip-path="url(#paa6d2ff80c)" style="fill: #238636; stroke: #30363d; stroke-width: 0.8; stroke-linejoin: miter"/> - +" clip-path="url(#paa6d2ff80c)" style="fill: #238636; stroke: #30363d; stroke-width: 0.8; stroke-linejoin: miter"/> - +" clip-path="url(#paa6d2ff80c)" style="fill: #238636; stroke: #30363d; stroke-width: 0.8; stroke-linejoin: miter"/> - +" clip-path="url(#paa6d2ff80c)" style="fill: #30363d; stroke: #30363d; stroke-width: 0.8; stroke-linejoin: miter"/> - +" clip-path="url(#paa6d2ff80c)" style="fill: #30363d; stroke: #30363d; stroke-width: 0.8; stroke-linejoin: miter"/> - +" clip-path="url(#paa6d2ff80c)" style="fill: #238636; stroke: #30363d; stroke-width: 0.8; stroke-linejoin: miter"/> - +" clip-path="url(#paa6d2ff80c)" style="fill: #30363d; stroke: #30363d; stroke-width: 0.8; stroke-linejoin: miter"/> - +" clip-path="url(#paa6d2ff80c)" style="fill: #30363d; stroke: #30363d; stroke-width: 0.8; stroke-linejoin: miter"/> - +" clip-path="url(#paa6d2ff80c)" style="fill: #238636; stroke: #30363d; stroke-width: 0.8; stroke-linejoin: miter"/> - +" clip-path="url(#paa6d2ff80c)" style="fill: #30363d; stroke: #30363d; stroke-width: 0.8; stroke-linejoin: miter"/> - +" clip-path="url(#paa6d2ff80c)" style="fill: #30363d; stroke: #30363d; stroke-width: 0.8; stroke-linejoin: miter"/> - +" clip-path="url(#paa6d2ff80c)" style="fill: #238636; stroke: #30363d; stroke-width: 0.8; stroke-linejoin: miter"/> - +" clip-path="url(#paa6d2ff80c)" style="fill: #30363d; stroke: #30363d; stroke-width: 0.8; stroke-linejoin: miter"/> - +" clip-path="url(#paa6d2ff80c)" style="fill: #30363d; stroke: #30363d; stroke-width: 0.8; stroke-linejoin: miter"/> - +" clip-path="url(#paa6d2ff80c)" style="fill: #238636; stroke: #30363d; stroke-width: 0.8; stroke-linejoin: miter"/> - +" clip-path="url(#paa6d2ff80c)" style="fill: #238636; stroke: #30363d; stroke-width: 0.8; stroke-linejoin: miter"/> - +" clip-path="url(#paa6d2ff80c)" style="fill: #238636; stroke: #30363d; stroke-width: 0.8; stroke-linejoin: miter"/> - +" clip-path="url(#paa6d2ff80c)" style="fill: #238636; stroke: #30363d; stroke-width: 0.8; stroke-linejoin: miter"/> - +" clip-path="url(#paa6d2ff80c)" style="fill: #30363d; stroke: #30363d; stroke-width: 0.8; stroke-linejoin: miter"/> - +" clip-path="url(#paa6d2ff80c)" style="fill: #30363d; stroke: #30363d; stroke-width: 0.8; stroke-linejoin: miter"/> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + @@ -533,9 +653,9 @@ z - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + @@ -770,9 +890,9 @@ z - + - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + @@ -821,9 +941,9 @@ z - + - - - - - - - - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1141,9 +1261,9 @@ z - + - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + @@ -1269,16 +1389,16 @@ z - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1372,31 +1492,31 @@ z - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1404,9 +1524,9 @@ z - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1467,34 +1587,34 @@ z - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1502,9 +1622,32 @@ z - + - + - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + @@ -1552,33 +1694,63 @@ z - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1586,9 +1758,9 @@ z - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1644,9 +1816,9 @@ z - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + - - - + + + - - + + - - - + + + - - - + + + - - - + + + - - + + - - - + + + - - - + + + - - + + - - - + + + - - - + + + - - - + + + - - + + - - - + + + - - + + - - - + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + - + - - - - - + + + + + - - + - + - - - - - - - - + + + + + + + + - - + - + - - - - - + + + + + - - + - + - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - + + diff --git a/docs/superpowers/plans/2026-07-17-repository-cleanup-readme-benchmark.md b/docs/superpowers/plans/2026-07-17-repository-cleanup-readme-benchmark.md index d6a4745..e3ffbd9 100644 --- a/docs/superpowers/plans/2026-07-17-repository-cleanup-readme-benchmark.md +++ b/docs/superpowers/plans/2026-07-17-repository-cleanup-readme-benchmark.md @@ -448,10 +448,7 @@ def render_chart(data: dict[str, Any], output: Path) -> None: labels = [item["repository"] for item in projects] scores = [item["score"] for item in projects] grades = [item["evidence_grade"] for item in projects] - colors = [ - "#0969da" if label == "U-C4N/Autocad-MCP" else "#6e7781" - for label in labels - ] + colors = ["#0969da" if label == "U-C4N/Autocad-MCP" else "#6e7781" for label in labels] fig, ax = plt.subplots(figsize=(12, 6.4), constrained_layout=True) fig.patch.set_facecolor("#f6f8fa") diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json new file mode 100644 index 0000000..fb8517f --- /dev/null +++ b/docs/tool-inventory.json @@ -0,0 +1,2347 @@ +{ + "_comment": "GENERATED FILE - do not edit by hand. Derived from the live FastMCP component registry; regenerate with `python scripts/generate_tool_inventory.py`.", + "schema_version": 1, + "package_version": "1.5.0", + "totals": { + "tools": 154, + "resources": 6, + "prompts": 5, + "groups": 19 + }, + "groups": { + "analysis": 12, + "batch": 3, + "blocks": 8, + "corner_ops": 4, + "dimensions": 5, + "drawing": 11, + "engineering": 10, + "entity_creation": 18, + "entity_modification": 15, + "entity_query": 8, + "layers": 14, + "layouts": 12, + "premium": 12, + "solids": 5, + "system": 7, + "templates": 2, + "transactions": 3, + "validation": 1, + "view": 4 + }, + "tools": [ + { + "name": "analysis_bounding_box", + "group": "analysis", + "cost": "read", + "summary": "Get the overall extents of everything drawn.", + "tags": [ + "analysis", + "query" + ], + "annotations": { + "title": "Drawing Bounding Box", + "readOnlyHint": true + } + }, + { + "name": "analysis_entity_stats", + "group": "analysis", + "cost": "read", + "summary": "Count the drawing's objects, broken down by type and by layer.", + "tags": [ + "analysis", + "query" + ], + "annotations": { + "title": "Entity Statistics", + "readOnlyHint": true + } + }, + { + "name": "analysis_find_in_region", + "group": "analysis", + "cost": "read", + "summary": "List everything that falls inside a rectangular window.", + "tags": [ + "analysis", + "query" + ], + "annotations": { + "title": "Find Entities in Region", + "readOnlyHint": true + } + }, + { + "name": "analysis_layer_stats", + "group": "analysis", + "cost": "read", + "summary": "Report how many entities, and which types, sit on each layer.", + "tags": [ + "analysis", + "layer", + "query" + ], + "annotations": { + "title": "Layer Statistics", + "readOnlyHint": true + } + }, + { + "name": "analysis_list_properties", + "group": "analysis", + "cost": "read", + "summary": "Dump every DXF property of one entity, like LIST.", + "tags": [ + "analysis", + "query" + ], + "annotations": { + "title": "List Entity Properties", + "readOnlyHint": true + } + }, + { + "name": "analysis_measure_area", + "group": "analysis", + "cost": "read", + "summary": "Measure a polygon's area and perimeter from its vertices.", + "tags": [ + "analysis", + "measure" + ], + "annotations": { + "title": "Measure Area", + "readOnlyHint": true, + "idempotentHint": true + } + }, + { + "name": "analysis_measure_distance", + "group": "analysis", + "cost": "read", + "summary": "Measure the gap between two points: distance, dx/dy and angle.", + "tags": [ + "analysis", + "measure" + ], + "annotations": { + "title": "Measure Distance", + "readOnlyHint": true, + "idempotentHint": true + } + }, + { + "name": "analysis_measure_entity", + "group": "analysis", + "cost": "read", + "summary": "Area and perimeter of an existing entity, by handle.", + "tags": [ + "analysis", + "measure" + ], + "annotations": { + "title": "Measure Entity", + "readOnlyHint": true, + "idempotentHint": true + } + }, + { + "name": "analysis_select_by_layer", + "group": "analysis", + "cost": "read", + "summary": "Grab the handles of everything sitting on one layer.", + "tags": [ + "analysis", + "query" + ], + "annotations": { + "title": "Select Entities By Layer", + "readOnlyHint": true + } + }, + { + "name": "analysis_select_by_type", + "group": "analysis", + "cost": "read", + "summary": "Grab the handles of every entity of one type: all the circles, all the lines.", + "tags": [ + "analysis", + "query" + ], + "annotations": { + "title": "Select Entities By Type", + "readOnlyHint": true + } + }, + { + "name": "block_create_from_entities", + "group": "blocks", + "cost": "mutate", + "summary": "Turn existing entities into a reusable block definition.", + "tags": [ + "block" + ], + "annotations": { + "title": "Create Block From Entities", + "readOnlyHint": false + } + }, + { + "name": "block_explode", + "group": "blocks", + "cost": "destructive", + "summary": "Break a block reference apart into its component entities.", + "tags": [ + "block" + ], + "annotations": { + "title": "Explode Block", + "readOnlyHint": false, + "destructiveHint": true + } + }, + { + "name": "block_find_references", + "group": "blocks", + "cost": "read", + "summary": "Find every place a given block is inserted.", + "tags": [ + "block", + "query" + ], + "annotations": { + "title": "Find Blocks By Name", + "readOnlyHint": true + } + }, + { + "name": "block_get_attributes", + "group": "blocks", + "cost": "read", + "summary": "Read the tag/value pairs off a block reference.", + "tags": [ + "block", + "query" + ], + "annotations": { + "title": "Get Block Attributes", + "readOnlyHint": true + } + }, + { + "name": "block_insert", + "group": "blocks", + "cost": "mutate", + "summary": "Place a block, filling in its attribute values as you go.", + "tags": [ + "block" + ], + "annotations": { + "title": "Insert Block", + "readOnlyHint": false + } + }, + { + "name": "block_list", + "group": "blocks", + "cost": "read", + "summary": "List the block definitions the drawing already carries.", + "tags": [ + "block", + "query" + ], + "annotations": { + "title": "List Blocks", + "readOnlyHint": true + } + }, + { + "name": "block_set_attributes", + "group": "blocks", + "cost": "mutate", + "summary": "Fill in a block reference's attributes, such as title-block fields.", + "tags": [ + "block" + ], + "annotations": { + "title": "Set Block Attributes", + "readOnlyHint": false + } + }, + { + "name": "boundary_from_entities", + "group": "analysis", + "cost": "mutate", + "summary": "Join named entities into one closed boundary polyline.", + "tags": [ + "analysis" + ], + "annotations": { + "title": "Boundary from Entities", + "destructiveHint": false + } + }, + { + "name": "boundary_trace", + "group": "analysis", + "cost": "mutate", + "summary": "Trace the closed boundary around a point, like BOUNDARY.", + "tags": [ + "analysis" + ], + "annotations": { + "title": "Trace Boundary", + "destructiveHint": false + } + }, + { + "name": "cad_batch", + "group": "batch", + "cost": "destructive", + "summary": "Run an ordered list of tool calls in one round trip, binding results between steps.", + "tags": [ + "batch" + ], + "annotations": { + "title": "Batch: Run Tools", + "readOnlyHint": false, + "destructiveHint": true + } + }, + { + "name": "construction_clear", + "group": "premium", + "cost": "destructive", + "summary": "Wipe the construction scaffolding off the drawing before finalize.", + "tags": [ + "construction", + "premium" + ], + "annotations": { + "title": "Construction: Clear (delete scaffold)", + "destructiveHint": true + } + }, + { + "name": "construction_xline", + "group": "premium", + "cost": "mutate", + "summary": "Lay down an infinite guide line to build geometry against.", + "tags": [ + "construction", + "premium" + ], + "annotations": { + "title": "Construction: XLine (infinite reference)", + "destructiveHint": false + } + }, + { + "name": "datum_feature", + "group": "engineering", + "cost": "mutate", + "summary": "Mark a datum on a feature: filled triangle plus the boxed datum letter.", + "tags": [ + "engineering", + "gdt" + ], + "annotations": { + "title": "GD&T: Datum Feature (ISO 1101)", + "destructiveHint": false + } + }, + { + "name": "dimension_aligned", + "group": "dimensions", + "cost": "mutate", + "summary": "Dimension the true distance between two points, along a slope.", + "tags": [ + "annotation", + "dimension" + ], + "annotations": { + "title": "Aligned Dimension", + "readOnlyHint": false + } + }, + { + "name": "dimension_angular", + "group": "dimensions", + "cost": "mutate", + "summary": "Dimension the included angle between two rays from a vertex.", + "tags": [ + "annotation", + "dimension" + ], + "annotations": { + "title": "Angular Dimension", + "readOnlyHint": false + } + }, + { + "name": "dimension_auto", + "group": "premium", + "cost": "mutate", + "summary": "Dimension a set of entities at once, as a chain, baseline or ordinate run.", + "tags": [ + "dimension", + "premium" + ], + "annotations": { + "title": "Dimension: Auto (chain / baseline / ordinate)", + "destructiveHint": false + } + }, + { + "name": "dimension_diameter", + "group": "dimensions", + "cost": "mutate", + "summary": "Call out a hole or shaft diameter, optionally to a fit such as H7.", + "tags": [ + "annotation", + "dimension" + ], + "annotations": { + "title": "Diameter Dimension", + "readOnlyHint": false + } + }, + { + "name": "dimension_linear", + "group": "dimensions", + "cost": "mutate", + "summary": "Dimension a horizontal or vertical size, with an ISO 129 tolerance or ISO 286 fit.", + "tags": [ + "annotation", + "dimension" + ], + "annotations": { + "title": "Linear Dimension", + "readOnlyHint": false + } + }, + { + "name": "dimension_radius", + "group": "dimensions", + "cost": "mutate", + "summary": "Call out the radius of a circle or arc, optionally toleranced.", + "tags": [ + "annotation", + "dimension" + ], + "annotations": { + "title": "Radius Dimension", + "readOnlyHint": false + } + }, + { + "name": "drawing_apply_iso_layers", + "group": "premium", + "cost": "mutate", + "summary": "Set up a standard ISO layer set with the right colours and lineweights.", + "tags": [ + "layers", + "premium" + ], + "annotations": { + "title": "Drawing: Apply ISO Layer Set (bootstrap)", + "destructiveHint": false + } + }, + { + "name": "drawing_audit", + "group": "drawing", + "cost": "mutate", + "summary": "Scan the drawing for structural errors and repair what it finds.", + "tags": [ + "cleanup", + "drawing" + ], + "annotations": { + "title": "Audit Drawing", + "readOnlyHint": false + } + }, + { + "name": "drawing_close", + "group": "drawing", + "cost": "destructive", + "summary": "Close the drawing, saving first unless you say otherwise.", + "tags": [ + "drawing" + ], + "annotations": { + "title": "Close Drawing", + "destructiveHint": true + } + }, + { + "name": "drawing_critique", + "group": "premium", + "cost": "read", + "summary": "Review the drawing for drafting mistakes: must come back empty before finalize.", + "tags": [ + "premium", + "validation" + ], + "annotations": { + "title": "Drawing: Critique (premium quality checks)", + "readOnlyHint": true, + "destructiveHint": false + } + }, + { + "name": "drawing_deliver", + "group": "engineering", + "cost": "destructive", + "summary": "Hand the drawing off: a hashed, validated bundle of files plus a manifest.", + "tags": [ + "delivery", + "drawing", + "engineering", + "validation" + ], + "annotations": { + "title": "Drawing: Deliver Auditable Bundle", + "destructiveHint": true + } + }, + { + "name": "drawing_export_dxf", + "group": "drawing", + "cost": "mutate", + "summary": "Write a DXF interchange copy of the drawing.", + "tags": [ + "drawing", + "export" + ], + "annotations": { + "title": "Export DXF" + } + }, + { + "name": "drawing_export_pdf", + "group": "drawing", + "cost": "mutate", + "summary": "Plot the drawing, or one paper-space layout, to PDF.", + "tags": [ + "drawing", + "export" + ], + "annotations": { + "title": "Export PDF" + } + }, + { + "name": "drawing_finalize", + "group": "engineering", + "cost": "destructive", + "summary": "Finish the drawing: validate, critique, save, screenshot and score it.", + "tags": [ + "drawing", + "engineering", + "validation" + ], + "annotations": { + "title": "Drawing: Finalize (validate + save + screenshot)", + "destructiveHint": true + } + }, + { + "name": "drawing_info", + "group": "entity_query", + "cost": "read", + "summary": "Read the current drawing's name, path, extents and object counts.", + "tags": [ + "drawing", + "query" + ], + "annotations": { + "title": "Drawing Info", + "readOnlyHint": true + } + }, + { + "name": "drawing_new", + "group": "drawing", + "cost": "mutate", + "summary": "Start a blank drawing, pre-seeded with the standard engineering layers.", + "tags": [ + "drawing" + ], + "annotations": { + "title": "New Drawing", + "destructiveHint": false + } + }, + { + "name": "drawing_open", + "group": "drawing", + "cost": "mutate", + "summary": "Load an existing DXF from disk (DWG only on the live AutoCAD backend).", + "tags": [ + "drawing" + ], + "annotations": { + "title": "Open Drawing" + } + }, + { + "name": "drawing_plan", + "group": "premium", + "cost": "safe", + "summary": "Commit sheet size, scale, layer set and dimension style before any geometry.", + "tags": [ + "planning", + "premium" + ], + "annotations": { + "title": "Drawing: Plan (commit intent before drawing)", + "destructiveHint": false + } + }, + { + "name": "drawing_preflight", + "group": "premium", + "cost": "read", + "summary": "Check the brief before you draw: units, part type, tolerances, missing inputs.", + "tags": [ + "planning", + "premium", + "validation" + ], + "annotations": { + "title": "Drawing: Preflight", + "readOnlyHint": true + } + }, + { + "name": "drawing_purge", + "group": "drawing", + "cost": "destructive", + "summary": "Strip unused layers, blocks, linetypes and styles out of the file.", + "tags": [ + "cleanup", + "drawing" + ], + "annotations": { + "title": "Purge Drawing" + } + }, + { + "name": "drawing_redo", + "group": "drawing", + "cost": "safe", + "summary": "Reapply the operation you just undid.", + "tags": [ + "drawing", + "undo" + ], + "annotations": { + "title": "Redo", + "destructiveHint": false + } + }, + { + "name": "drawing_refine", + "group": "premium", + "cost": "destructive", + "summary": "Auto-repair what the critique found, then re-check, up to three rounds.", + "tags": [ + "modify", + "premium", + "validation" + ], + "annotations": { + "title": "Drawing: Refine", + "destructiveHint": true + } + }, + { + "name": "drawing_save", + "group": "drawing", + "cost": "mutate", + "summary": "Write the drawing back to its file, or to a path you give.", + "tags": [ + "drawing" + ], + "annotations": { + "title": "Save Drawing" + } + }, + { + "name": "drawing_save_as", + "group": "drawing", + "cost": "mutate", + "summary": "Save a copy under a new name; the extension picks DXF, DWG or DWT.", + "tags": [ + "drawing" + ], + "annotations": { + "title": "Save As" + } + }, + { + "name": "drawing_settings", + "group": "system", + "cost": "safe", + "summary": "Read or change drawing units, precision, linetype and dimension scale by name.", + "tags": [ + "system" + ], + "annotations": { + "title": "Drawing Settings (read / change)", + "readOnlyHint": false, + "destructiveHint": false + } + }, + { + "name": "drawing_undo", + "group": "drawing", + "cost": "safe", + "summary": "Step back one operation.", + "tags": [ + "drawing", + "undo" + ], + "annotations": { + "title": "Undo", + "destructiveHint": false, + "idempotentHint": false + } + }, + { + "name": "entity_array_polar", + "group": "entity_modification", + "cost": "mutate", + "summary": "Repeat an entity around a centre: a bolt circle or radial pattern.", + "tags": [ + "array", + "entity", + "modify" + ], + "annotations": { + "title": "Polar Array", + "readOnlyHint": false + } + }, + { + "name": "entity_array_rectangular", + "group": "entity_modification", + "cost": "mutate", + "summary": "Repeat an entity in a grid of rows and columns.", + "tags": [ + "array", + "entity", + "modify" + ], + "annotations": { + "title": "Rectangular Array", + "readOnlyHint": false + } + }, + { + "name": "entity_batch_create", + "group": "batch", + "cost": "mutate", + "summary": "Draw many entities in one call instead of one round trip each.", + "tags": [ + "batch", + "create", + "entity" + ], + "annotations": { + "title": "Batch Create Entities", + "readOnlyHint": false + } + }, + { + "name": "entity_batch_modify", + "group": "batch", + "cost": "destructive", + "summary": "Move, rotate, scale, restyle or delete many entities in one call.", + "tags": [ + "batch", + "entity", + "modify" + ], + "annotations": { + "title": "Batch Modify Entities", + "readOnlyHint": false + } + }, + { + "name": "entity_chamfer", + "group": "corner_ops", + "cost": "mutate", + "summary": "Bevel a corner between two lines by two setback distances.", + "tags": [ + "corner", + "entity", + "modify" + ], + "annotations": { + "title": "Chamfer Two Entities", + "readOnlyHint": false, + "destructiveHint": false + } + }, + { + "name": "entity_change_space", + "group": "layouts", + "cost": "mutate", + "summary": "Move entities between model space and a sheet through a viewport.", + "tags": [ + "layout", + "modify" + ], + "annotations": { + "title": "Change Space", + "destructiveHint": false + } + }, + { + "name": "entity_copy", + "group": "entity_modification", + "cost": "mutate", + "summary": "Duplicate an entity and offset the copy.", + "tags": [ + "entity", + "modify" + ], + "annotations": { + "title": "Copy Entity", + "readOnlyHint": false, + "destructiveHint": false + } + }, + { + "name": "entity_create_arc", + "group": "entity_creation", + "cost": "mutate", + "summary": "Draw a circular arc from a centre, a radius and two angles.", + "tags": [ + "create", + "entity" + ], + "annotations": { + "title": "Create Arc", + "readOnlyHint": false + } + }, + { + "name": "entity_create_block_ref", + "group": "blocks", + "cost": "mutate", + "summary": "Drop an instance of an existing block definition into the drawing.", + "tags": [ + "block", + "create", + "entity" + ], + "annotations": { + "title": "Insert Block Reference", + "readOnlyHint": false + } + }, + { + "name": "entity_create_circle", + "group": "entity_creation", + "cost": "mutate", + "summary": "Draw a circle: a hole, a bore, a pitch circle.", + "tags": [ + "create", + "entity" + ], + "annotations": { + "title": "Create Circle", + "readOnlyHint": false + } + }, + { + "name": "entity_create_ellipse", + "group": "entity_creation", + "cost": "mutate", + "summary": "Draw an ellipse from its centre, major axis vector and axis ratio.", + "tags": [ + "create", + "entity" + ], + "annotations": { + "title": "Create Ellipse", + "readOnlyHint": false + } + }, + { + "name": "entity_create_hatch", + "group": "entity_creation", + "cost": "mutate", + "summary": "Fill a closed boundary with a section pattern such as ANSI31 or SOLID.", + "tags": [ + "create", + "entity" + ], + "annotations": { + "title": "Create Hatch", + "readOnlyHint": false + } + }, + { + "name": "entity_create_line", + "group": "entity_creation", + "cost": "mutate", + "summary": "Draw a straight line between two points.", + "tags": [ + "create", + "entity" + ], + "annotations": { + "title": "Create Line", + "readOnlyHint": false + } + }, + { + "name": "entity_create_mtext", + "group": "entity_creation", + "cost": "mutate", + "summary": "Place a wrapped paragraph note in a text box of a given width.", + "tags": [ + "annotation", + "create", + "entity" + ], + "annotations": { + "title": "Create MText", + "readOnlyHint": false + } + }, + { + "name": "entity_create_point", + "group": "entity_creation", + "cost": "mutate", + "summary": "Place a point marker (node) at a coordinate.", + "tags": [ + "create", + "entity" + ], + "annotations": { + "title": "Create Point", + "readOnlyHint": false + } + }, + { + "name": "entity_create_polyline", + "group": "entity_creation", + "cost": "mutate", + "summary": "Draw a connected outline or closed profile through a list of points.", + "tags": [ + "create", + "entity" + ], + "annotations": { + "title": "Create Polyline", + "readOnlyHint": false + } + }, + { + "name": "entity_create_rectangle", + "group": "entity_creation", + "cost": "mutate", + "summary": "Draw a closed rectangle from two opposite corners.", + "tags": [ + "create", + "entity" + ], + "annotations": { + "title": "Create Rectangle", + "readOnlyHint": false, + "idempotentHint": false + } + }, + { + "name": "entity_create_revcloud", + "group": "entity_creation", + "cost": "mutate", + "summary": "Draw a revision cloud around an area.", + "tags": [ + "create", + "entity" + ], + "annotations": { + "title": "Create Revision Cloud", + "destructiveHint": false + } + }, + { + "name": "entity_create_spline", + "group": "entity_creation", + "cost": "mutate", + "summary": "Draw a smooth NURBS curve through fit points.", + "tags": [ + "create", + "entity" + ], + "annotations": { + "title": "Create Spline", + "readOnlyHint": false + } + }, + { + "name": "entity_create_table", + "group": "entity_creation", + "cost": "mutate", + "summary": "Place a parts list, BOM or schedule as a table with headers and rows.", + "tags": [ + "annotation", + "create", + "entity", + "table" + ], + "annotations": { + "title": "Create Table", + "readOnlyHint": false + } + }, + { + "name": "entity_create_text", + "group": "entity_creation", + "cost": "mutate", + "summary": "Place a single-line text label.", + "tags": [ + "annotation", + "create", + "entity" + ], + "annotations": { + "title": "Create Text", + "readOnlyHint": false + } + }, + { + "name": "entity_create_wipeout", + "group": "entity_creation", + "cost": "mutate", + "summary": "Mask whatever is behind a closed polygon on the sheet.", + "tags": [ + "create", + "entity" + ], + "annotations": { + "title": "Create Wipeout", + "destructiveHint": false + } + }, + { + "name": "entity_delete", + "group": "entity_modification", + "cost": "destructive", + "summary": "Erase one entity by handle.", + "tags": [ + "entity", + "modify" + ], + "annotations": { + "title": "Delete Entity", + "readOnlyHint": false, + "destructiveHint": true + } + }, + { + "name": "entity_delete_many", + "group": "entity_modification", + "cost": "destructive", + "summary": "Erase a whole list of entities in one call.", + "tags": [ + "entity", + "modify" + ], + "annotations": { + "title": "Delete Multiple Entities", + "readOnlyHint": false, + "destructiveHint": true + } + }, + { + "name": "entity_edit_geometry", + "group": "entity_modification", + "cost": "mutate", + "summary": "Change a circle's radius, a line's endpoints or an arc's angles in place.", + "tags": [ + "entity", + "modify" + ], + "annotations": { + "title": "Edit Geometry", + "readOnlyHint": false, + "destructiveHint": false + } + }, + { + "name": "entity_edit_text", + "group": "entity_modification", + "cost": "mutate", + "summary": "Reword a label, or change its height or rotation, keeping its handle.", + "tags": [ + "entity", + "modify" + ], + "annotations": { + "title": "Edit Text", + "readOnlyHint": false, + "destructiveHint": false + } + }, + { + "name": "entity_extend", + "group": "corner_ops", + "cost": "mutate", + "summary": "Lengthen a line until it meets a boundary line.", + "tags": [ + "corner", + "entity", + "modify" + ], + "annotations": { + "title": "Extend Entity", + "readOnlyHint": false, + "destructiveHint": false + } + }, + { + "name": "entity_fillet", + "group": "corner_ops", + "cost": "mutate", + "summary": "Round a corner between two lines with a tangent arc.", + "tags": [ + "corner", + "entity", + "modify" + ], + "annotations": { + "title": "Fillet Two Entities", + "readOnlyHint": false, + "destructiveHint": false + } + }, + { + "name": "entity_get", + "group": "entity_query", + "cost": "read", + "summary": "Inspect one entity by handle: type, layer, colour and geometry.", + "tags": [ + "entity", + "query" + ], + "annotations": { + "title": "Get Entity", + "readOnlyHint": true + } + }, + { + "name": "entity_list", + "group": "entity_query", + "cost": "read", + "summary": "Browse the drawing's entities and their handles, filtered by type or layer.", + "tags": [ + "entity", + "query" + ], + "annotations": { + "title": "List Entities", + "readOnlyHint": true + } + }, + { + "name": "entity_mirror", + "group": "entity_modification", + "cost": "mutate", + "summary": "Reflect an entity across a mirror line, keeping or dropping the original.", + "tags": [ + "entity", + "modify" + ], + "annotations": { + "title": "Mirror Entity", + "readOnlyHint": false, + "destructiveHint": false + } + }, + { + "name": "entity_move", + "group": "entity_modification", + "cost": "mutate", + "summary": "Shift an entity by a displacement vector.", + "tags": [ + "entity", + "modify" + ], + "annotations": { + "title": "Move Entity", + "readOnlyHint": false, + "destructiveHint": false + } + }, + { + "name": "entity_offset", + "group": "entity_modification", + "cost": "mutate", + "summary": "Make a parallel copy of a line, circle or polyline at a set distance.", + "tags": [ + "entity", + "modify" + ], + "annotations": { + "title": "Offset Entity", + "readOnlyHint": false, + "destructiveHint": false + } + }, + { + "name": "entity_rotate", + "group": "entity_modification", + "cost": "mutate", + "summary": "Turn an entity about a base point by an angle in degrees.", + "tags": [ + "entity", + "modify" + ], + "annotations": { + "title": "Rotate Entity", + "readOnlyHint": false, + "destructiveHint": false + } + }, + { + "name": "entity_scale", + "group": "entity_modification", + "cost": "mutate", + "summary": "Resize an entity uniformly about a base point.", + "tags": [ + "entity", + "modify" + ], + "annotations": { + "title": "Scale Entity", + "readOnlyHint": false, + "destructiveHint": false + } + }, + { + "name": "entity_select_smart", + "group": "premium", + "cost": "read", + "summary": "Pick every entity matching a description: type, layer, colour, length, location.", + "tags": [ + "premium", + "select" + ], + "annotations": { + "title": "Entity: Smart Select (semantic predicate)", + "readOnlyHint": true + } + }, + { + "name": "entity_set_properties", + "group": "entity_modification", + "cost": "mutate", + "summary": "Change an entity's layer, colour, linetype, lineweight or visibility.", + "tags": [ + "entity", + "modify" + ], + "annotations": { + "title": "Set Entity Properties", + "readOnlyHint": false, + "destructiveHint": false + } + }, + { + "name": "entity_trim", + "group": "corner_ops", + "cost": "mutate", + "summary": "Cut a line back to where another crosses it, keeping the side you point at.", + "tags": [ + "corner", + "entity", + "modify" + ], + "annotations": { + "title": "Trim Entity", + "readOnlyHint": false, + "destructiveHint": false + } + }, + { + "name": "gd_frame", + "group": "engineering", + "cost": "mutate", + "summary": "Draw an ISO 1101 feature control frame: symbol, tolerance zone and datums.", + "tags": [ + "engineering", + "gdt" + ], + "annotations": { + "title": "GD&T: Feature Control Frame (ISO 1101)", + "destructiveHint": false + } + }, + { + "name": "gear_draw_helical_front_view", + "group": "engineering", + "cost": "mutate", + "summary": "Draw a helical gear front view: true involute teeth, circles, bore and keyway.", + "tags": [ + "engineering", + "gear" + ], + "annotations": { + "title": "Gear: Helical Front View", + "destructiveHint": false + } + }, + { + "name": "gear_draw_section_aa", + "group": "engineering", + "cost": "mutate", + "summary": "Draw the hatched side cross-section A-A of a gear you already drew.", + "tags": [ + "engineering", + "gear" + ], + "annotations": { + "title": "Gear: Section A-A View", + "destructiveHint": false + } + }, + { + "name": "gear_draw_spur_front_view", + "group": "engineering", + "cost": "mutate", + "summary": "Draw a straight-tooth spur gear front view with true involute teeth.", + "tags": [ + "engineering", + "gear" + ], + "annotations": { + "title": "Gear: Spur Front View", + "destructiveHint": false + } + }, + { + "name": "hatch_add_boundary", + "group": "entity_creation", + "cost": "mutate", + "summary": "Add a boundary path to a hatch, arcs and ellipses included.", + "tags": [ + "create", + "entity" + ], + "annotations": { + "title": "Add Hatch Boundary", + "destructiveHint": false + } + }, + { + "name": "hatch_edit", + "group": "entity_creation", + "cost": "mutate", + "summary": "Change a hatch's pattern, scale, angle, colour or island style.", + "tags": [ + "create", + "entity" + ], + "annotations": { + "title": "Edit Hatch", + "destructiveHint": false + } + }, + { + "name": "hatch_set_gradient", + "group": "entity_creation", + "cost": "mutate", + "summary": "Replace a hatch's fill with a two-colour gradient.", + "tags": [ + "create", + "entity" + ], + "annotations": { + "title": "Set Hatch Gradient", + "destructiveHint": false + } + }, + { + "name": "keyway_draw_keyed_bore", + "group": "engineering", + "cost": "mutate", + "summary": "Draw a bore with a DIN 6885 keyway, auto-sized from the bore diameter.", + "tags": [ + "engineering", + "keyway" + ], + "annotations": { + "title": "Keyway: Keyed Bore (front view)", + "destructiveHint": false + } + }, + { + "name": "keyway_draw_section", + "group": "engineering", + "cost": "mutate", + "summary": "Draw the side cross-section of a keyed bore.", + "tags": [ + "engineering", + "keyway" + ], + "annotations": { + "title": "Keyway: Side Section", + "destructiveHint": false + } + }, + { + "name": "layer_create", + "group": "layers", + "cost": "mutate", + "summary": "Add a layer with a colour, linetype and lineweight.", + "tags": [ + "layer" + ], + "annotations": { + "title": "Create Layer", + "readOnlyHint": false + } + }, + { + "name": "layer_delete", + "group": "layers", + "cost": "destructive", + "summary": "Remove an empty layer from the drawing.", + "tags": [ + "layer" + ], + "annotations": { + "title": "Delete Layer", + "readOnlyHint": false, + "destructiveHint": true + } + }, + { + "name": "layer_freeze", + "group": "layers", + "cost": "safe", + "summary": "Freeze a layer so it stops drawing and regenerating.", + "tags": [ + "layer" + ], + "annotations": { + "title": "Freeze Layer" + } + }, + { + "name": "layer_hide", + "group": "layers", + "cost": "safe", + "summary": "Turn a layer off so its entities stop showing.", + "tags": [ + "layer" + ], + "annotations": { + "title": "Hide Layer" + } + }, + { + "name": "layer_isolate", + "group": "layers", + "cost": "safe", + "summary": "Hide every layer except one, to work on it alone.", + "tags": [ + "layer" + ], + "annotations": { + "title": "Isolate Layer", + "readOnlyHint": false + } + }, + { + "name": "layer_list", + "group": "layers", + "cost": "read", + "summary": "List every layer with its colour, linetype and visibility state.", + "tags": [ + "layer", + "query" + ], + "annotations": { + "title": "List Layers", + "readOnlyHint": true + } + }, + { + "name": "layer_lock", + "group": "layers", + "cost": "safe", + "summary": "Lock a layer so its entities can be seen but not edited.", + "tags": [ + "layer" + ], + "annotations": { + "title": "Lock Layer" + } + }, + { + "name": "layer_modify", + "group": "layers", + "cost": "mutate", + "summary": "Change an existing layer's colour, linetype or lineweight.", + "tags": [ + "layer" + ], + "annotations": { + "title": "Modify Layer", + "readOnlyHint": false, + "destructiveHint": false + } + }, + { + "name": "layer_set_current", + "group": "layers", + "cost": "safe", + "summary": "Choose the layer that new geometry lands on.", + "tags": [ + "layer" + ], + "annotations": { + "title": "Set Current Layer", + "readOnlyHint": false, + "destructiveHint": false + } + }, + { + "name": "layer_show", + "group": "layers", + "cost": "safe", + "summary": "Turn a layer that was switched off back on.", + "tags": [ + "layer" + ], + "annotations": { + "title": "Show Layer" + } + }, + { + "name": "layer_thaw", + "group": "layers", + "cost": "safe", + "summary": "Bring a frozen layer back into view.", + "tags": [ + "layer" + ], + "annotations": { + "title": "Thaw Layer" + } + }, + { + "name": "layer_unlock", + "group": "layers", + "cost": "safe", + "summary": "Unlock a layer so its entities can be edited again.", + "tags": [ + "layer" + ], + "annotations": { + "title": "Unlock Layer" + } + }, + { + "name": "layout_copy", + "group": "layouts", + "cost": "mutate", + "summary": "Duplicate a sheet with its page setup and geometry.", + "tags": [ + "layout" + ], + "annotations": { + "title": "Copy Layout", + "destructiveHint": false + } + }, + { + "name": "layout_create", + "group": "layouts", + "cost": "mutate", + "summary": "Add a new paper-space sheet tab to the drawing.", + "tags": [ + "layout" + ], + "annotations": { + "title": "Create Layout", + "destructiveHint": false + } + }, + { + "name": "layout_delete", + "group": "layouts", + "cost": "destructive", + "summary": "Delete a sheet tab and everything drawn on it.", + "tags": [ + "layout" + ], + "annotations": { + "title": "Delete Layout", + "destructiveHint": true + } + }, + { + "name": "layout_list", + "group": "layouts", + "cost": "read", + "summary": "List the sheet tabs: Model plus every paper-space layout.", + "tags": [ + "layout", + "query" + ], + "annotations": { + "title": "List Layouts", + "readOnlyHint": true + } + }, + { + "name": "layout_rename", + "group": "layouts", + "cost": "mutate", + "summary": "Rename a sheet tab, keeping its geometry and handles.", + "tags": [ + "layout" + ], + "annotations": { + "title": "Rename Layout", + "destructiveHint": false + } + }, + { + "name": "layout_set_current", + "group": "layouts", + "cost": "safe", + "summary": "Switch to a sheet tab, or back to model space.", + "tags": [ + "layout" + ], + "annotations": { + "title": "Set Current Layout" + } + }, + { + "name": "leader_create_mleader", + "group": "entity_creation", + "cost": "mutate", + "summary": "Add a callout: an arrow with a note on the end.", + "tags": [ + "annotation", + "create", + "entity", + "leader" + ], + "annotations": { + "title": "Create Multileader", + "readOnlyHint": false + } + }, + { + "name": "linetype_list", + "group": "layers", + "cost": "read", + "summary": "List the dash patterns currently loaded in the drawing.", + "tags": [ + "layer", + "linetype" + ], + "annotations": { + "title": "List Loaded Linetypes", + "readOnlyHint": true + } + }, + { + "name": "linetype_load", + "group": "layers", + "cost": "mutate", + "summary": "Load a linetype such as CENTER or HIDDEN, without the FILEDIA dialog trap.", + "tags": [ + "layer", + "linetype" + ], + "annotations": { + "title": "Load Linetype", + "readOnlyHint": false + } + }, + { + "name": "point_from_snap", + "group": "premium", + "cost": "read", + "summary": "Get an exact endpoint, midpoint, centre, quadrant or perpendicular foot.", + "tags": [ + "premium", + "snap" + ], + "annotations": { + "title": "Point: Snap (deterministic OSNAP)", + "readOnlyHint": true + } + }, + { + "name": "point_intersection", + "group": "premium", + "cost": "read", + "summary": "Find where two lines or circles cross, exactly.", + "tags": [ + "premium", + "snap" + ], + "annotations": { + "title": "Point: Intersection (deterministic)", + "readOnlyHint": true + } + }, + { + "name": "point_tangent", + "group": "premium", + "cost": "read", + "summary": "Find where a line from an outside point touches a circle.", + "tags": [ + "premium", + "snap" + ], + "annotations": { + "title": "Point: Tangent from external point", + "readOnlyHint": true + } + }, + { + "name": "selection_filter", + "group": "entity_query", + "cost": "read", + "summary": "Select entities by layer, type, colour, linetype or minimum area.", + "tags": [ + "entity", + "query" + ], + "annotations": { + "title": "Select by Properties", + "readOnlyHint": true + } + }, + { + "name": "selection_get", + "group": "entity_query", + "cost": "read", + "summary": "Read what the user already picked in the AutoCAD viewport (COM backend).", + "tags": [ + "entity", + "query" + ], + "annotations": { + "title": "Get Viewport Selection", + "readOnlyHint": true + } + }, + { + "name": "selection_polygon", + "group": "entity_query", + "cost": "read", + "summary": "Select entities inside or crossing an arbitrary polygon.", + "tags": [ + "entity", + "query" + ], + "annotations": { + "title": "Select by Polygon", + "readOnlyHint": true + } + }, + { + "name": "selection_window", + "group": "entity_query", + "cost": "read", + "summary": "Select entities inside or crossing a rectangle.", + "tags": [ + "entity", + "query" + ], + "annotations": { + "title": "Select by Window", + "readOnlyHint": true + } + }, + { + "name": "solid_boolean", + "group": "solids", + "cost": "destructive", + "summary": "Union, subtract or intersect two 3D solids; the tool solid is consumed.", + "tags": [ + "solid" + ], + "annotations": { + "title": "Solid: Boolean", + "destructiveHint": true + } + }, + { + "name": "solid_box", + "group": "solids", + "cost": "mutate", + "summary": "Create a 3D solid box (live AutoCAD, opt-in via ENABLE_3D).", + "tags": [ + "solid" + ], + "annotations": { + "title": "Solid: Box", + "destructiveHint": false + } + }, + { + "name": "solid_cylinder", + "group": "solids", + "cost": "mutate", + "summary": "Create a 3D solid cylinder or shaft (live AutoCAD, opt-in via ENABLE_3D).", + "tags": [ + "solid" + ], + "annotations": { + "title": "Solid: Cylinder", + "destructiveHint": false + } + }, + { + "name": "solid_extrude", + "group": "solids", + "cost": "mutate", + "summary": "Pull a closed profile up into a 3D solid (live AutoCAD, opt-in via ENABLE_3D).", + "tags": [ + "solid" + ], + "annotations": { + "title": "Solid: Extrude", + "destructiveHint": false + } + }, + { + "name": "solid_revolve", + "group": "solids", + "cost": "mutate", + "summary": "Spin a closed profile around an axis into a 3D solid (opt-in via ENABLE_3D).", + "tags": [ + "solid" + ], + "annotations": { + "title": "Solid: Revolve", + "destructiveHint": false + } + }, + { + "name": "system_about", + "group": "system", + "cost": "read", + "summary": "See what this server can do: version, tool groups and active profile.", + "tags": [ + "system" + ], + "annotations": { + "title": "Backend Info", + "readOnlyHint": true + } + }, + { + "name": "system_capabilities", + "group": "entity_query", + "cost": "read", + "summary": "Ask the active backend which features it really supports.", + "tags": [ + "query", + "system" + ], + "annotations": { + "title": "Backend Capabilities", + "readOnlyHint": true + } + }, + { + "name": "system_get_variable", + "group": "system", + "cost": "read", + "summary": "Read one AutoCAD system variable, such as DIMSCALE or LTSCALE.", + "tags": [ + "system" + ], + "annotations": { + "title": "Get System Variable", + "readOnlyHint": true + } + }, + { + "name": "system_run_command", + "group": "system", + "cost": "escape", + "summary": "Send a raw command string to the AutoCAD command line (COM only).", + "tags": [ + "system" + ], + "annotations": { + "title": "Run AutoCAD Command", + "readOnlyHint": false + } + }, + { + "name": "system_run_lisp", + "group": "system", + "cost": "escape", + "summary": "Evaluate an AutoLISP expression inside AutoCAD (COM only).", + "tags": [ + "system" + ], + "annotations": { + "title": "Execute AutoLISP", + "readOnlyHint": false + } + }, + { + "name": "system_set_variable", + "group": "system", + "cost": "safe", + "summary": "Set one AutoCAD system variable by name.", + "tags": [ + "system" + ], + "annotations": { + "title": "Set System Variable", + "readOnlyHint": false + } + }, + { + "name": "system_status", + "group": "system", + "cost": "read", + "summary": "Check the connection: which engine is live and what document is open.", + "tags": [ + "system" + ], + "annotations": { + "title": "Server Status", + "readOnlyHint": true + } + }, + { + "name": "template_apply_layers", + "group": "templates", + "cost": "mutate", + "summary": "Create a standard layer set: architectural, mechanical, electrical or piping.", + "tags": [ + "layer", + "template" + ], + "annotations": { + "title": "Apply Layer Template", + "readOnlyHint": false + } + }, + { + "name": "template_list", + "group": "templates", + "cost": "read", + "summary": "Show the available layer templates and the layers in each.", + "tags": [ + "query", + "template" + ], + "annotations": { + "title": "List Available Templates", + "readOnlyHint": true + } + }, + { + "name": "text_find_replace", + "group": "entity_modification", + "cost": "mutate", + "summary": "Find and replace text across the drawing.", + "tags": [ + "entity", + "modify" + ], + "annotations": { + "title": "Find and Replace Text", + "destructiveHint": false + } + }, + { + "name": "text_set_background", + "group": "entity_modification", + "cost": "mutate", + "summary": "Put an opaque background box behind an MTEXT.", + "tags": [ + "entity", + "modify" + ], + "annotations": { + "title": "Set Text Background", + "destructiveHint": false + } + }, + { + "name": "titleblock_apply_iso_a3", + "group": "engineering", + "cost": "mutate", + "summary": "Stamp an ISO 7200 title block and sheet frame onto an A3 drawing.", + "tags": [ + "engineering", + "titleblock" + ], + "annotations": { + "title": "TitleBlock: ISO A3", + "destructiveHint": false + } + }, + { + "name": "transaction_begin", + "group": "transactions", + "cost": "mutate", + "summary": "Open a rollback checkpoint before a risky edit.", + "tags": [ + "transaction" + ], + "annotations": { + "title": "Begin Transaction", + "readOnlyHint": false, + "destructiveHint": false + } + }, + { + "name": "transaction_commit", + "group": "transactions", + "cost": "safe", + "summary": "Keep the changes and drop the checkpoint.", + "tags": [ + "transaction" + ], + "annotations": { + "title": "Commit Transaction", + "readOnlyHint": false, + "destructiveHint": false + } + }, + { + "name": "transaction_rollback", + "group": "transactions", + "cost": "destructive", + "summary": "Throw away every change made since transaction_begin.", + "tags": [ + "transaction" + ], + "annotations": { + "title": "Rollback Transaction", + "readOnlyHint": false, + "destructiveHint": true + } + }, + { + "name": "validation_check", + "group": "validation", + "cost": "read", + "summary": "Sanity-check the drawing for empty layers, zero-length lines and duplicates.", + "tags": [ + "analysis", + "validation" + ], + "annotations": { + "title": "Validate Drawing", + "readOnlyHint": true + } + }, + { + "name": "view_screenshot", + "group": "view", + "cost": "read", + "summary": "Capture a PNG picture of the drawing as it currently looks.", + "tags": [ + "screenshot", + "view" + ], + "annotations": { + "title": "Screenshot", + "readOnlyHint": true + } + }, + { + "name": "view_zoom_and_screenshot", + "group": "view", + "cost": "read", + "summary": "Fit the drawing on screen, then capture it: the quickest visual check.", + "tags": [ + "screenshot", + "view" + ], + "annotations": { + "title": "Zoom and Screenshot", + "readOnlyHint": true + } + }, + { + "name": "view_zoom_extents", + "group": "view", + "cost": "safe", + "summary": "Zoom out until every entity fits on screen.", + "tags": [ + "view" + ], + "annotations": { + "title": "Zoom Extents", + "readOnlyHint": false, + "destructiveHint": false + } + }, + { + "name": "view_zoom_window", + "group": "view", + "cost": "safe", + "summary": "Zoom in on a rectangular region of the drawing.", + "tags": [ + "view" + ], + "annotations": { + "title": "Zoom Window", + "readOnlyHint": false, + "destructiveHint": false + } + }, + { + "name": "viewport_create", + "group": "layouts", + "cost": "mutate", + "summary": "Put a scaled window onto model space on a sheet, at 1:1, 1:2 and so on.", + "tags": [ + "layout" + ], + "annotations": { + "title": "Create Viewport", + "destructiveHint": false + } + }, + { + "name": "viewport_delete", + "group": "layouts", + "cost": "destructive", + "summary": "Remove a viewport from a sheet.", + "tags": [ + "layout" + ], + "annotations": { + "title": "Delete Viewport", + "destructiveHint": true + } + }, + { + "name": "viewport_list", + "group": "layouts", + "cost": "read", + "summary": "List the viewports on a sheet with their scales and locks.", + "tags": [ + "layout", + "query" + ], + "annotations": { + "title": "List Viewports", + "readOnlyHint": true + } + }, + { + "name": "viewport_lock", + "group": "layouts", + "cost": "safe", + "summary": "Lock a viewport so its scale cannot be zoomed away.", + "tags": [ + "layout" + ], + "annotations": { + "title": "Lock Viewport" + } + }, + { + "name": "viewport_set_scale", + "group": "layouts", + "cost": "mutate", + "summary": "Set a viewport's scale, e.g. 1:2 or 1:50.", + "tags": [ + "layout" + ], + "annotations": { + "title": "Set Viewport Scale", + "destructiveHint": false + } + } + ], + "resources": [ + { + "uri": "autocad://blocks", + "name": "Block Library", + "description": "All block definitions in the current drawing", + "mime_type": "application/json", + "tags": [ + "block" + ], + "templated": false, + "parameters": [] + }, + { + "uri": "autocad://drawing/info", + "name": "Current Drawing Info", + "description": "Metadata for the currently open drawing", + "mime_type": "application/json", + "tags": [ + "drawing" + ], + "templated": false, + "parameters": [] + }, + { + "uri": "autocad://entities/stats", + "name": "Entity Statistics", + "description": "Entity counts by type and layer", + "mime_type": "application/json", + "tags": [ + "analysis" + ], + "templated": false, + "parameters": [] + }, + { + "uri": "autocad://entities/{layer_name}", + "name": "Entities By Layer", + "description": "List all entities on a specific layer", + "mime_type": "application/json", + "tags": [ + "entity", + "layer" + ], + "templated": true, + "parameters": [ + "layer_name" + ] + }, + { + "uri": "autocad://layers", + "name": "Layer List", + "description": "All layers in the current drawing with properties", + "mime_type": "application/json", + "tags": [ + "layer" + ], + "templated": false, + "parameters": [] + }, + { + "uri": "autocad://system/status", + "name": "Server Status", + "description": "AutoCAD MCP Pro server and backend status", + "mime_type": "application/json", + "tags": [ + "system" + ], + "templated": false, + "parameters": [] + } + ], + "prompts": [ + { + "name": "prompt_electrical_schematic", + "description": "Generate a prompt for creating an electrical schematic diagram.", + "tags": [ + "electrical", + "template" + ], + "arguments": [ + { + "name": "circuit_name", + "required": false + }, + { + "name": "voltage", + "required": false + } + ] + }, + { + "name": "prompt_floor_plan", + "description": "Generate a prompt for creating a floor plan drawing.", + "tags": [ + "architectural", + "template" + ], + "arguments": [ + { + "name": "building_name", + "required": false + }, + { + "name": "scale", + "required": false + }, + { + "name": "units", + "required": false + } + ] + }, + { + "name": "prompt_mechanical_drawing", + "description": "Generate a prompt for creating a mechanical engineering drawing.", + "tags": [ + "mechanical", + "template" + ], + "arguments": [ + { + "name": "part_name", + "required": false + }, + { + "name": "material", + "required": false + }, + { + "name": "scale", + "required": false + } + ] + }, + { + "name": "prompt_pid_diagram", + "description": "Generate a prompt for creating a P&ID (Piping and Instrumentation Diagram).", + "tags": [ + "pid", + "template" + ], + "arguments": [ + { + "name": "project_name", + "required": false + }, + { + "name": "revision", + "required": false + } + ] + }, + { + "name": "prompt_quick_drawing", + "description": "Generate step-by-step instructions for creating a drawing from a description.", + "tags": [ + "template", + "utility" + ], + "arguments": [ + { + "name": "description", + "required": true + } + ] + } + ] +} diff --git a/engineering/fits.py b/engineering/fits.py index 4c87186..af689ab 100644 --- a/engineering/fits.py +++ b/engineering/fits.py @@ -10,14 +10,25 @@ Scope (deliberate, documented): - Nominal sizes over 1 mm up to and including 500 mm. - IT grades 4-11 (IT4 is carried for the shaft-k rule and future hole mirrors). -- Shaft letters: d, e, f, g, h, js, k (IT4-7), m, n, p. -- Hole letters: D, E, F, G, H, JS (mirrored clearance letters + H + JS). -- Transition/interference HOLE letters (K, M, N, P) require the ISO 286 - delta-rule and sub-stepped r/s tables; they are intentionally excluded and - raise a ValueError naming the supported set. +- Shaft letters: d, e, f, g, h, js, k (IT4-7), m, n, p, and the interference + shafts r, s, t, u (t from 24 mm, where ISO 286 begins defining it). +- Hole letters: D, E, F, G, H, JS (mirrored clearance letters + H + JS), and + K, M, N, P via the ISO 286 delta rule ``ES = -ei + Δ``, ``Δ = IT(n) − + IT(n−1)``. That is a rule over tables already here, not new table data, which + is why it can be pinned against published values rather than trusted. + +r/s/t/u *are* new table data, sub-stepped more finely than the main size steps, +so they are cross-checked in the tests against ISO 286-1's own derivation +formulae — an independent source. r reproduces as the geometric mean of p and s +on 24/24 steps (with p pinned before r existed); s, t and u track their +IT7-based formulae within 1 µm on every step from 18 mm up, and the five places +where the standard's rounding puts a published value 2-3 µm off its own formula +are listed by name in the tests rather than hidden. This covers the preferred general-mechanical fits (H7/g6, H7/h6, H7/k6, -H7/n6, H7/p6, H8/f7, H9/d9, G7/h6, F8/h7, JS-symmetric, ...). +H7/n6, H7/p6, H8/f7, H9/d9, G7/h6, F8/h7, JS-symmetric, ...), the hole-basis +transition and interference pairs (K7/h6, M7/h6, N7/h6, P7/h6), and the heavy +press/drive fits (H7/r6, H7/s6, H7/t6, H7/u6). """ from __future__ import annotations @@ -78,10 +89,206 @@ _SHAFT_EI: dict[str, tuple[int, ...]] = { "p": (6, 12, 15, 18, 22, 26, 32, 37, 43, 50, 56, 62, 68), } -SUPPORTED_SHAFT_LETTERS = ("d", "e", "f", "g", "h", "js", "k", "m", "n", "p") -SUPPORTED_HOLE_LETTERS = ("D", "E", "F", "G", "H", "JS") +# Interference shafts r/s/t/u are sub-stepped more finely than the main steps: +# ISO 286 splits >18-30 into 18-24/24-30, >30-50 into 30-40/40-50, and every step +# above 50 mm in two or three. Flattening them onto the main steps is a silent +# error of tens of microns at the sizes these fits are actually used at. +_FINE_SIZE_STEPS: tuple[tuple[float, float], ...] = ( + (1, 3), + (3, 6), + (6, 10), + (10, 18), + (18, 24), + (24, 30), + (30, 40), + (40, 50), + (50, 65), + (65, 80), + (80, 100), + (100, 120), + (120, 140), + (140, 160), + (160, 180), + (180, 200), + (200, 225), + (225, 250), + (250, 280), + (280, 315), + (315, 355), + (355, 400), + (400, 450), + (450, 500), +) -_FIT_CODE_RE = re.compile(r"^\s*(JS|js|[A-Ha-h]|[k-pk-p])(\d{1,2})\s*$") +# Fundamental deviations ei (µm) for the interference shafts, one per fine step. +# +# PROVENANCE — this is table data, not a rule, so it is cross-checked in +# tests/test_fits.py against ISO 286-1's own derivation formulae rather than +# trusted on its own: +# r = geometric mean of p and s -> 24/24 steps agree within 1 µm, and p +# was already pinned before r existed +# s (>50 mm) = IT7 + 0.40*D -> agrees within 1 µm +# s (<=50 mm) = IT8 + 0..4 -> monotone inside the standard's band +# t = IT7 + 0.63*D -> 17/19 within 1 µm +# u = IT7 + 1.00*D -> 17/20 within 1 µm +# D is the geometric mean of the step bounds. The handful of steps where the +# standard's rounding puts the published value 2-3 µm off its own formula are +# listed by name in KNOWN_FORMULA_DIVERGENCES, so a reader holding ISO 286 has +# five numbers to check rather than ninety-five. +# +# t is undefined below 24 mm in the standard and is None there — refused, not +# extrapolated. +_SHAFT_EI_FINE: dict[str, tuple[int | None, ...]] = { + "r": ( + 10, + 15, + 19, + 23, + 28, + 28, + 34, + 34, + 41, + 43, + 51, + 54, + 63, + 65, + 68, + 77, + 80, + 84, + 94, + 98, + 108, + 114, + 126, + 132, + ), + "s": ( + 14, + 19, + 23, + 28, + 35, + 35, + 43, + 43, + 53, + 59, + 71, + 79, + 92, + 100, + 108, + 122, + 130, + 140, + 158, + 170, + 190, + 208, + 232, + 252, + ), + "t": ( + None, + None, + None, + None, + None, + 41, + 48, + 54, + 66, + 75, + 91, + 104, + 122, + 134, + 146, + 166, + 180, + 196, + 218, + 240, + 268, + 294, + 330, + 360, + ), + "u": ( + 18, + 23, + 28, + 33, + 41, + 48, + 60, + 70, + 87, + 102, + 124, + 144, + 170, + 190, + 210, + 236, + 258, + 284, + 315, + 350, + 390, + 435, + 490, + 540, + ), +} + + +def _fine_size_index(nominal_mm: float) -> int: + for index, (over, up_to) in enumerate(_FINE_SIZE_STEPS): + if over < nominal_mm <= up_to: + return index + raise ValueError( # pragma: no cover - _size_index rejects out-of-range first + f"No ISO 286 sub-step covers {nominal_mm} mm" + ) + + +SUPPORTED_SHAFT_LETTERS = ("d", "e", "f", "g", "h", "js", "k", "m", "n", "p", "r", "s", "t", "u") +SUPPORTED_HOLE_LETTERS = ("D", "E", "F", "G", "H", "JS", "K", "M", "N", "P") + +_FIT_CODE_RE = re.compile(r"^\s*(JS|js|[A-Ha-h]|[K-Pk-p]|[r-ur-u])(\d{1,2})\s*$") + +#: Holes derived from the same-letter shaft by the ISO 286 delta rule. No new +#: table data: ES = -ei + Δ, with Δ = IT(n) − IT(n−1) taken from _IT_TABLE. +_DELTA_RULE_HOLES = ("K", "M", "N", "P") + +#: The highest IT grade the delta still applies at. Above it ISO 286 sets Δ = 0, +#: so the hole is the exact mirror of the shaft. +_DELTA_MAX_GRADE = {"K": 8, "M": 8, "N": 8, "P": 7} + + +def _delta_rule_es(letter: str, grade: int, size_index: int, it_um: int) -> float: + """Upper deviation of a transition/interference hole (ISO 286 delta rule). + + ``ES = -ei + Δ`` where ``ei`` is the same-letter shaft's fundamental + deviation and ``Δ = IT(n) − IT(n−1)``. Both inputs are already authored in + this module, so this adds a *rule* rather than a table — and the rule is + pinned against published values in the tests, which is what makes it + checkable at all. + """ + if grade <= _IT_GRADES[0]: + raise ValueError( + f"Fit {letter}{grade}: the ISO 286 delta rule needs IT{grade - 1}, which is " + f"below the authored IT{_IT_GRADES[0]}-IT{_IT_GRADES[-1]} range. Use IT5 or " + "coarser for a transition/interference hole." + ) + ei = float(_SHAFT_EI[letter.lower()][size_index]) + if grade > _DELTA_MAX_GRADE[letter]: + return -ei # ISO 286: above this grade the delta is zero + previous_it = _IT_TABLE[size_index][_IT_GRADES.index(grade) - 1] + return -ei + float(it_um - previous_it) @dataclass(frozen=True) @@ -163,12 +370,23 @@ def fit_lookup(code: str, nominal_mm: float) -> FitDeviation: else: ei = float(_SHAFT_EI[letter][index]) upper_um, lower_um = ei + it_um, ei + elif letter in _DELTA_RULE_HOLES: # K, M, N, P — transition/interference holes + es = _delta_rule_es(letter, grade, index, it_um) + upper_um, lower_um = es, es - it_um + elif letter in _SHAFT_EI_FINE: # r, s, t, u — interference shafts + ei_um = _SHAFT_EI_FINE[letter][_fine_size_index(float(nominal_mm))] + if ei_um is None: + raise ValueError( + f"Fit {letter}{grade}: ISO 286 does not define shaft {letter!r} below " + f"24 mm, and {nominal_mm} mm is under it. Use s (defined from 1 mm) " + "for a lighter interference fit, or explicit tol_upper/tol_lower." + ) + ei = float(ei_um) + upper_um, lower_um = ei + it_um, ei else: raise ValueError( f"Fit letter {letter!r} is outside the authored scope. Supported: " - f"shafts {SUPPORTED_SHAFT_LETTERS}, holes {SUPPORTED_HOLE_LETTERS}. " - "Transition/interference hole letters (K, M, N, P) need the ISO 286 " - "delta-rule and are not authored yet." + f"shafts {SUPPORTED_SHAFT_LETTERS}, holes {SUPPORTED_HOLE_LETTERS}." ) return FitDeviation( diff --git a/engineering/measure.py b/engineering/measure.py new file mode 100644 index 0000000..8387d40 --- /dev/null +++ b/engineering/measure.py @@ -0,0 +1,149 @@ +"""Exact area and perimeter for DXF boundary geometry. + +Pure arithmetic: no ezdxf, no COM, no I/O. Both backends call the same functions, +so the headless and live engines cannot drift apart on the numbers, and the tests +exercise the real implementation rather than a stand-in. + +The thing that makes this exact rather than approximate is the **bulge**. A DXF +polyline edge carries a bulge factor, and a non-zero bulge means that edge is a +circular arc — always circular, never a spline — so the enclosed area is the +shoelace of the vertices plus each arc's signed circular segment, and the walked +length is arc length rather than chord length. Both are closed-form. + +Getting this wrong is not a rounding matter. Measured on a 100x100 square with +one edge bulged into an outward semicircle: ignoring bulge reports 10000 against +a true 13926.9908, i.e. **28.2% low**, and a perimeter 12.5% low. +""" + +from __future__ import annotations + +import math +from collections.abc import Sequence + +#: Max chord deviation (drawing units) when geometry has to be flattened because +#: it is not closed-form — splines, partial ellipses. +DEFAULT_FLATTEN_TOLERANCE = 0.001 + +#: Above this vertex count the O(n^2) self-intersection test is skipped and the +#: answer is reported as unknown. A slow honest "I did not check" beats both a +#: hang and a confident False. +MAX_SELF_INTERSECT_VERTS = 2000 + +_EPS = 1e-12 + + +def polygon_area_perimeter( + vertices: Sequence[tuple[float, float, float]], + closed: bool, +) -> tuple[float, float]: + """Exact area and perimeter of a bulge-carrying polygon. + + ``vertices`` are ``(x, y, bulge)``; the bulge belongs to the edge *leaving* + that vertex, which is the DXF convention. + + ``closed=False`` still returns the area of the implicitly closed region — + that is what AutoCAD's AREA command does — but the perimeter is the length + actually walked, without the closing segment. Callers must report the + assumption rather than let it pass silently. + + Two vertices are not degenerate when they carry bulges: that is how DXF + stores a circle built from two semicircular arcs, and the shoelace over its + two points is genuinely 0 while the two circular segments are the entire + shape. Only zero or one vertex bounds nothing. + """ + count = len(vertices) + if count < 2: + return 0.0, _open_length(vertices) + + shoelace = 0.0 + for i in range(count): + x1, y1, _ = vertices[i] + x2, y2, _ = vertices[(i + 1) % count] + shoelace += x1 * y2 - x2 * y1 + shoelace *= 0.5 + + segments = 0.0 + perimeter = 0.0 + limit = count if closed else count - 1 + for i in range(count): + x1, y1, bulge = vertices[i] + x2, y2, _ = vertices[(i + 1) % count] + chord = math.hypot(x2 - x1, y2 - y1) + if abs(bulge) < _EPS or chord < _EPS: + if i < limit: + perimeter += chord + continue + theta = 4.0 * math.atan(bulge) # signed sweep, radians + radius = chord / (2.0 * math.sin(abs(theta) / 2.0)) + # The circular segment cut off by the chord. Signed: a positive bulge + # bows the edge outward and adds material, a negative one removes it. + segments += math.copysign( + radius * radius / 2.0 * (abs(theta) - math.sin(abs(theta))), theta + ) + if i < limit: + perimeter += radius * abs(theta) + + return abs(shoelace + segments), perimeter + + +def _open_length(vertices: Sequence[tuple[float, float, float]]) -> float: + return sum( + math.dist(vertices[i][:2], vertices[i + 1][:2]) for i in range(max(0, len(vertices) - 1)) + ) + + +def is_self_intersecting(vertices: Sequence[tuple[float, float]]) -> bool | None: + """True when non-adjacent edges of the closed loop cross; None if not checked. + + Worth reporting because the shoelace *cancels* crossed lobes rather than + failing: a bowtie made of two 2500-unit triangles measures 0.0, which is a + confident wrong number of exactly the kind this release removes. + """ + count = len(vertices) + if count < 4: + return False + if count > MAX_SELF_INTERSECT_VERTS: + return None # unknown beats a guess + + for i in range(count): + a1, a2 = vertices[i], vertices[(i + 1) % count] + for j in range(i + 1, count): + if j == i or (j + 1) % count == i or j == (i + 1) % count: + continue # adjacent edges share an endpoint by construction + b1, b2 = vertices[j], vertices[(j + 1) % count] + if _segments_cross(a1, a2, b1, b2): + return True + return False + + +def _orientation(p, q, r) -> float: + return (q[0] - p[0]) * (r[1] - p[1]) - (q[1] - p[1]) * (r[0] - p[0]) + + +def _segments_cross(p1, p2, q1, q2) -> bool: + """Proper crossing only — touching at an endpoint is not an intersection.""" + d1 = _orientation(q1, q2, p1) + d2 = _orientation(q1, q2, p2) + d3 = _orientation(p1, p2, q1) + d4 = _orientation(p1, p2, q2) + return ((d1 > _EPS) != (d2 > _EPS)) and ((d3 > _EPS) != (d4 > _EPS)) + + +def ellipse_area(major: float, ratio: float) -> float: + """pi*a*b — closed form, exact.""" + return math.pi * abs(float(major)) * abs(float(major) * float(ratio)) + + +def ellipse_perimeter_ramanujan(major: float, ratio: float) -> float: + """Ramanujan's second approximation. + + Relative error is around 1e-10 for ratio >= 0.1, but it is an approximation + and not closed form, so anything reporting it must set ``perimeter_exact`` + to False. Exact for a circle (ratio == 1), which is the useful sanity check. + """ + a = abs(float(major)) + b = abs(float(major) * float(ratio)) + if a < _EPS or b < _EPS: + return 4.0 * max(a, b) + h = ((a - b) ** 2) / ((a + b) ** 2) + return math.pi * (a + b) * (1.0 + (3.0 * h) / (10.0 + math.sqrt(4.0 - 3.0 * h))) diff --git a/engineering/preflight.py b/engineering/preflight.py index cf605fe..661233b 100644 --- a/engineering/preflight.py +++ b/engineering/preflight.py @@ -108,13 +108,18 @@ def preflight_drawing( questions: list[PreflightQuestion] = [] required = ( - ("units", "MISSING_UNITS", "Çizim birimi nedir?", ["mm", "inch"]), - ("part_type", "MISSING_PART_TYPE", "Üretilecek parça/çizim türü nedir?", []), - ("dimensions", "MISSING_DIMENSIONS", "Ana üretim ölçüleri nelerdir?", []), + ("units", "MISSING_UNITS", "What drawing units should be used?", ["mm", "inch"]), + ( + "part_type", + "MISSING_PART_TYPE", + "What kind of part or drawing is being produced?", + [], + ), + ("dimensions", "MISSING_DIMENSIONS", "What are the main production dimensions?", []), ( "tolerance_policy", "MISSING_TOLERANCE_POLICY", - "Hangi tolerans politikası kullanılmalı?", + "Which tolerance policy should apply?", ["ISO 2768-m", "explicit", "none"], ), ) @@ -138,7 +143,7 @@ def preflight_drawing( "CONFLICTING_REQUIREMENT", field_name, [seen_constraints[field_name], value], - f"{field_name} için birbiriyle çelişen değerler verildi.", + f"Conflicting values were supplied for {field_name}.", ) ) else: diff --git a/engineering/titleblock.py b/engineering/titleblock.py index 97bf82a..5bc39d6 100644 --- a/engineering/titleblock.py +++ b/engineering/titleblock.py @@ -59,11 +59,50 @@ async def apply_iso_a3_titleblock( *, metadata: TitleBlockMetadata, origin: tuple[float, float] = (0.0, 0.0), + layout: str | None = None, ) -> dict: - """Draw a 420x297 ISO 7200 title block at `origin` (lower-left of sheet).""" + """Draw a 420x297 ISO 7200 title block at `origin` (lower-left of sheet). + + With `layout`, the sheet is drawn on that paper-space layout — which is + where a title block belongs, since the border frames the printed sheet + rather than the model. The caller's current space is restored afterwards, so + asking for a sheet border does not silently move them onto the sheet. + Without `layout` it draws in whatever space is current, as it always did. + """ ox, oy = float(origin[0]), float(origin[1]) + previous_space = "Model" + if layout is not None: + listing = await backend.layout_list() + names = {str(entry) for entry in (listing.get("layouts") or [])} + if layout not in names: + raise RuntimeError( + f"apply_iso_a3_titleblock: layout {layout!r} does not exist " + f"(have: {', '.join(sorted(str(n) for n in names))}). Create it with " + "layout_create first." + ) + previous_space = getattr(backend, "_current_space", "Model") + switched = await backend.layout_set_current(layout) + if not switched.get("ok", True): + raise RuntimeError(f"apply_iso_a3_titleblock: {switched.get('error')}") + + try: + return await _draw_titleblock(backend, metadata=metadata, ox=ox, oy=oy, layout=layout) + finally: + if layout is not None: + await backend.layout_set_current(previous_space) + + +async def _draw_titleblock( + backend: AutoCADBackend, + *, + metadata: TitleBlockMetadata, + ox: float, + oy: float, + layout: str | None, +) -> dict: + # ---- outer + inner borders ------------------------------------------------- outer_pts = [ [ox, oy], @@ -263,6 +302,8 @@ async def apply_iso_a3_titleblock( ) return { + "ok": True, + "layout": layout or "Model", "outer_border": outer.handle, "inner_border": inner.handle, "titleblock_lines": titleblock_lines, diff --git a/pyproject.toml b/pyproject.toml index 1337164..a5b4d4a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "autocad-mcp-pro" -version = "1.4.0" +version = "1.5.0" description = "Production-grade AutoCAD MCP server — COM API + ezdxf dual engine" readme = "README.md" requires-python = ">=3.11" @@ -32,9 +32,31 @@ classifiers = [ "Topic :: Software Development :: Libraries :: Python Modules", ] dependencies = [ - "fastmcp>=3.0", - "ezdxf>=1.3", - "pydantic>=2.0", + # These ranges are what an *installer* gets from PyPI, so they stay ranges: + # a library that pins its dependencies exactly is unco-installable. They are + # deliberately narrow because a range is all a `pip install` has to protect + # it. The floor of each range is the version this release is tested against. + # + # What CI and contributors get is different and exact: uv.lock pins the + # whole transitive graph (including the dev tools) to the set the suite was + # last run against, and every locked lane installs with `--locked` so an + # untested upstream cannot appear silently. Ranges guard users; the lock + # guards us. Regenerate the lock with `uv lock` whenever these change. + # + # Every ceiling is the next major. A tighter cap (e.g. `<3.5`) would block a + # user whose environment already holds a newer minor from installing us at + # all, and it buys nothing here: the lock is what protects our own runs, and + # `deps-floating.yml` runs the suite against unpinned latest on a schedule, + # so a breaking upstream minor surfaces as a failing scheduled job rather + # than as a silent CI-green-but-users-broken state. + # + # fastmcp 3.x moves fast and `fastmcp` is now a thin meta-package + # (Requires-Dist: fastmcp-slim[client,server]), so a new minor can reshape + # the API we build on. If deps-floating goes red, add a targeted `!=` for + # the bad version rather than lowering this ceiling. + "fastmcp>=3.4.5,<4", + "ezdxf>=1.4,<2", + "pydantic>=2.0,<3", ] [project.urls] @@ -44,11 +66,20 @@ Issues = "https://github.com/U-C4N/Autocad-MCP/issues" Changelog = "https://github.com/U-C4N/Autocad-MCP/blob/main/CHANGELOG.md" [project.optional-dependencies] +# Rendering needs an extra — the core install cannot draw pixels. +# ezdxf.addons.drawing.frontend does an unconditional top-level `import +# PIL.Image`, so Pillow is required by *every* ezdxf render path (PNG +# screenshot and PDF export alike), not only by the COM window-capture path +# it is listed under below. `pip install autocad-mcp-pro` on its own therefore +# renders nothing. Install `[pdf]` (or `[full]`) for rendering on any platform: +# Pillow comes in transitively as a matplotlib dependency. com = [ "pywin32>=306", + # Win32 window capture for the live-AutoCAD screenshot path. "Pillow>=10.0", ] pdf = [ + # Also satisfies ezdxf's hard Pillow requirement, transitively. "matplotlib>=3.8", ] full = [ @@ -63,12 +94,24 @@ autocad-mcp = "server:main" # without a --from indirection (MCP registry / uvx convention). autocad-mcp-pro = "server:main" -[tool.uv] -dev-dependencies = [ +# PEP 735 dependency group (was [tool.uv] dev-dependencies, which uv now warns +# is deprecated). The standard spelling is understood by uv *and* by pip >=25.1 +# (`pip install --group dev`), so the dev toolchain has one definition whether +# or not you use uv. +# +# These are the exact gates CI runs, and uv.lock freezes them: `uv sync +# --locked` reproduces this list bit-for-bit, so a lint or test job can no +# longer be broken by a tool release nobody chose. +[dependency-groups] +dev = [ "pytest>=8.0", "pytest-asyncio>=0.23", "pytest-cov>=5.0", - "ruff>=0.8", + # Exact pin *and* locked: the formatter's output changes between minors, so + # a floating ruff makes `ruff format --check .` fail on unchanged code. This + # is now the only place the version is written — the workflows install ruff + # from the lock rather than restating it. + "ruff==0.16.1", ] [build-system] @@ -78,7 +121,15 @@ build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] # Flat layout: no directory matches the project name, so hatchling needs an # explicit file selection (GH issue #2). server.py imports config + security. -only-include = ["server.py", "config.py", "security.py", "version.py", "backends", "engineering"] +only-include = [ + "server.py", + "config.py", + "security.py", + "version.py", + "backends", + "engineering", + "discovery", +] [tool.ruff] line-length = 100 diff --git a/scripts/generate_tool_inventory.py b/scripts/generate_tool_inventory.py new file mode 100644 index 0000000..ccf93e7 --- /dev/null +++ b/scripts/generate_tool_inventory.py @@ -0,0 +1,258 @@ +"""Generate ``docs/tool-inventory.json`` — a machine-readable snapshot of the surface. + +Everything here is derived from the **live component registry**, never by +parsing server.py's text. That is the whole point: the release gates used to +count the literal string ``@mcp.tool(`` in server.py, which made that string +load-bearing anywhere in the file (writing it in a docstring inflated the +count, so the one docstring that needed the example had to spell it without its +parentheses). A snapshot taken from the registry cannot be fooled by prose, and +it carries far more than a count — every tool's group, cost and summary, plus +the resource and prompt inventories. + +The snapshot is read back by ``tests/test_tool_inventory.py`` (drift gate) and +by ``tests/test_release_consistency.py`` (README release-snapshot counts). + +Usage:: + + python scripts/generate_tool_inventory.py # write the file + python scripts/generate_tool_inventory.py --check # exit 1 if it drifted + +``--check`` is the same comparison the test makes, for use outside pytest. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import sys +import tomllib +from pathlib import Path +from typing import Any + +# Make the project root importable when this script is run from anywhere. +_PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(_PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(_PROJECT_ROOT)) + +#: Where the generated snapshot lives (docs, not a shipped package file — it is +#: deliberately absent from the wheel's ``only-include`` and from the Docker +#: image, so the Dockerfile/wheel parity gate is unaffected). +INVENTORY_PATH = _PROJECT_ROOT / "docs" / "tool-inventory.json" + +#: Path as written in messages, so a failure reads the same on every platform. +INVENTORY_REL_PATH = "docs/tool-inventory.json" + +#: The one string every failure message must contain. +REGENERATE_COMMAND = "python scripts/generate_tool_inventory.py" + +#: Bump when the document's shape changes (not when its contents change). +SCHEMA_VERSION = 1 + +_FILE_COMMENT = ( + "GENERATED FILE - do not edit by hand. Derived from the live FastMCP " + f"component registry; regenerate with `{REGENERATE_COMMAND}`." +) + +# Ordered so a diff of the annotations block is stable. +_ANNOTATION_FIELDS = ( + "title", + "readOnlyHint", + "destructiveHint", + "idempotentHint", + "openWorldHint", +) + + +def _pyproject_version() -> str: + """The version every other release gate keys off (pyproject is the source).""" + with (_PROJECT_ROOT / "pyproject.toml").open("rb") as stream: + return str(tomllib.load(stream)["project"]["version"]) + + +def _components() -> dict[str, Any]: + """The untransformed local component registry — tools, resources, prompts. + + ``server._registered_tools()`` reads the same mapping for the tool half; this + is the resource/prompt half of it. Reading the registry (rather than + ``list_resources`` / ``list_prompts``) keeps the snapshot below every + transform layer, so what gets committed is what is *registered*, not what + some discovery mode happens to advertise. + """ + import server + + return server.mcp._local_provider._components + + +def _annotations(tool: Any) -> dict[str, Any]: + """The tool's own MCP annotations, minus the fields it left unset.""" + annotations = getattr(tool, "annotations", None) + if annotations is None: + return {} + return { + field: getattr(annotations, field) + for field in _ANNOTATION_FIELDS + if getattr(annotations, field, None) is not None + } + + +def _tool_rows(tools: list[Any], groups: dict[str, list[str]]) -> list[dict[str, Any]]: + """One row per registered tool, sorted by name. + + ``cost`` and ``summary`` come from the ``meta["cad"]`` card that ``@cad_tool`` + writes; ``group`` from ``server._tool_groups()``, i.e. from the tool's own + tags. Aliases are deliberately *not* copied here — ``discovery/aliases.py`` + is their single source of truth and already has its own coverage gate. + """ + group_of = {name: label for label, names in groups.items() for name in names} + rows = [] + for tool in tools: + cad = (getattr(tool, "meta", None) or {}).get("cad") or {} + rows.append( + { + "name": tool.name, + "group": group_of.get(tool.name), + "cost": cad.get("cost"), + "summary": cad.get("summary"), + "tags": sorted(getattr(tool, "tags", None) or ()), + "annotations": _annotations(tool), + } + ) + return sorted(rows, key=lambda row: row["name"]) + + +def _resource_rows(components: dict[str, Any]) -> list[dict[str, Any]]: + """Plain resources and resource templates in one uniformly-shaped list. + + FastMCP files them under two registry prefixes (``resource:`` and + ``template:``); the README counts them as one number, so they are merged and + told apart by ``templated``. + """ + rows = [] + for key, component in components.items(): + if key.startswith("resource:"): + uri, templated, parameters = str(component.uri), False, [] + elif key.startswith("template:"): + uri, templated = str(component.uri_template), True + parameters = sorted( + (getattr(component, "parameters", None) or {}).get("properties", {}) + ) + else: + continue + rows.append( + { + "uri": uri, + "name": component.name, + "description": component.description or "", + "mime_type": component.mime_type, + "tags": sorted(getattr(component, "tags", None) or ()), + "templated": templated, + "parameters": parameters, + } + ) + return sorted(rows, key=lambda row: row["uri"]) + + +def _prompt_rows(components: dict[str, Any]) -> list[dict[str, Any]]: + rows = [] + for key, component in components.items(): + if not key.startswith("prompt:"): + continue + rows.append( + { + "name": component.name, + "description": component.description or "", + "tags": sorted(getattr(component, "tags", None) or ()), + "arguments": [ + {"name": argument.name, "required": bool(argument.required)} + for argument in (component.arguments or []) + ], + } + ) + return sorted(rows, key=lambda row: row["name"]) + + +async def build_inventory() -> dict[str, Any]: + """Build the snapshot document from the live registry. + + Raises rather than emitting an empty section: committing an inventory that + silently lost every tool would be worse than a broken generator, because the + drift gate would then happily lock the emptiness in. + """ + import server + + components = _components() + tools = await server._registered_tools() + groups = await server._tool_groups() + tool_rows = _tool_rows(tools, groups) + resource_rows = _resource_rows(components) + prompt_rows = _prompt_rows(components) + for label, rows in ( + ("tools", tool_rows), + ("resources", resource_rows), + ("prompts", prompt_rows), + ): + if not rows: + raise RuntimeError( + f"refusing to write an inventory with zero {label}: the component " + "registry looks unreadable (FastMCP layout change?)" + ) + return { + "_comment": _FILE_COMMENT, + "schema_version": SCHEMA_VERSION, + "package_version": _pyproject_version(), + "totals": { + "tools": len(tool_rows), + "resources": len(resource_rows), + "prompts": len(prompt_rows), + "groups": len(groups), + }, + "groups": {label: len(names) for label, names in sorted(groups.items())}, + "tools": tool_rows, + "resources": resource_rows, + "prompts": prompt_rows, + } + + +def render(inventory: dict[str, Any]) -> str: + """Serialize deterministically: same registry in, byte-identical text out.""" + return json.dumps(inventory, indent=2, ensure_ascii=False) + "\n" + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Generate docs/tool-inventory.json from the live MCP registry." + ) + parser.add_argument( + "--check", + action="store_true", + help="do not write; exit 1 if the committed file has drifted from the live surface", + ) + args = parser.parse_args(argv) + + text = render(asyncio.run(build_inventory())) + + if args.check: + current = INVENTORY_PATH.read_text(encoding="utf-8") if INVENTORY_PATH.exists() else None + if current == text: + print(f"ok: {INVENTORY_REL_PATH} matches the live surface") + return 0 + reason = "is missing" if current is None else "has drifted from the live surface" + print(f"DRIFT: {INVENTORY_REL_PATH} {reason}") + print(f" regenerate it with: {REGENERATE_COMMAND}") + return 1 + + INVENTORY_PATH.parent.mkdir(parents=True, exist_ok=True) + INVENTORY_PATH.write_text(text, encoding="utf-8", newline="\n") + inventory = json.loads(text) + totals = inventory["totals"] + print( + f"wrote {INVENTORY_REL_PATH}: {totals['tools']} tools, " + f"{totals['resources']} resources, {totals['prompts']} prompts, " + f"{totals['groups']} groups" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/security.py b/security.py index 6fb121b..1952102 100644 --- a/security.py +++ b/security.py @@ -6,6 +6,7 @@ Path traversal protection and command injection prevention. from __future__ import annotations import logging +import os import re from pathlib import Path @@ -29,6 +30,38 @@ _BLOCKED_PATH_PATTERNS = re.compile( ) +_DRIVE_ANCHOR = re.compile(r"^[A-Za-z]:$") + + +def _reject_non_local_anchor(original: str, resolved: Path) -> None: + r"""Every guard below compares against plain ``X:\`` paths. + + Windows accepts three other anchors that name the same files but compare + unequal: the extended-length prefix (``\\?\C:\...``), UNC (``\\host\share``) + and the admin shares (``\\localhost\C$``). The backslash spellings are + already rejected by ``_BLOCKED_PATH_PATTERNS``, but ``pathlib`` normalises + the *forward-slash* spellings into them only after ``.resolve()`` — so they + slipped past the pattern check and then failed to match ``C:/Windows`` in + the system-directory loop. Proven: ``//?/C:/Windows/win.ini`` handed back a + working handle to ``C:\Windows\win.ini``. + + A hard input rejection, so a plain ``ToolError`` like its siblings — not a + capability refusal, which means "this backend cannot" and would wrongly + suggest another engine would accept it. + """ + drive = resolved.drive + if _DRIVE_ANCHOR.match(drive): + return + log.warning("Blocked non-local path anchor: %s -> %s", original, resolved) + raise ToolError( + f"Path rejected: '{original}' resolves to '{resolved}', which is not a " + f"plain local drive path (anchor '{drive}'). Extended-length (\\\\?\\), " + "device and UNC (\\\\host\\share, \\\\localhost\\C$) paths bypass the " + "system-directory and allowed-root checks and are not accepted. Use a " + "normal 'X:\\folder\\file.dxf' path." + ) + + def validate_path(path: str, *, allow_write: bool = False) -> Path: """Resolve and validate a file path against security rules. @@ -49,6 +82,12 @@ def validate_path(path: str, *, allow_write: bool = False) -> Path: resolved = Path(path).resolve() + # After .resolve() (drive is only meaningful once resolved) and before the + # system-directory loop, whose comparisons this is protecting. POSIX has no + # drive component at all, so the check is Windows-only by construction. + if os.name == "nt": + _reject_non_local_anchor(path, resolved) + _SYSTEM_DIRS = [ Path("C:/Windows").resolve(), Path("C:/Program Files").resolve(), @@ -103,56 +142,107 @@ def validate_path(path: str, *, allow_write: bool = False) -> Path: # --------------------------------------------------------------------------- # Command / LISP sanitization +# +# Why it is NOT incoherent to block INSERT/SAVEAS/NEW/OPEN/PURGE/ERASE here +# while `block_insert` / `drawing_save_as` / `drawing_new` / `drawing_open` / +# `drawing_purge` / `entity_delete` are tools — this gets re-raised, so it is +# recorded once: +# +# `sanitize_command` and `sanitize_lisp` each have exactly ONE caller, both of +# them the COM-only free-text escape hatch into SendCommand. The policy is +# scoped to the CHANNEL, not to the operation, which is capability narrowing +# rather than a contradiction: +# +# * `block_insert(name, x, y, ...)` takes a block ALREADY in the drawing plus +# typed geometry. The INSERT *command* takes a file path and pulls in an +# external DWG, which can carry an attached VBA/LISP payload. Same name, +# different capability. +# * `entity_delete(handle)` deletes one named entity. `ERASE ALL` does not. +# * `drawing_save_as` / `_open` / `_new` route their path through +# validate_path. A free-text `SAVEAS C:\...` routes through nothing. # --------------------------------------------------------------------------- -_DANGEROUS_COMMANDS = [ - "ERASE", - "_ERASE", - "DELETE", - "_DELETE", - "PURGE", - "_PURGE", - "QUIT", - "_QUIT", - "CLOSE", - "_CLOSE", - "QSAVE", - "_QSAVE", - "SAVEAS", - "_SAVEAS", - "NEW", - "_NEW", - "OPEN", - "_OPEN", - "RECOVER", - "_RECOVER", - "WBLOCK", - "_WBLOCK", - "INSERT", - "_INSERT", - "XREF", - "_XREF", - "SHELL", - "_SHELL", - "SCRIPT", - "_SCRIPT", - "APPLOAD", - "_APPLOAD", - "NETLOAD", - "_NETLOAD", - "VBARUN", - "_VBARUN", - "VBALOAD", - "_VBALOAD", - "SECURITYOPTIONS", - "_SECURITYOPTIONS", -] - -_DANGEROUS_COMMAND_PATTERNS = re.compile( - r"\b(" + "|".join(re.escape(cmd) for cmd in _DANGEROUS_COMMANDS) + r")\b", - re.IGNORECASE, +# Matched against the extracted VERB, so the "_XXX" duplicates the old list +# carried are gone — command_verbs() strips every decorator AutoCAD accepts. +# +# Single-letter aliases are deliberately absent. `E` is the ERASE alias, but it +# is also the Extents *option* of ZOOM, so blocking it refuses `_ZOOM\nE\n` — +# the canonical zoom-extents macro, and this repo's own safe-command fixture. +# A guardrail that blocks the most common harmless macro gets switched off, and +# then it guards nothing. +_DANGEROUS_COMMANDS = frozenset( + { + # destructive to the drawing + "ERASE", + "DELETE", + "PURGE", + "WBLOCK", + "INSERT", + "XREF", + # ends or overwrites the session / file + "QUIT", + "EXIT", + "CLOSE", + "CLOSEALL", + "QSAVE", + "SAVE", + "SAVEAS", + "NEW", + "OPEN", + "RECOVER", + "RECOVERALL", + # loads or runs code — every channel found while probing + "SHELL", + "SCRIPT", + "SCR", + "APPLOAD", + "NETLOAD", + "ARX", + "VBARUN", + "VBALOAD", + "VBASTMT", + "VBAIDE", + "VBAMAN", + "CUILOAD", + "MENULOAD", + "MENU", + "CUI", + # writes files outside the tool layer's path validation + "EXPORT", + "ETRANSMIT", + "PUBLISH", + "SECURITYOPTIONS", + } ) +# AutoCAD accepts a command with any mix of these leading decorators: +# _ international (locale-independent) form +# . suppress a redefined command +# - the command-line (no dialog) variant +# ' transparent +_COMMAND_DECORATORS = "_.-*'" + + +def command_verbs(command: str) -> list[str]: + """Every command verb in a SendCommand string. + + SendCommand takes a whole macro, not one command: segments are separated by + a newline or ``;``, and only the FIRST token of a segment is a verb — the + rest is argument data. Scanning the raw string with a word-boundary regex is + what refused ``MTEXT 0,0 10,10 Do not erase this part``, which is annotation + text, while still missing spellings AutoCAD accepts. + """ + verbs: list[str] = [] + for segment in re.split(r"[\n;]+", command): + segment = segment.strip() + if not segment: + continue + token = segment.split()[0].split(",")[0].lstrip(_COMMAND_DECORATORS) + if token: + verbs.append(token.upper()) + return verbs + + # Dangerous LISP symbols. We match these as **bare tokens anywhere** in the # expression — not only when they appear right after `(` — because aliasing # tricks like `(setq f command)(f "ERASE")` reference the symbol as a value. @@ -191,6 +281,12 @@ _DANGEROUS_LISP_PATTERNS = [ r"\*push-error-using-stack\*", ] +# A double-quoted literal is DATA — drawing text, a layer name, a note. The +# symbol denylist must not read it. Without this, an engineering CAD server +# refused its own vocabulary: '(setq desc "Max load 500 kg")' matched +# r"(?:auto)?load" and '(setq layer "C:CENTER")' matched r"c:[\w-]+". +_LISP_STRING_LITERAL = re.compile(r'"(?:[^"\\]|\\.)*"') + _DANGEROUS_LISP_PATTERN = re.compile( r"(?:(? str: log.info("Dangerous commands enabled — bypassing sanitization for: %s", command[:80]) return command - if _DANGEROUS_COMMAND_PATTERNS.search(command): + blocked = {verb for verb in command_verbs(command) if verb in _DANGEROUS_COMMANDS} + if blocked: log.warning("Blocked dangerous command: %s", command[:120]) raise ToolError( - f"Command rejected: '{command[:80]}' contains a restricted command. " - "Restricted commands include ERASE, DELETE, PURGE, QUIT, SHELL, SCRIPT, etc. " - "Set DANGEROUS_COMMANDS_ENABLED=true to allow all commands." + f"Command rejected: {', '.join(sorted(blocked))} " + f"{'is a restricted command' if len(blocked) == 1 else 'are restricted commands'}. " + "This denylist is a guardrail against accidental destruction, not a " + "security boundary — " + "prefer the typed tools (entity_delete, drawing_save_as, block_insert, " + "drawing_purge), which validate their arguments. Set " + "DANGEROUS_COMMANDS_ENABLED=true to allow all commands." ) return command @@ -236,7 +337,10 @@ def sanitize_lisp(expression: str) -> str: ) return expression - if _DANGEROUS_LISP_PATTERN.search(expression): + # Blank the literals, then scan. The ORIGINAL expression is returned: + # this decides whether to run it, it does not rewrite it. + code_only = _LISP_STRING_LITERAL.sub('""', expression) + if _DANGEROUS_LISP_PATTERN.search(code_only): log.warning("Blocked dangerous LISP expression: %s", expression[:120]) raise ToolError( "LISP expression rejected: contains a restricted function. " diff --git a/server.json b/server.json index 5a82ce9..b61b73b 100644 --- a/server.json +++ b/server.json @@ -7,12 +7,12 @@ "url": "https://github.com/U-C4N/Autocad-MCP", "source": "github" }, - "version": "1.4.0", + "version": "1.5.0", "packages": [ { "registryType": "pypi", "identifier": "autocad-mcp-pro", - "version": "1.4.0", + "version": "1.5.0", "transport": { "type": "stdio" }, @@ -33,7 +33,14 @@ }, { "name": "TOOL_PROFILE", - "description": "Exposed tool profile: full (default), core or lean.", + "description": "Exposed tool profile: full (default) or lean.", + "isRequired": false, + "format": "string", + "isSecret": false + }, + { + "name": "DISCOVERY_MODE", + "description": "Tool discovery: off (default) advertises the full catalog; search replaces it with search_tools plus a call_tool proxy.", "isRequired": false, "format": "string", "isSecret": false diff --git a/server.py b/server.py index 332a7a7..06aff67 100644 --- a/server.py +++ b/server.py @@ -14,7 +14,6 @@ import argparse import json import logging import math -import os import sys import time from dataclasses import asdict @@ -28,10 +27,14 @@ from fastmcp.server.middleware import Middleware, MiddlewareContext from fastmcp.server.middleware.error_handling import ErrorHandlingMiddleware from fastmcp.server.middleware.logging import LoggingMiddleware from fastmcp.server.middleware.timing import TimingMiddleware +from fastmcp.tools.tool import ToolResult from pydantic import Field import config +from backends.base import BlockInfo, EntityInfo, LayerInfo +from discovery.aliases import aliases_for from engineering.fits import fit_lookup +from engineering.measure import is_self_intersecting, polygon_area_perimeter from security import sanitize_command, sanitize_lisp, validate_path from version import __version__ @@ -75,7 +78,12 @@ def _detect_autocad_running() -> bool: async def _make_backend(): """Create the best available backend.""" - backend_env = os.environ.get("AUTOCAD_MCP_BACKEND", "auto").lower().strip() + # Through the setting, not straight off os.environ: reading the environment + # here is what let config.settings.backend drift into a control that + # controlled nothing, so a test believing it had pinned the headless engine + # could reach a live AutoCAD instead. The property reads the environment + # live, so nothing about startup ordering changes. + backend_env = config.settings.backend if backend_env == "ezdxf": from backends.ezdxf_backend import EzdxfBackend @@ -152,7 +160,13 @@ class AuditMiddleware(Middleware): try: result = await call_next(context) elapsed = (time.monotonic() - start) * 1000 - log.info("TOOL OK %-40s %6.1fms", tool_name, elapsed) + # A refusal comes back as a *returned* error result, not a raise + # (see CapabilityRefusalMiddleware), so control flow alone would + # log it as TOOL OK. + if getattr(result, "is_error", False): + log.warning("TOOL ERR %-40s %6.1fms (refused)", tool_name, elapsed) + else: + log.info("TOOL OK %-40s %6.1fms", tool_name, elapsed) return result except Exception as exc: elapsed = (time.monotonic() - start) * 1000 @@ -160,6 +174,54 @@ class AuditMiddleware(Middleware): raise +def _backend_name_or_none(context) -> str | None: + """Which engine refused, for the refusal payload. Must never raise.""" + try: + backend = context.fastmcp_context.lifespan_context.get("backend") + return getattr(backend, "name", None) + except Exception: # a label is never worth breaking a refusal over + return None + + +class CapabilityRefusalMiddleware(Middleware): + """Carry a typed capability refusal across the JSON-RPC boundary. + + ``FastMCP.call_tool`` rewraps a non-``FastMCPError`` as + ``ToolError(f"Error calling tool {name!r}: {e}") from e``. The ``from e`` + keeps the cause alive in-process — which is the only reason ``cad_batch`` + can classify a refusal — but ``__cause__`` does not serialise, so a remote + client used to get an English sentence and had to substring-match it to tell + "this backend cannot" from "your arguments were wrong". + + Middleware runs outside that wrap with the cause chain still intact, so this + is the last place the type is knowable. ``ToolResult(is_error=True)`` is the + only channel that carries both a machine-readable payload *and* the error + flag; rebasing the exception on ``FastMCPError`` carries neither, which is + why no tool function changes here. + """ + + async def on_call_tool(self, context: MiddlewareContext, call_next): + try: + return await call_next(context) + except Exception as exc: + capability = _batch_capability_of(exc) + if capability is None: + raise # not ours — leave it byte-for-byte as it was + message = _refusal_message(exc) + return ToolResult( + content=message, # prose-only clients still get a full sentence + structured_content={ + "ok": False, + "kind": "unsupported", + "capability": capability, + "error": message, + "tool": context.message.name, + "backend": _backend_name_or_none(context), + }, + is_error=True, + ) + + # --------------------------------------------------------------------------- # HTTP auth # --------------------------------------------------------------------------- @@ -230,6 +292,63 @@ mcp.add_middleware(ErrorHandlingMiddleware()) mcp.add_middleware(AuditMiddleware()) mcp.add_middleware(TimingMiddleware()) mcp.add_middleware(LoggingMiddleware()) +# Added last on purpose: first-added is outermost, so this sits innermost — +# closest to the call_tool wrap, where the refusal's __cause__ chain is still +# intact. Anywhere further out and AuditMiddleware would log the raise before +# it becomes a result. +mcp.add_middleware(CapabilityRefusalMiddleware()) + + +# --------------------------------------------------------------------------- +# Tool discovery mode (DISCOVERY_MODE) +# --------------------------------------------------------------------------- +# "off" the whole catalog is advertised (default, backwards compatible) +# "search" list_tools returns a search tool + a call_tool proxy instead, and +# the catalog is reached through discovery/transform.py — which +# indexes each tool's AutoCAD command aliases as well as its +# description, so BPOLY/QSELECT/MATCHPROP resolve at all. +# +# Tool *counts* are unaffected: system_status / system_about / _tool_groups / +# _apply_tool_profile all read the untransformed registry (_registered_tools). + +DISCOVERY_MODES = ("off", "search") + +_discovery_transform = None + + +def _apply_discovery_mode(mode: str | None = None) -> str: + """Attach or detach the tool-search transform. Returns the mode applied. + + Idempotent: re-applying removes the previous transform first, so a mode + switch never stacks two search interfaces on one server. + """ + global _discovery_transform + selected = (mode or config.settings.discovery_mode or "off").lower().strip() + if selected not in DISCOVERY_MODES: + log.warning( + "Unknown DISCOVERY_MODE %r - falling back to 'off'. Valid modes: %s.", + selected, + ", ".join(DISCOVERY_MODES), + ) + selected = "off" + if _discovery_transform is not None: + try: + mcp._transforms.remove(_discovery_transform) + except (AttributeError, ValueError) as exc: # pragma: no cover - layout change + log.debug("Could not detach the discovery transform: %s", exc) + _discovery_transform = None + if selected == "search": + # Imported lazily so "off" never pays for it, matching how the backend + # modules are kept out of import time. + from discovery.transform import CadSearchTransform + + _discovery_transform = CadSearchTransform() + mcp.add_transform(_discovery_transform) + log.info("Tool discovery: search transform attached (clients see search_tools/call_tool)") + return selected + + +_apply_discovery_mode() # --------------------------------------------------------------------------- @@ -246,6 +365,42 @@ def _backend(ctx: Context): return b +def _backend_supports(backend, capability: str) -> bool: + """True unless `backend` explicitly reports `capability` as unsupported. + + Unknown keys and probe failures answer True: a capability map that has not + heard of a feature is not evidence the feature is missing, and refusing on + that basis would break any backend older than the key. + """ + try: + feature = backend.capabilities().features.get(capability) + except Exception as exc: # a capability probe must never break a tool call + log.debug("capability probe for %r failed: %s", capability, exc) + return True + return feature is None or bool(feature.supported) + + +def _is_dwg_path(path: str) -> bool: + """True when `path` names a DWG file. The extension is authoritative (N2).""" + from pathlib import Path as _P + + return _P(path).suffix.lower() == ".dwg" + + +# T0.2 — the read-side twin of the backend's DWG write refusal. ezdxf detects +# format by content, so a DXF saved under a .dwg name (what pre-1.5.0 +# drawing_save produced) opens fine and gets reported as an opened DWG, while a +# genuine DWG dies with ezdxf's "is not a DXF file" — which blames the file +# instead of the backend. Refuse both, in the backend's own vocabulary. +_DWG_READ_REFUSAL = ( + "drawing_open: backend '{backend}' has no DWG support (capability 'dwg' is " + "unsupported), so '{path}' is refused rather than opened as something else: " + "a DXF saved under a .dwg name would be parsed and reported back as a DWG. " + "Open the .dxf instead, or switch to the live COM backend " + "(AUTOCAD_MCP_BACKEND=com, needs Windows + AutoCAD) to read real DWG." +) + + def _dc(obj) -> dict: """Convert dataclass to dict (recursively).""" if hasattr(obj, "__dataclass_fields__"): @@ -255,33 +410,263 @@ def _dc(obj) -> dict: return obj -async def _registered_tools() -> list: - """Return the FunctionTool objects currently registered on the server. +# --------------------------------------------------------------------------- +# Result shaping — field projection + compact rows +# --------------------------------------------------------------------------- +# +# Discovery cost is only half the token bill; this is the other half. A default +# ``entity_list(limit=100)`` over a 300-entity drawing is ~24 kB of JSON the +# model has to read, and roughly a third of it is the ``bounding_box`` inside +# ``properties`` that nobody asked for. +# +# ONE mechanism, two parameters, spelled identically on every collection tool +# (:data:`SHAPED_RESULT_TOOLS`) — a per-tool parameter set would rot, so +# tests/test_result_projection.py gates the registry against that list in both +# directions and compares the two parameter schemas across every carrier: +# +# ``fields=[...]`` projection. Column subset, caller's order preserved, +# validated against the row's dataclass. A typo is an error +# naming the valid fields — a projection that answered +# ``{"handel": null}`` a hundred times would read as "this +# drawing has no handles", which is worse than no feature. +# ``compact=True`` columnar envelope instead of a list of dicts, so the key +# names are paid for once rather than once per row. It is +# also the only shape with somewhere to put ``total`` / +# ``truncated`` / ``next_offset``. +# +# Omit both and the value is byte-identical to what 1.4.0 returned. That is the +# compatibility contract, and every pre-existing test in the suite is its gate; +# it is also why the honest-truncation metadata could not simply be bolted onto +# the default list return. +# +# `_dc()` stays the only dataclass→dict step: shaping runs *after* it, over +# plain dicts, so the convention is untouched and a backend that grows a field +# gets it projectable for free. - R20: prefer the public FastMCP 3.0.1 accessor (``mcp._list_tools()``, - also exposed as ``mcp.list_tools()``) which returns a ``Sequence[Tool]``. - Only fall back to the private ``_local_provider._components`` registry if - the public path is unavailable. Never raises; returns [] when unknown so - callers can label/omit rather than surface a bogus count. +#: Separator for reaching one key inside a row's nested ``properties`` payload. +FIELD_PATH_SEP = "." + +#: The two parameters, in the order they appear in every shaped signature. +RESULT_SHAPE_PARAMS = ("fields", "compact") + +#: Every tool carrying the mechanism. Closed on purpose and gated both ways: +#: a shaped tool missing from here fails the gate, and a name here that is not +#: a registered carrier fails it too. +SHAPED_RESULT_TOOLS = ( + "analysis_find_in_region", + "analysis_select_by_layer", + "analysis_select_by_type", + "block_find_references", + "block_list", + "entity_array_polar", + "entity_array_rectangular", + "entity_list", + "entity_select_smart", + "layer_list", + "selection_get", +) + +# Both descriptions are paid once per tool in every uncached catalog, so they +# are written to the shortest length that still prevents a wasted round trip. +_FIELDS_DESC = ( + "Project to these fields, in this order (e.g. ['handle','type','layer']); " + "'properties.' reaches one nested value. Omit for the full record; an " + "unknown name errors and lists the valid ones." +) +_COMPACT_DESC = ( + "Return a columnar {fields, rows, count, offset, total, truncated, next_offset} " + "envelope instead of dicts: much cheaper per row, and the only shape that " + "reports truncation." +) + +#: Shared annotations so the two parameters cannot drift tool to tool. +ResultFields = Annotated[list[str] | None, Field(default=None, description=_FIELDS_DESC)] +ResultCompact = Annotated[bool, Field(default=False, description=_COMPACT_DESC)] + + +def _nested_keys(rows: list[dict], root: str) -> list[str]: + """Every sub-key present under `root` anywhere in `rows`.""" + keys: set[str] = set() + for row in rows: + value = row.get(root) + if isinstance(value, dict): + keys.update(value) + return sorted(keys) + + +def _resolve_fields(fields: list[str], rows: list[dict], spec: type, tool: str) -> list[str]: + """Validate a projection against `spec`'s fields; return it de-duplicated. + + Validation is the whole point: an unvalidated projection turns a typo into a + column of nulls, and a column of nulls reads like data. + + Nested sub-keys are checked against the keys this result actually carries, + which is data-dependent by nature (a CIRCLE has ``radius``, a LINE does + not). A sub-key present on *some* rows is legitimate and yields null on the + rest; one present on *none* is the typo case and raises. An empty result is + exempt — there is no column there to misread. + """ + roots = tuple(spec.__dataclass_fields__) + valid = ", ".join(roots) + resolved: list[str] = [] + for raw in fields: + name = str(raw).strip() + root, sep, sub = name.partition(FIELD_PATH_SEP) + if root not in roots: + raise ToolError( + f"{tool}: unknown field {name!r}. Valid fields ({spec.__name__}): {valid}." + ) + if sep: + if not sub: + raise ToolError( + f"{tool}: field {name!r} names no sub-key. " + f"Write '{root}.', or just '{root}' for the whole object." + ) + available = _nested_keys(rows, root) + if rows and sub not in available: + raise ToolError( + f"{tool}: {root!r} carries no {sub!r} in this result. " + f"Available {root} keys here: {', '.join(available) or '(none)'}. " + f"Valid top-level fields: {valid}." + ) + if name not in resolved: + resolved.append(name) + if not resolved: + raise ToolError( + f"{tool}: fields=[] would project nothing. Omit the parameter for the " + f"full record, or name fields: {valid}." + ) + return resolved + + +def _pluck(row: dict, name: str): + """One projected cell. Missing nested keys are null, never a KeyError.""" + root, sep, sub = name.partition(FIELD_PATH_SEP) + value = row.get(root) + if not sep: + return value + return value.get(sub) if isinstance(value, dict) else None + + +def _shape_rows( + rows, + *, + spec: type, + fields: list[str] | None, + compact: bool, + tool: str, + total: int | None = None, + offset: int = 0, +) -> list[dict] | dict: + """Apply the shared projection/compaction to one collection result. + + `rows` are backend dataclasses (or anything ``_dc`` handles); `spec` is the + dataclass they are, which is what makes the field validation possible on an + empty result — the valid names come from the type, not from the data. + + `total` is the size of the matching set *before* paging or capping, so the + envelope can state ``truncated`` as a fact. Passing ``None`` (unknown) makes + ``truncated`` null rather than false: not knowing is not the same as knowing + there is nothing more, and only one of those is safe to read as complete. + """ + dicts = [_dc(row) for row in rows] + # `is None` rather than falsiness: an explicit `fields=[]` is a caller + # mistake worth an error, not a silent fall-through to the full record. + if fields is None and not compact: + return dicts # the 1.4.0 value, byte for byte + names = ( + list(spec.__dataclass_fields__) + if fields is None + else _resolve_fields(fields, dicts, spec, tool) + ) + projected = [{name: _pluck(row, name) for name in names} for row in dicts] + if not compact: + return projected + count = len(projected) + truncated = None if total is None else (offset + count) < total + return { + "fields": names, + "rows": [[row[name] for name in names] for row in projected], + "count": count, + "offset": offset, + "total": total, + "truncated": truncated, + "next_offset": offset + count if truncated else None, + } + + +def _local_tool_components() -> list: + """Tool components straight out of the local registry, synchronously. + + The untransformed source of truth for the whole file: ``_registered_tools`` + reads it, and ``cad_tool`` needs it at import time (before any event loop + exists) to attach metadata to a just-registered tool. + """ + components = mcp._local_provider._components + return [value for key, value in components.items() if key.startswith("tool:")] + + +async def _registered_tools() -> list: + """Return the FunctionTool objects registered on the server, *untransformed*. + + This deliberately reads the local component registry rather than any + ``list_tools`` accessor, so that "what is registered" stays independent of + every transform layer FastMCP can put in front of the catalog. There are two + such layers and they surface at different accessors: + + * *server-level* — ``mcp.add_transform(...)``, which is what + ``_apply_discovery_mode("search")`` installs — is applied by the public + ``mcp.list_tools()``, i.e. at the wire, where a client sees + ``search_tools``/``call_tool`` in place of the catalog. + ``mcp._list_tools()`` sits upstream of that chain and still lists the + full inventory, so search mode as shipped does not move these counts. + * *provider-level* — ``provider.add_transform(...)`` — is applied inside + ``Provider.list_tools()``, which ``mcp._list_tools()`` aggregates. That + one *does* rewrite ``_list_tools()`` wholesale: a transform returning a + couple of synthetic entries makes it report a couple of tools. + + The component registry sits below both, which is what every caller here + needs. Over a rewritten catalog, ``system_status`` / ``system_about`` would + report the advertised surface instead of the inventory, ``_tool_groups()`` + would bucket only what survived the rewrite, and ``_apply_tool_profile()`` + would compute a collapsed ``registered - enabled`` set, never call + ``mcp.disable()``, and silently turn TOOL_PROFILE into a no-op. Reading + pre-transform is what keeps "registered" and "advertised" two separate, + stable numbers under any transform layer, present or future. + + Ordered fallbacks, both untransformed, then ``mcp._list_tools()`` as a last + resort should a future FastMCP rename the private layout — last precisely + because it is the one accessor here a provider-level transform can reach. + Never raises; returns [] when the registry is genuinely unknown so callers + can label/omit rather than surface a bogus count. """ try: - tools = await mcp._list_tools() - return list(tools) - except Exception as exc: # pragma: no cover - public path is the norm - log.debug("public _list_tools() failed, falling back: %s", exc) - try: - components = mcp._local_provider._components - return [v for k, v in components.items() if k.startswith("tool:")] + tools = _local_tool_components() + if tools: + return tools except Exception as exc: - log.debug("private tool registry unavailable: %s", exc) + log.debug("private tool registry unavailable, falling back: %s", exc) + try: + # Same source, different accessor: LocalProvider._list_tools() is the + # pre-transform base that Provider.list_tools() decorates. + tools = await mcp._local_provider._list_tools() + if tools: + return list(tools) + except Exception as exc: + log.debug("private local listing unavailable, falling back: %s", exc) + try: # pragma: no cover - only reachable if both private paths break + return list(await mcp._list_tools()) + except Exception as exc: + log.debug("public _list_tools() failed: %s", exc) return [] async def _registered_tool_count() -> int | None: """Number of @mcp.tool registrations currently on the server. - R20: backed by the public accessor (see ``_registered_tools``). Returns - ``None`` (never -1) when the count is genuinely unknown so system_status / + Backed by the untransformed registry (see ``_registered_tools``), so a + search transform cannot shrink the reported inventory. Returns ``None`` + (never -1) when the count is genuinely unknown so system_status / system_about can omit/label it rather than report a fake value. """ tools = await _registered_tools() @@ -366,18 +751,102 @@ async def _tool_groups() -> dict: return dict(sorted(groups.items())) +# --------------------------------------------------------------------------- +# Discovery metadata channel (@cad_tool) +# --------------------------------------------------------------------------- + +# How much damage a call can do, for a client that wants to gate or colour-code +# the surface before calling it: +# read pure query, no document mutation +# safe mutates, trivially reversible (settings, current layer, view) +# mutate creates or edits geometry +# destructive deletes or overwrites geometry/files +# escape raw AutoCAD command / LISP passthrough +CAD_TOOL_COSTS = ("read", "safe", "mutate", "destructive", "escape") + + +def cad_tool(*, summary: str, cost: str): + """Attach discovery metadata to the ``mcp.tool`` registration below it. + + Usage — this decorator sits **above** the registration:: + + @cad_tool(summary="Open a rollback checkpoint.", cost="mutate") + @mcp.tool # ... with its usual annotations= / tags= arguments + async def transaction_begin(ctx: Context = None) -> dict: ... + + (The example spells the decorator without its argument list only because + the release-consistency gate counts literal registrations by scanning this + file's text, and a docstring example would inflate the count.) + + The ordering is deliberate. ``tags=`` and ``annotations=`` stay on the + ``mcp.tool`` call, so this wrapper never receives them and cannot perturb + ``_tool_groups()`` — whose frozen 20-tag priority list feeds system_about. + It also leaves the registration itself literally intact for that gate. + + Writes a single ``cad`` key into the tool's MCP ``meta`` channel: + + ``summary`` one compact line, for a search hit's preview + ``cost`` one of :data:`CAD_TOOL_COSTS` + ``acad`` AutoCAD command names, from ``discovery.aliases`` + ``synonyms`` natural-language phrasings, from ``discovery.aliases`` + + The alias corpus is imported, never restated here: one edit site, and the + coverage gate over all registered tools already lives with the corpus. + """ + if cost not in CAD_TOOL_COSTS: + raise ValueError(f"cad_tool cost must be one of {CAD_TOOL_COSTS}, got {cost!r}") + + def decorate(target): + # decorator_mode="function" (the default) hands back the function and + # registers the component; "object" hands back the component itself. + name = getattr(target, "name", None) or getattr(target, "__name__", "") + fastmcp_meta = getattr(target, "__fastmcp__", None) + name = getattr(fastmcp_meta, "name", None) or name + component = target if hasattr(target, "meta") else None + if component is None: + component = next((t for t in _local_tool_components() if t.name == name), None) + if component is None: + raise RuntimeError( + f"@cad_tool({name!r}): tool is not registered. cad_tool must be written " + "directly above the mcp.tool decorator, never below it or on a bare function." + ) + record = aliases_for(name) + if record is None: + log.warning("No discovery aliases for tool %r; searches will miss it", name) + meta = dict(component.meta or {}) + meta["cad"] = { + "summary": summary, + "cost": cost, + "acad": list(record.acad) if record else [], + "synonyms": list(record.synonyms) if record else [], + } + component.meta = meta + return target + + return decorate + + # --------------------------------------------------------------------------- # Tool profiles (capability-aware discovery) # --------------------------------------------------------------------------- # Some MCP clients degrade (or hard-cap) when a server exposes 100+ tools. # TOOL_PROFILE selects how much of the surface is advertised: # full — everything (default, backwards compatible) -# core — everything except raw escape hatches and long-tail tools # lean — a curated ~46-tool drafting/inspection core # Disabled tools stay registered (system_about still reports the full # inventory) but are hidden from MCP list_tools and rejected if called. -TOOL_PROFILES = ("lean", "core", "full") +TOOL_PROFILES = ("lean", "full") + +# Profiles that used to exist, mapped to why they went away. Kept so an old +# TOOL_PROFILE value still boots the server (falling back to "full") with a +# warning that names the removed profile instead of a generic "unknown value". +REMOVED_TOOL_PROFILES = { + "core": ( + "the discovery layer (tool search + AutoCAD command aliases) now solves " + "the crowded-surface problem it existed for" + ), +} LEAN_TOOL_NAMES = frozenset( { @@ -436,26 +905,11 @@ LEAN_TOOL_NAMES = frozenset( "system_status", "system_about", "system_capabilities", - } -) - -CORE_EXCLUDED_TOOL_NAMES = frozenset( - { - # raw escape hatches — deliberate opt-in only - "system_run_command", - "system_run_lisp", - # low-level variable access (drawing_settings covers the common cases) - "system_get_variable", - "system_set_variable", - # long-tail / niche tools - "selection_get", - "linetype_list", - "linetype_load", - "block_find_references", - "drawing_undo", - "drawing_redo", - "view_zoom_window", - "entity_create_point", + # batching — the profile exists for clients with tight tool caps, which + # are exactly the clients paying the most per turn. Measured, the big + # lever is turn elimination (9.07x), so a lean surface without cad_batch + # withholds the saving from the callers who need it most. + "cad_batch", } ) @@ -469,8 +923,6 @@ _active_tool_profile: dict | None = None def _profile_enabled_names(profile: str, registered: set[str]) -> set[str]: if profile == "lean": enabled = registered & LEAN_TOOL_NAMES - elif profile == "core": - enabled = registered - CORE_EXCLUDED_TOOL_NAMES else: enabled = set(registered) if not config.settings.enable_3d: @@ -488,9 +940,22 @@ async def _apply_tool_profile(profile: str | None = None) -> dict: switches are idempotent. """ global _active_tool_profile - selected = (profile or config.settings.tool_profile or "full").lower().strip() + requested = (profile or config.settings.tool_profile or "full").lower().strip() + selected = requested if selected not in TOOL_PROFILES: - log.warning("Unknown TOOL_PROFILE %r - falling back to 'full'", selected) + if selected in REMOVED_TOOL_PROFILES: + log.warning( + "TOOL_PROFILE %r was removed (%s) - falling back to 'full'. Valid profiles: %s.", + selected, + REMOVED_TOOL_PROFILES[selected], + ", ".join(TOOL_PROFILES), + ) + else: + log.warning( + "Unknown TOOL_PROFILE %r - falling back to 'full'. Valid profiles: %s.", + selected, + ", ".join(TOOL_PROFILES), + ) selected = "full" registered = {tool.name for tool in await _registered_tools() if getattr(tool, "name", None)} enabled = _profile_enabled_names(selected, registered) @@ -506,6 +971,10 @@ async def _apply_tool_profile(profile: str | None = None) -> dict: "disabled_count": len(disabled), "disabled_tools": disabled, } + if requested != selected: + # Surface the fallback where an MCP client can actually see it: a + # warning on a STDIO server's stderr is routinely invisible. + _active_tool_profile["requested"] = requested log.info( "Tool profile '%s': %d enabled, %d hidden", selected, @@ -520,6 +989,7 @@ async def _apply_tool_profile(profile: str | None = None) -> dict: # --------------------------------------------------------------------------- +@cad_tool(summary="Read the current drawing's name, path, extents and object counts.", cost="read") @mcp.tool( annotations={"title": "Drawing Info", "readOnlyHint": True}, tags={"drawing", "query"}, @@ -535,6 +1005,10 @@ async def drawing_info(ctx: Context) -> dict: return _dc(result) +@cad_tool( + summary="Start a blank drawing, pre-seeded with the standard engineering layers.", + cost="mutate", +) @mcp.tool( annotations={"title": "New Drawing", "destructiveHint": False}, tags={"drawing"}, @@ -581,23 +1055,41 @@ async def drawing_new( return result +@cad_tool( + summary="Load an existing DXF from disk (DWG only on the live AutoCAD backend).", + cost="mutate", +) @mcp.tool( annotations={"title": "Open Drawing"}, tags={"drawing"}, ) async def drawing_open( - path: Annotated[str, "Full path to the .dwg or .dxf file"], + path: Annotated[ + str, + "Full path to the .dxf file. .dwg needs a backend that can read it " + "(the live COM backend); the headless ezdxf backend refuses it.", + ], ctx: Context = None, ) -> dict: - """Open an existing DWG or DXF drawing file.""" + """Open an existing DXF drawing file (DWG too, on the live COM backend). + + T0.2: a .dwg path is refused up front when the active backend has no `dwg` + capability. ezdxf sniffs content rather than extensions, so it would parse a + mislabelled DXF-in-a-.dwg and this tool would answer with a document that + does not exist in that format. + """ validated = validate_path(path, allow_write=False) + backend = _backend(ctx) + if _is_dwg_path(str(validated)) and not _backend_supports(backend, "dwg"): + raise ToolError(_DWG_READ_REFUSAL.format(backend=backend.name, path=validated)) await ctx.info(f"Opening drawing: {validated}") await ctx.report_progress(0, 100) - result = await _backend(ctx).drawing_open(str(validated)) + result = await backend.drawing_open(str(validated)) await ctx.report_progress(100, 100) return result +@cad_tool(summary="Write the drawing back to its file, or to a path you give.", cost="mutate") @mcp.tool( annotations={"title": "Save Drawing"}, tags={"drawing"}, @@ -614,6 +1106,10 @@ async def drawing_save( return await _backend(ctx).drawing_save(path) +@cad_tool( + summary="Save a copy under a new name; the extension picks DXF, DWG or DWT.", + cost="mutate", +) @mcp.tool( annotations={"title": "Save As"}, tags={"drawing"}, @@ -642,6 +1138,7 @@ async def drawing_save_as( return await _backend(ctx).drawing_save_as(str(validated), fmt) +@cad_tool(summary="Write a DXF interchange copy of the drawing.", cost="mutate") @mcp.tool( annotations={"title": "Export DXF"}, tags={"drawing", "export"}, @@ -656,6 +1153,7 @@ async def drawing_export_dxf( return await _backend(ctx).drawing_export_dxf(str(validated)) +@cad_tool(summary="Plot the drawing, or one paper-space layout, to PDF.", cost="mutate") @mcp.tool( annotations={"title": "Export PDF"}, tags={"drawing", "export"}, @@ -678,6 +1176,10 @@ async def drawing_export_pdf( return result +@cad_tool( + summary="Strip unused layers, blocks, linetypes and styles out of the file.", + cost="destructive", +) @mcp.tool( annotations={"title": "Purge Drawing"}, tags={"drawing", "cleanup"}, @@ -688,16 +1190,25 @@ async def drawing_purge(ctx: Context = None) -> dict: return await _backend(ctx).drawing_purge() +@cad_tool(summary="Scan the drawing for structural errors and repair what it finds.", cost="mutate") @mcp.tool( annotations={"title": "Audit Drawing", "readOnlyHint": False}, tags={"drawing", "cleanup"}, ) async def drawing_audit(ctx: Context = None) -> dict: - """Run an integrity audit on the drawing to detect and fix errors.""" + """Audit the drawing: repair every fixable structural problem, and report it. + + This mutates the drawing. `fixes` lists repairs that have ALREADY been + applied, so save afterwards to keep them; `errors` lists problems that could + not be repaired. On the live COM backend AutoCAD applies repairs but hands + back no counts, so they arrive as null with `detail: "unavailable"` rather + than as zero. + """ await ctx.info("Auditing drawing") return await _backend(ctx).drawing_audit() +@cad_tool(summary="Close the drawing, saving first unless you say otherwise.", cost="destructive") @mcp.tool( annotations={"title": "Close Drawing", "destructiveHint": True}, tags={"drawing"}, @@ -713,29 +1224,48 @@ async def drawing_close( return await _backend(ctx).drawing_close(save) +@cad_tool(summary="Step back one operation.", cost="safe") @mcp.tool( annotations={"title": "Undo", "destructiveHint": False, "idempotentHint": False}, tags={"drawing", "undo"}, ) async def drawing_undo(ctx: Context = None) -> dict: - """Undo the last drawing operation.""" + """Undo the last drawing operation. + + On the live COM backend this is AutoCAD's own undo. The headless backend has + no journal, so a step is a full DXF snapshot and history is **off by + default** — set `EZDXF_UNDO_DEPTH` to the number of steps you want. Measured + cost of switching it on: 37x on entity creation (0.18 -> 6.65 ms per call). + For a single checkpoint around a risky sequence, `transaction_begin` / + `transaction_rollback` is far cheaper. + + Drawing something after an undo discards the redo branch, as in AutoCAD. + """ return await _backend(ctx).drawing_undo() +@cad_tool(summary="Reapply the operation you just undid.", cost="safe") @mcp.tool( annotations={"title": "Redo", "destructiveHint": False}, tags={"drawing", "undo"}, ) async def drawing_redo(ctx: Context = None) -> dict: - """Redo the last undone drawing operation.""" + """Reapply the operation you just undid. + + Same history as `drawing_undo`, so the headless backend needs + `EZDXF_UNDO_DEPTH` set. Anything drawn after an undo discards the redo + branch — otherwise redo would restore a state that never existed, with + geometry you had removed reappearing beside geometry you drew afterwards. + """ return await _backend(ctx).drawing_redo() # --------------------------------------------------------------------------- -# ── SECTION 2: Entity Creation (14 tools) ─────────────────────────────────── +# ── SECTION 2: Entity Creation (19 tools) ─────────────────────────────────── # --------------------------------------------------------------------------- +@cad_tool(summary="Draw a straight line between two points.", cost="mutate") @mcp.tool( annotations={"title": "Create Line", "readOnlyHint": False}, tags={"entity", "create"}, @@ -758,6 +1288,7 @@ async def entity_create_line( return _dc(result) +@cad_tool(summary="Draw a circle: a hole, a bore, a pitch circle.", cost="mutate") @mcp.tool( annotations={"title": "Create Circle", "readOnlyHint": False}, tags={"entity", "create"}, @@ -776,6 +1307,7 @@ async def entity_create_circle( return _dc(result) +@cad_tool(summary="Draw a circular arc from a centre, a radius and two angles.", cost="mutate") @mcp.tool( annotations={"title": "Create Arc", "readOnlyHint": False}, tags={"entity", "create"}, @@ -798,6 +1330,10 @@ async def entity_create_arc( return _dc(result) +@cad_tool( + summary="Draw a connected outline or closed profile through a list of points.", + cost="mutate", +) @mcp.tool( annotations={"title": "Create Polyline", "readOnlyHint": False}, tags={"entity", "create"}, @@ -818,6 +1354,7 @@ async def entity_create_polyline( return _dc(result) +@cad_tool(summary="Draw a closed rectangle from two opposite corners.", cost="mutate") @mcp.tool( annotations={"title": "Create Rectangle", "readOnlyHint": False, "idempotentHint": False}, tags={"entity", "create"}, @@ -841,6 +1378,7 @@ async def entity_create_rectangle( return _dc(result) +@cad_tool(summary="Place a single-line text label.", cost="mutate") @mcp.tool( annotations={"title": "Create Text", "readOnlyHint": False}, tags={"entity", "create", "annotation"}, @@ -861,6 +1399,7 @@ async def entity_create_text( return _dc(result) +@cad_tool(summary="Place a wrapped paragraph note in a text box of a given width.", cost="mutate") @mcp.tool( annotations={"title": "Create MText", "readOnlyHint": False}, tags={"entity", "create", "annotation"}, @@ -886,6 +1425,10 @@ async def entity_create_mtext( return _dc(result) +@cad_tool( + summary="Place a parts list, BOM or schedule as a table with headers and rows.", + cost="mutate", +) @mcp.tool( annotations={"title": "Create Table", "readOnlyHint": False}, tags={"entity", "create", "annotation", "table"}, @@ -909,6 +1452,7 @@ async def entity_create_table( return _dc(result) +@cad_tool(summary="Add a callout: an arrow with a note on the end.", cost="mutate") @mcp.tool( annotations={"title": "Create Multileader", "readOnlyHint": False}, tags={"entity", "create", "annotation", "leader"}, @@ -929,6 +1473,10 @@ async def leader_create_mleader( return _dc(result) +@cad_tool( + summary="Fill a closed boundary with a section pattern such as ANSI31 or SOLID.", + cost="mutate", +) @mcp.tool( annotations={"title": "Create Hatch", "readOnlyHint": False}, tags={"entity", "create"}, @@ -950,6 +1498,7 @@ async def entity_create_hatch( return _dc(result) +@cad_tool(summary="Draw a smooth NURBS curve through fit points.", cost="mutate") @mcp.tool( annotations={"title": "Create Spline", "readOnlyHint": False}, tags={"entity", "create"}, @@ -966,6 +1515,10 @@ async def entity_create_spline( return _dc(result) +@cad_tool( + summary="Draw an ellipse from its centre, major axis vector and axis ratio.", + cost="mutate", +) @mcp.tool( annotations={"title": "Create Ellipse", "readOnlyHint": False}, tags={"entity", "create"}, @@ -992,6 +1545,7 @@ async def entity_create_ellipse( return _dc(result) +@cad_tool(summary="Place a point marker (node) at a coordinate.", cost="mutate") @mcp.tool( annotations={"title": "Create Point", "readOnlyHint": False}, tags={"entity", "create"}, @@ -1008,6 +1562,10 @@ async def entity_create_point( return _dc(result) +@cad_tool( + summary="Drop an instance of an existing block definition into the drawing.", + cost="mutate", +) @mcp.tool( annotations={"title": "Insert Block Reference", "readOnlyHint": False}, tags={"entity", "create", "block"}, @@ -1030,6 +1588,121 @@ async def entity_create_block_ref( return _dc(result) +@cad_tool(summary="Replace a hatch's fill with a two-colour gradient.", cost="mutate") +@mcp.tool( + annotations={"title": "Set Hatch Gradient", "destructiveHint": False}, + tags={"entity", "create"}, +) +async def hatch_set_gradient( + handle: Annotated[str, "Handle of an existing HATCH."], + color1: Annotated[list[int], "Start colour as [r, g, b]."], + color2: Annotated[list[int], "End colour as [r, g, b]."], + rotation: Annotated[float, "Gradient angle in degrees."] = 0.0, + centered: Annotated[float, "0 = one-sided, 1 = centred."] = 0.0, + one_color: Annotated[bool, "Blend color1 towards the background instead of color2."] = False, + tint: Annotated[float, "Tint value used with one_color (0-1)."] = 0.0, + name: Annotated[str, "Gradient name: LINEAR, CYLINDER, CURVED, SPHERICAL, HEMISPHERICAL."] = ( + "LINEAR" + ), + ctx: Context = None, +) -> dict: + """Fill a hatch with a gradient instead of a pattern.""" + return await _backend(ctx).hatch_set_gradient( + handle, color1, color2, rotation, centered, one_color, tint, name + ) + + +@cad_tool(summary="Change a hatch's pattern, scale, angle, colour or island style.", cost="mutate") +@mcp.tool( + annotations={"title": "Edit Hatch", "destructiveHint": False}, + tags={"entity", "create"}, +) +async def hatch_edit( + handle: Annotated[str, "Handle of an existing HATCH."], + pattern: Annotated[str, "New pattern name. Empty leaves it alone."] = "", + scale: Annotated[float | None, "New pattern scale (> 0)."] = None, + angle: Annotated[float | None, "New pattern angle in degrees."] = None, + color: Annotated[int | None, "New ACI colour."] = None, + style: Annotated[str, "Island style: normal, outer or ignore. Empty leaves it alone."] = "", + ctx: Context = None, +) -> dict: + """Edit an existing hatch in place. + + Omitted parameters are left alone — a partial edit that resets the rest is + data loss. `changed` reports which attributes actually moved, so re-setting + a value to what it already was comes back as an empty list rather than a + false positive. + """ + return await _backend(ctx).hatch_edit(handle, pattern, scale, angle, color, style) + + +@cad_tool(summary="Add a boundary path to a hatch, arcs and ellipses included.", cost="mutate") +@mcp.tool( + annotations={"title": "Add Hatch Boundary", "destructiveHint": False}, + tags={"entity", "create"}, +) +async def hatch_add_boundary( + handle: Annotated[str, "Handle of an existing HATCH."], + edges: Annotated[ + list[dict], + "Typed edges: {'type':'line','start':[x,y],'end':[x,y]} | " + "{'type':'arc','center':[x,y],'radius':r,'start_angle':a,'end_angle':b,'ccw':true} | " + "{'type':'ellipse','center':[x,y],'major_axis':[x,y],'ratio':r}", + ], + ctx: Context = None, +) -> dict: + """Add one boundary path built from typed edges. + + Typed edges exist because a boundary that only accepts vertex lists + silently straightens every curve it is given. Every edge is validated + before any is written, so a malformed list refuses instead of leaving a + half-built path. + """ + return await _backend(ctx).hatch_add_boundary(handle, edges) + + +@cad_tool(summary="Mask whatever is behind a closed polygon on the sheet.", cost="mutate") +@mcp.tool( + annotations={"title": "Create Wipeout", "destructiveHint": False}, + tags={"entity", "create"}, +) +async def entity_create_wipeout( + points: Annotated[list[list[float]], "Closed polygon as [[x, y], ...]; at least 3 points."], + layer: Annotated[str, "Target layer. Empty uses the current layer."] = "", + ctx: Context = None, +) -> dict: + """Create a WIPEOUT that hides drawing content behind its outline. + + Refuses fewer than three points: a zero-area mask hides nothing while + reporting success. + """ + return await _backend(ctx).entity_create_wipeout(points, layer or None) + + +@cad_tool(summary="Draw a revision cloud around an area.", cost="mutate") +@mcp.tool( + annotations={"title": "Create Revision Cloud", "destructiveHint": False}, + tags={"entity", "create"}, +) +async def entity_create_revcloud( + points: Annotated[list[list[float]], "Path corners as [[x, y], ...]."], + segment_length: Annotated[ + float, + Field(gt=0, description="Approximate arc length of each cloud bump, in drawing units."), + ], + layer: Annotated[str, "Target layer. Empty uses the current layer."] = "", + closed: Annotated[bool, "Close the path back to the first point."] = True, + ctx: Context = None, +) -> dict: + """Draw a revision cloud: a polyline whose every segment carries an arc. + + A `segment_length` longer than the shortest edge is refused — the result + would carry no arcs at all and would be a plain polyline reported as a + cloud. + """ + return await _backend(ctx).entity_create_revcloud(points, segment_length, layer or None, closed) + + # --------------------------------------------------------------------------- # ── SECTION 3: Dimensions (5 tools) ───────────────────────────────────────── # --------------------------------------------------------------------------- @@ -1065,6 +1738,10 @@ def _fit_to_tolerances( ) +@cad_tool( + summary="Dimension a horizontal or vertical size, with an ISO 129 tolerance or ISO 286 fit.", + cost="mutate", +) @mcp.tool( annotations={"title": "Linear Dimension", "readOnlyHint": False}, tags={"annotation", "dimension"}, @@ -1121,6 +1798,7 @@ async def dimension_linear( return _dc(result) +@cad_tool(summary="Dimension the true distance between two points, along a slope.", cost="mutate") @mcp.tool( annotations={"title": "Aligned Dimension", "readOnlyHint": False}, tags={"annotation", "dimension"}, @@ -1140,6 +1818,7 @@ async def dimension_aligned( return _dc(result) +@cad_tool(summary="Dimension the included angle between two rays from a vertex.", cost="mutate") @mcp.tool( annotations={"title": "Angular Dimension", "readOnlyHint": False}, tags={"annotation", "dimension"}, @@ -1163,6 +1842,7 @@ async def dimension_angular( return _dc(result) +@cad_tool(summary="Call out the radius of a circle or arc, optionally toleranced.", cost="mutate") @mcp.tool( annotations={"title": "Radius Dimension", "readOnlyHint": False}, tags={"annotation", "dimension"}, @@ -1212,6 +1892,10 @@ async def dimension_radius( return _dc(result) +@cad_tool( + summary="Call out a hole or shaft diameter, optionally to a fit such as H7.", + cost="mutate", +) @mcp.tool( annotations={"title": "Diameter Dimension", "readOnlyHint": False}, tags={"annotation", "dimension"}, @@ -1263,10 +1947,11 @@ async def dimension_diameter( # --------------------------------------------------------------------------- -# ── SECTION 4: Entity Modification (16 tools) ─────────────────────────────── +# ── SECTION 4: Entity Modification (18 tools) ─────────────────────────────── # --------------------------------------------------------------------------- +@cad_tool(summary="Shift an entity by a displacement vector.", cost="mutate") @mcp.tool( annotations={"title": "Move Entity", "readOnlyHint": False, "destructiveHint": False}, tags={"entity", "modify"}, @@ -1283,6 +1968,7 @@ async def entity_move( return await _backend(ctx).entity_move(handle, dx, dy, dz) +@cad_tool(summary="Duplicate an entity and offset the copy.", cost="mutate") @mcp.tool( annotations={"title": "Copy Entity", "readOnlyHint": False, "destructiveHint": False}, tags={"entity", "modify"}, @@ -1300,6 +1986,7 @@ async def entity_copy( return _dc(result) +@cad_tool(summary="Turn an entity about a base point by an angle in degrees.", cost="mutate") @mcp.tool( annotations={"title": "Rotate Entity", "readOnlyHint": False, "destructiveHint": False}, tags={"entity", "modify"}, @@ -1316,6 +2003,7 @@ async def entity_rotate( return await _backend(ctx).entity_rotate(handle, base_x, base_y, angle_deg) +@cad_tool(summary="Resize an entity uniformly about a base point.", cost="mutate") @mcp.tool( annotations={"title": "Scale Entity", "readOnlyHint": False, "destructiveHint": False}, tags={"entity", "modify"}, @@ -1331,6 +2019,10 @@ async def entity_scale( return await _backend(ctx).entity_scale(handle, base_x, base_y, factor) +@cad_tool( + summary="Reflect an entity across a mirror line, keeping or dropping the original.", + cost="mutate", +) @mcp.tool( annotations={"title": "Mirror Entity", "readOnlyHint": False, "destructiveHint": False}, tags={"entity", "modify"}, @@ -1349,6 +2041,10 @@ async def entity_mirror( return _dc(result) +@cad_tool( + summary="Make a parallel copy of a line, circle or polyline at a set distance.", + cost="mutate", +) @mcp.tool( annotations={"title": "Offset Entity", "readOnlyHint": False, "destructiveHint": False}, tags={"entity", "modify"}, @@ -1368,6 +2064,10 @@ async def entity_offset( # ── corner operations (trim/extend/fillet/chamfer) ────────────────────────── +@cad_tool( + summary="Cut a line back to where another crosses it, keeping the side you point at.", + cost="mutate", +) @mcp.tool( annotations={"title": "Trim Entity", "readOnlyHint": False, "destructiveHint": False}, tags={"entity", "modify", "corner"}, @@ -1388,6 +2088,7 @@ async def entity_trim( return _dc(result) +@cad_tool(summary="Lengthen a line until it meets a boundary line.", cost="mutate") @mcp.tool( annotations={"title": "Extend Entity", "readOnlyHint": False, "destructiveHint": False}, tags={"entity", "modify", "corner"}, @@ -1408,6 +2109,7 @@ async def entity_extend( return _dc(result) +@cad_tool(summary="Round a corner between two lines with a tangent arc.", cost="mutate") @mcp.tool( annotations={"title": "Fillet Two Entities", "readOnlyHint": False, "destructiveHint": False}, tags={"entity", "modify", "corner"}, @@ -1429,6 +2131,7 @@ async def entity_fillet( return _dc(result) +@cad_tool(summary="Bevel a corner between two lines by two setback distances.", cost="mutate") @mcp.tool( annotations={"title": "Chamfer Two Entities", "readOnlyHint": False, "destructiveHint": False}, tags={"entity", "modify", "corner"}, @@ -1451,6 +2154,7 @@ async def entity_chamfer( return _dc(result) +@cad_tool(summary="Erase one entity by handle.", cost="destructive") @mcp.tool( annotations={"title": "Delete Entity", "readOnlyHint": False, "destructiveHint": True}, tags={"entity", "modify"}, @@ -1464,6 +2168,7 @@ async def entity_delete( return await _backend(ctx).entity_delete(handle) +@cad_tool(summary="Repeat an entity in a grid of rows and columns.", cost="mutate") @mcp.tool( annotations={"title": "Rectangular Array", "readOnlyHint": False}, tags={"entity", "modify", "array"}, @@ -1474,19 +2179,37 @@ async def entity_array_rectangular( cols: Annotated[int, Field(description="Number of columns", ge=1)], row_spacing: Annotated[float, "Spacing between rows (Y direction)"], col_spacing: Annotated[float, "Spacing between columns (X direction)"], + fields: ResultFields = None, + compact: ResultCompact = False, ctx: Context = None, -) -> list[dict]: - """Create a rectangular array of copies. Returns info of all created copies.""" +) -> list[dict] | dict: + """Create a rectangular array of copies. Returns info of all created copies. + + rows x cols is unbounded, so this is a result-heavy tool despite being a + create: a 40x40 grid hands back 1600 full records. fields=["handle"] is + usually all a caller needs from it. + """ await ctx.info(f"Creating {rows}×{cols} rectangular array of entity {handle}") - total = rows * cols - await ctx.report_progress(0, total) + made = rows * cols + await ctx.report_progress(0, made) result = await _backend(ctx).entity_array_rectangular( handle, rows, cols, row_spacing, col_spacing ) - await ctx.report_progress(total, total) - return [_dc(e) for e in result] + await ctx.report_progress(made, made) + return _shape_rows( + result, + spec=EntityInfo, + fields=fields, + compact=compact, + tool="entity_array_rectangular", + total=len(result), + ) +@cad_tool( + summary="Repeat an entity around a centre: a bolt circle or radial pattern.", + cost="mutate", +) @mcp.tool( annotations={"title": "Polar Array", "readOnlyHint": False}, tags={"entity", "modify", "array"}, @@ -1497,14 +2220,31 @@ async def entity_array_polar( fill_angle: Annotated[float, "Total angle to fill in degrees (360 for full circle)"], center_x: Annotated[float, "Array center X"], center_y: Annotated[float, "Array center Y"], + fields: ResultFields = None, + compact: ResultCompact = False, ctx: Context = None, -) -> list[dict]: - """Create a polar (circular) array of copies around a center point.""" +) -> list[dict] | dict: + """Create a polar (circular) array of copies around a center point. + + `count` is unbounded, so the same result-shaping applies as for the + rectangular array: fields=["handle"] when the geometry is already known. + """ await ctx.info(f"Creating polar array of {count} items around ({center_x},{center_y})") result = await _backend(ctx).entity_array_polar(handle, count, fill_angle, center_x, center_y) - return [_dc(e) for e in result] + return _shape_rows( + result, + spec=EntityInfo, + fields=fields, + compact=compact, + tool="entity_array_polar", + total=len(result), + ) +@cad_tool( + summary="Change an entity's layer, colour, linetype, lineweight or visibility.", + cost="mutate", +) @mcp.tool( annotations={"title": "Set Entity Properties", "readOnlyHint": False, "destructiveHint": False}, tags={"entity", "modify"}, @@ -1527,6 +2267,10 @@ async def entity_set_properties( ) +@cad_tool( + summary="Reword a label, or change its height or rotation, keeping its handle.", + cost="mutate", +) @mcp.tool( annotations={"title": "Edit Text", "readOnlyHint": False, "destructiveHint": False}, tags={"entity", "modify"}, @@ -1548,6 +2292,58 @@ async def entity_edit_text( return _dc(result) +@cad_tool(summary="Put an opaque background box behind an MTEXT.", cost="mutate") +@mcp.tool( + annotations={"title": "Set Text Background", "destructiveHint": False}, + tags={"entity", "modify"}, +) +async def text_set_background( + handle: Annotated[str, "Handle of an MTEXT entity."], + enabled: Annotated[bool, "False removes the background box."] = True, + color: Annotated[int, "ACI background colour (1-255). 0 uses the drawing background."] = 0, + scale: Annotated[ + float, + Field(ge=1.0, description="Box size as a multiple of the text box; must be >= 1."), + ] = 1.5, + ctx: Context = None, +) -> dict: + """Mask what is behind an MTEXT so it stays readable over hatch or geometry. + + MTEXT only: TEXT has no background-fill attribute, so setting one on it + would report success and change nothing. + """ + return await _backend(ctx).text_set_background(handle, enabled, color or None, scale) + + +@cad_tool(summary="Find and replace text across the drawing.", cost="mutate") +@mcp.tool( + annotations={"title": "Find and Replace Text", "destructiveHint": False}, + tags={"entity", "modify"}, +) +async def text_find_replace( + find: Annotated[str, "Literal text to search for (not a regex)."], + replace: Annotated[str, "Replacement text."], + layer: Annotated[str, "Restrict to one layer. Empty searches every layer."] = "", + match_case: Annotated[bool, "Case-sensitive search."] = True, + dry_run: Annotated[bool, "Report what would change without changing it."] = False, + ctx: Context = None, +) -> dict: + """Replace text in TEXT, MTEXT and block attributes (ATTRIB and ATTDEF). + + `searched_types` is on the response because "no matches" and "that type was + never searched" are different answers. Block *definitions* are included, so + the next insert does not reintroduce the old text. DIMENSION text is out of + scope: its text field holds the `<>` override placeholder rather than the + measurement, so editing it would break the association. + """ + await ctx.info(f"Replacing {find!r} with {replace!r}{' (dry run)' if dry_run else ''}") + return await _backend(ctx).text_find_replace(find, replace, layer or None, match_case, dry_run) + + +@cad_tool( + summary="Change a circle's radius, a line's endpoints or an arc's angles in place.", + cost="mutate", +) @mcp.tool( annotations={"title": "Edit Geometry", "readOnlyHint": False, "destructiveHint": False}, tags={"entity", "modify"}, @@ -1587,10 +2383,84 @@ async def entity_edit_geometry( # --------------------------------------------------------------------------- -# ── SECTION 5: Entity Query (4 tools) ─────────────────────────────────────── +# ── SECTION 5: Entity Query (7 tools) ─────────────────────────────────────── # --------------------------------------------------------------------------- +@cad_tool(summary="Select entities inside or crossing a rectangle.", cost="read") +@mcp.tool( + annotations={"title": "Select by Window", "readOnlyHint": True}, + tags={"entity", "query"}, +) +async def selection_window( + x1: Annotated[float, "First corner X."], + y1: Annotated[float, "First corner Y."], + x2: Annotated[float, "Opposite corner X."], + y2: Annotated[float, "Opposite corner Y."], + mode: Annotated[ + str, + Field( + default="window", + description="window = wholly inside only; crossing = also entities straddling the " + "edge.", + ), + ] = "window", + entity_type: Annotated[str, "Restrict to a DXF type, e.g. CIRCLE. Empty means any."] = "", + layer: Annotated[str, "Restrict to a layer. Empty means any."] = "", + ctx: Context = None, +) -> dict: + """AutoCAD's ssget window/crossing selection. + + Corners may be given in any order. Selection is by *drawn* position, so an + entity in a mirrored frame is found where `entity_get` reports it. A + zero-area box is refused rather than answered with an empty list. + """ + return await _backend(ctx).selection_window(x1, y1, x2, y2, mode, entity_type, layer) + + +@cad_tool(summary="Select entities inside or crossing an arbitrary polygon.", cost="read") +@mcp.tool( + annotations={"title": "Select by Polygon", "readOnlyHint": True}, + tags={"entity", "query"}, +) +async def selection_polygon( + points: Annotated[list[list[float]], "Polygon vertices as [[x, y], ...]; at least 3."], + mode: Annotated[ + str, + Field(default="window", description="window = wholly inside only; crossing = touching."), + ] = "window", + entity_type: Annotated[str, "Restrict to a DXF type. Empty means any."] = "", + layer: Annotated[str, "Restrict to a layer. Empty means any."] = "", + ctx: Context = None, +) -> dict: + """Window or crossing selection against a polygon rather than a rectangle.""" + return await _backend(ctx).selection_polygon(points, mode, entity_type, layer) + + +@cad_tool(summary="Select entities by layer, type, colour, linetype or minimum area.", cost="read") +@mcp.tool( + annotations={"title": "Select by Properties", "readOnlyHint": True}, + tags={"entity", "query"}, +) +async def selection_filter( + entity_type: Annotated[str, "DXF type, e.g. LWPOLYLINE. Empty means any."] = "", + layer: Annotated[str, "Layer name. Empty means any."] = "", + color: Annotated[int | None, "ACI colour to match."] = None, + linetype: Annotated[str, "Linetype name. Empty means any."] = "", + min_area: Annotated[float | None, "Keep only closed shapes with at least this area."] = None, + ctx: Context = None, +) -> dict: + """AutoCAD's QSELECT: filter the drawing by properties. + + Named parameters rather than a query string, deliberately — a mistyped + attribute name in a query language comes back as an empty result, which is + indistinguishable from "no matches". `filtered_by` reports which filters + actually ran. + """ + return await _backend(ctx).selection_filter(entity_type, layer, color, linetype, min_area) + + +@cad_tool(summary="Inspect one entity by handle: type, layer, colour and geometry.", cost="read") @mcp.tool( annotations={"title": "Get Entity", "readOnlyHint": True}, tags={"entity", "query"}, @@ -1604,6 +2474,10 @@ async def entity_get( return _dc(result) +@cad_tool( + summary="Browse the drawing's entities and their handles, filtered by type or layer.", + cost="read", +) @mcp.tool( annotations={"title": "List Entities", "readOnlyHint": True}, tags={"entity", "query"}, @@ -1616,12 +2490,24 @@ async def entity_list( layer_filter: Annotated[str | None, "Filter by layer name"] = None, limit: Annotated[int, Field(description="Maximum entities to return", ge=1, le=1000)] = 100, offset: Annotated[int, Field(description="Number of entities to skip", ge=0)] = 0, + fields: ResultFields = None, + compact: ResultCompact = False, ctx: Context = None, -) -> list[dict]: +) -> list[dict] | dict: """List entities in the drawing with optional type and layer filters. Returns handle, type, layer, color, and type-specific properties. Use handles with entity_get, entity_move, entity_delete, etc. + + This is the most expensive result on the server — the full record runs + ~250 characters per entity, and `properties.bounding_box` alone is about a + third of it. When all you need is handles, say so:: + + entity_list(layer_filter="GEOMETRY", fields=["handle", "type"], compact=True) + + Paging honesty: a plain list has nowhere to say that more entities followed + the page, so `compact=True` is the only mode that reports `total`, + `truncated` and `next_offset` — all measured against the same filters. """ capped = min(int(limit), config.settings.max_list_limit) if capped < limit: @@ -1629,10 +2515,34 @@ async def entity_list( f"limit {limit} exceeds MAX_LIST_LIMIT={config.settings.max_list_limit}; capped" ) await ctx.info(f"Listing entities type={type_filter} layer={layer_filter} limit={capped}") - result = await _backend(ctx).entity_list(type_filter, layer_filter, capped, offset) - return [_dc(e) for e in result] + b = _backend(ctx) + result = await b.entity_list(type_filter, layer_filter, capped, offset) + total = None + if compact: + # Only counted when there is a field to report it in; the default path + # must not grow a second full pass over the drawing. + total = await b.entity_count(type_filter, layer_filter) + elif len(result) >= capped: + # A full page is the one case where the plain list is indistinguishable + # from a complete answer. The value cannot say so without breaking the + # compatibility contract, so say it in the log and name the mode that can. + await ctx.warning( + f"entity_list returned a full page of {capped}; more entities may follow. " + "Re-run with compact=True for total/truncated/next_offset, or page with " + f"offset={offset + len(result)}." + ) + return _shape_rows( + result, + spec=EntityInfo, + fields=fields, + compact=compact, + tool="entity_list", + total=total, + offset=offset, + ) +@cad_tool(summary="Erase a whole list of entities in one call.", cost="destructive") @mcp.tool( annotations={ "title": "Delete Multiple Entities", @@ -1661,11 +2571,19 @@ async def entity_delete_many( return {"ok": True, "deleted": deleted, "errors": errors} +@cad_tool( + summary="Read what the user already picked in the AutoCAD viewport (COM backend).", + cost="read", +) @mcp.tool( annotations={"title": "Get Viewport Selection", "readOnlyHint": True}, tags={"entity", "query"}, ) -async def selection_get(ctx: Context = None) -> dict: +async def selection_get( + fields: ResultFields = None, + compact: ResultCompact = False, + ctx: Context = None, +) -> dict: """Read the entities the user pre-selected in the AutoCAD viewport (COM backend only). Returns the implied "pickfirst" selection — the entities highlighted with @@ -1685,6 +2603,11 @@ async def selection_get(ctx: Context = None) -> dict: On the ezdxf headless backend there is no viewport, so this returns ok=False with an empty handles list. + + `fields` / `compact` shape the "entities" collection — this tool already + returns an object, so the columnar envelope lands *under* that key rather + than replacing the result. `handles` is unaffected, so a caller that only + wants handles can pass fields=["handle"] and still read `handles` directly. """ result = await _backend(ctx).selection_get() if result.get("ok"): @@ -1693,7 +2616,15 @@ async def selection_get(ctx: Context = None) -> dict: await ctx.warning(result.get("error", "selection_get returned not-ok")) # The backend places EntityInfo dataclasses under "entities"; serialize them. result = dict(result) - result["entities"] = [_dc(e) for e in result.get("entities", [])] + entities = result.get("entities", []) + result["entities"] = _shape_rows( + entities, + spec=EntityInfo, + fields=fields, + compact=compact, + tool="selection_get", + total=len(entities), + ) return result @@ -1702,16 +2633,33 @@ async def selection_get(ctx: Context = None) -> dict: # --------------------------------------------------------------------------- +@cad_tool(summary="List every layer with its colour, linetype and visibility state.", cost="read") @mcp.tool( annotations={"title": "List Layers", "readOnlyHint": True}, tags={"layer", "query"}, ) -async def layer_list(ctx: Context = None) -> list[dict]: - """List all layers with their properties (color, linetype, frozen, locked, visibility).""" +async def layer_list( + fields: ResultFields = None, + compact: ResultCompact = False, + ctx: Context = None, +) -> list[dict] | dict: + """List all layers with their properties (color, linetype, frozen, locked, visibility). + + Never truncated — a drawing's whole layer table is returned — so a compact + envelope here always reports truncated=false. + """ result = await _backend(ctx).layer_list() - return [_dc(lyr) for lyr in result] + return _shape_rows( + result, + spec=LayerInfo, + fields=fields, + compact=compact, + tool="layer_list", + total=len(result), + ) +@cad_tool(summary="Add a layer with a colour, linetype and lineweight.", cost="mutate") @mcp.tool( annotations={"title": "Create Layer", "readOnlyHint": False}, tags={"layer"}, @@ -1731,6 +2679,7 @@ async def layer_create( return _dc(result) +@cad_tool(summary="Remove an empty layer from the drawing.", cost="destructive") @mcp.tool( annotations={"title": "Delete Layer", "readOnlyHint": False, "destructiveHint": True}, tags={"layer"}, @@ -1744,6 +2693,7 @@ async def layer_delete( return await _backend(ctx).layer_delete(name) +@cad_tool(summary="Choose the layer that new geometry lands on.", cost="safe") @mcp.tool( annotations={"title": "Set Current Layer", "readOnlyHint": False, "destructiveHint": False}, tags={"layer"}, @@ -1757,6 +2707,7 @@ async def layer_set_current( return await _backend(ctx).layer_set_current(name) +@cad_tool(summary="Change an existing layer's colour, linetype or lineweight.", cost="mutate") @mcp.tool( annotations={"title": "Modify Layer", "readOnlyHint": False, "destructiveHint": False}, tags={"layer"}, @@ -1773,6 +2724,7 @@ async def layer_modify( return _dc(result) +@cad_tool(summary="Freeze a layer so it stops drawing and regenerating.", cost="safe") @mcp.tool(annotations={"title": "Freeze Layer"}, tags={"layer"}) async def layer_freeze( name: Annotated[str, "Layer name to freeze"], @@ -1782,6 +2734,7 @@ async def layer_freeze( return await _backend(ctx).layer_freeze(name) +@cad_tool(summary="Bring a frozen layer back into view.", cost="safe") @mcp.tool(annotations={"title": "Thaw Layer"}, tags={"layer"}) async def layer_thaw( name: Annotated[str, "Layer name to thaw"], @@ -1791,6 +2744,7 @@ async def layer_thaw( return await _backend(ctx).layer_thaw(name) +@cad_tool(summary="Lock a layer so its entities can be seen but not edited.", cost="safe") @mcp.tool(annotations={"title": "Lock Layer"}, tags={"layer"}) async def layer_lock( name: Annotated[str, "Layer name to lock"], @@ -1800,6 +2754,7 @@ async def layer_lock( return await _backend(ctx).layer_lock(name) +@cad_tool(summary="Unlock a layer so its entities can be edited again.", cost="safe") @mcp.tool(annotations={"title": "Unlock Layer"}, tags={"layer"}) async def layer_unlock( name: Annotated[str, "Layer name to unlock"], @@ -1809,6 +2764,7 @@ async def layer_unlock( return await _backend(ctx).layer_unlock(name) +@cad_tool(summary="Turn a layer off so its entities stop showing.", cost="safe") @mcp.tool(annotations={"title": "Hide Layer"}, tags={"layer"}) async def layer_hide( name: Annotated[str, "Layer name to turn off"], @@ -1818,6 +2774,7 @@ async def layer_hide( return await _backend(ctx).layer_hide(name) +@cad_tool(summary="Turn a layer that was switched off back on.", cost="safe") @mcp.tool(annotations={"title": "Show Layer"}, tags={"layer"}) async def layer_show( name: Annotated[str, "Layer name to turn on"], @@ -1827,6 +2784,7 @@ async def layer_show( return await _backend(ctx).layer_show(name) +@cad_tool(summary="Hide every layer except one, to work on it alone.", cost="safe") @mcp.tool( annotations={"title": "Isolate Layer", "readOnlyHint": False}, tags={"layer"}, @@ -1847,6 +2805,7 @@ async def layer_isolate( return {"ok": True, "isolated": name, "hidden_count": len(hidden), "hidden_layers": hidden} +@cad_tool(summary="List the dash patterns currently loaded in the drawing.", cost="read") @mcp.tool( annotations={"title": "List Loaded Linetypes", "readOnlyHint": True}, tags={"layer", "linetype"}, @@ -1856,6 +2815,10 @@ async def linetype_list(ctx: Context = None) -> list[str]: return await _backend(ctx).linetype_list() +@cad_tool( + summary="Load a linetype such as CENTER or HIDDEN, without the FILEDIA dialog trap.", + cost="mutate", +) @mcp.tool( annotations={"title": "Load Linetype", "readOnlyHint": False}, tags={"layer", "linetype"}, @@ -1885,16 +2848,33 @@ async def linetype_load( # --------------------------------------------------------------------------- +@cad_tool(summary="List the block definitions the drawing already carries.", cost="read") @mcp.tool( annotations={"title": "List Blocks", "readOnlyHint": True}, tags={"block", "query"}, ) -async def block_list(ctx: Context = None) -> list[dict]: - """List all block definitions in the drawing (name, origin, attribute count, entity count).""" +async def block_list( + fields: ResultFields = None, + compact: ResultCompact = False, + ctx: Context = None, +) -> list[dict] | dict: + """List all block definitions in the drawing (name, origin, attribute count, entity count). + + Never truncated — the whole block table is returned — so a compact envelope + here always reports truncated=false. + """ result = await _backend(ctx).block_list() - return [_dc(b) for b in result] + return _shape_rows( + result, + spec=BlockInfo, + fields=fields, + compact=compact, + tool="block_list", + total=len(result), + ) +@cad_tool(summary="Place a block, filling in its attribute values as you go.", cost="mutate") @mcp.tool( annotations={"title": "Insert Block", "readOnlyHint": False}, tags={"block"}, @@ -1918,6 +2898,7 @@ async def block_insert( return _dc(result) +@cad_tool(summary="Break a block reference apart into its component entities.", cost="destructive") @mcp.tool( annotations={"title": "Explode Block", "readOnlyHint": False, "destructiveHint": True}, tags={"block"}, @@ -1931,6 +2912,7 @@ async def block_explode( return await _backend(ctx).block_explode(handle) +@cad_tool(summary="Read the tag/value pairs off a block reference.", cost="read") @mcp.tool( annotations={"title": "Get Block Attributes", "readOnlyHint": True}, tags={"block", "query"}, @@ -1943,6 +2925,10 @@ async def block_get_attributes( return await _backend(ctx).block_get_attributes(handle) +@cad_tool( + summary="Fill in a block reference's attributes, such as title-block fields.", + cost="mutate", +) @mcp.tool( annotations={"title": "Set Block Attributes", "readOnlyHint": False}, tags={"block"}, @@ -1956,6 +2942,10 @@ async def block_set_attributes( return await _backend(ctx).block_set_attributes(handle, attributes) +@cad_tool( + summary="Turn existing entities into a reusable block definition.", + cost="mutate", +) @mcp.tool( annotations={"title": "Create Block From Entities", "readOnlyHint": False}, tags={"block"}, @@ -1969,33 +2959,116 @@ async def block_create_from_entities( ) -> dict: """Create a new block definition from existing entities in the drawing. - Note: This tool works by using AutoCAD's BLOCK command (COM backend only). - For ezdxf backend, entities must be added to a block definition directly. + Works on both engines. The originals stay in model space — this defines a + reusable block from them rather than consuming them the way AutoCAD's BLOCK + command does; use `block_insert` to place copies, and delete the originals + yourself if you want the command's behaviour. + + Handles that do not resolve are listed in `skipped` rather than silently + dropped, and a call where none resolve fails instead of leaving an empty + definition behind. """ await ctx.info(f"Creating block '{name}' from {len(handles)} entities") return await _backend(ctx).block_create_from_entities(name, handles, base_x, base_y) +@cad_tool(summary="Find every place a given block is inserted.", cost="read") @mcp.tool( annotations={"title": "Find Blocks By Name", "readOnlyHint": True}, tags={"block", "query"}, ) async def block_find_references( name: Annotated[str, "Block definition name to search for"], + fields: ResultFields = None, + compact: ResultCompact = False, ctx: Context = None, -) -> list[dict]: - """Find all insert references to a specific block definition.""" +) -> list[dict] | dict: + """Find all insert references to a specific block definition. + + Bounded by the backend's own default entity_list page (200 INSERTs scanned), + which is a pre-existing limit, not a new one: the compact envelope's `total` + counts the references found within that scan. + """ await ctx.info(f"Finding all references to block '{name}'") result = await _backend(ctx).entity_list(type_filter="INSERT") - refs = [_dc(e) for e in result if e.properties.get("block_name") == name] - return refs + refs = [e for e in result if e.properties.get("block_name") == name] + return _shape_rows( + refs, + spec=EntityInfo, + fields=fields, + compact=compact, + tool="block_find_references", + total=len(refs), + ) # --------------------------------------------------------------------------- -# ── SECTION 8: Analysis & Query (8 tools) ─────────────────────────────────── +# ── SECTION 8: Analysis & Query (12 tools) ────────────────────────────────── # --------------------------------------------------------------------------- +@cad_tool(summary="Trace the closed boundary around a point, like BOUNDARY.", cost="mutate") +@mcp.tool( + annotations={"title": "Trace Boundary", "destructiveHint": False}, + tags={"analysis"}, +) +async def boundary_trace( + x: Annotated[float, "Seed point X, inside the region to trace."], + y: Annotated[float, "Seed point Y, inside the region to trace."], + layer: Annotated[str, "Only consider edges on this layer. Empty considers all."] = "", + tolerance: Annotated[float, "Gap tolerance for joining edges."] = 1e-9, + ctx: Context = None, +) -> dict: + """AutoCAD's BOUNDARY/BPOLY: create a closed polyline around a seed point. + + Returns the *nearest enclosing* loop, so a seed inside an island gives the + island rather than the outer region. Straight edges are split where they + cross, so a line drawn across a shape divides it the way it looks like it + should. A seed with no enclosing loop is refused, and the error names the + gap when the edges nearly close. + """ + await ctx.info(f"Tracing the boundary around ({x}, {y})") + return await _backend(ctx).boundary_trace(x, y, layer or None, tolerance) + + +@cad_tool(summary="Join named entities into one closed boundary polyline.", cost="mutate") +@mcp.tool( + annotations={"title": "Boundary from Entities", "destructiveHint": False}, + tags={"analysis"}, +) +async def boundary_from_entities( + handles: Annotated[list[str], "Handles of the 2D entities that form the loop."], + tolerance: Annotated[float, "Gap tolerance for joining endpoints."] = 1e-9, + ctx: Context = None, +) -> dict: + """Chain the given entities into one closed polyline. + + The handles may arrive in any order — putting them in chain order is the + tool's job. A chain that does not close is refused, and the error names the + coordinates of the gap. + """ + return await _backend(ctx).boundary_from_entities(handles, tolerance) + + +@cad_tool(summary="Dump every DXF property of one entity, like LIST.", cost="read") +@mcp.tool( + annotations={"title": "List Entity Properties", "readOnlyHint": True}, + tags={"analysis", "query"}, +) +async def analysis_list_properties( + handle: Annotated[str, "Handle of the entity to dump."], + ctx: Context = None, +) -> dict: + """AutoCAD's LIST: the full DXF attribute set for one handle. + + `dxf_attributes` is the raw attribute set `entity_get` deliberately does + not carry. Coordinates in it are WCS, like everywhere else in this server, + and `extrusion` is reported so the entity's own frame is still visible. + """ + return await _backend(ctx).analysis_list_properties(handle) + + +@cad_tool(summary="Count the drawing's objects, broken down by type and by layer.", cost="read") @mcp.tool( annotations={"title": "Entity Statistics", "readOnlyHint": True}, tags={"analysis", "query"}, @@ -2010,6 +3083,7 @@ async def analysis_entity_stats(ctx: Context = None) -> dict: return await _backend(ctx).analysis_stats() +@cad_tool(summary="List everything that falls inside a rectangular window.", cost="read") @mcp.tool( annotations={"title": "Find Entities in Region", "readOnlyHint": True}, tags={"analysis", "query"}, @@ -2019,14 +3093,28 @@ async def analysis_find_in_region( y1: Annotated[float, "Region minimum Y"], x2: Annotated[float, "Region maximum X"], y2: Annotated[float, "Region maximum Y"], + fields: ResultFields = None, + compact: ResultCompact = False, ctx: Context = None, -) -> list[dict]: - """Find all entities within a rectangular region (crossing selection).""" +) -> list[dict] | dict: + """Find all entities within a rectangular region (crossing selection). + + Uncapped: a window over a busy drawing returns every hit. Project with + `fields` and/or `compact` before widening the window. + """ await ctx.info(f"Finding entities in region ({x1},{y1}) → ({x2},{y2})") result = await _backend(ctx).analysis_entities_in_region(x1, y1, x2, y2) - return [_dc(e) for e in result] + return _shape_rows( + result, + spec=EntityInfo, + fields=fields, + compact=compact, + tool="analysis_find_in_region", + total=len(result), + ) +@cad_tool(summary="Measure the gap between two points: distance, dx/dy and angle.", cost="read") @mcp.tool( annotations={"title": "Measure Distance", "readOnlyHint": True, "idempotentHint": True}, tags={"analysis", "measure"}, @@ -2051,33 +3139,87 @@ async def analysis_measure_distance( } +@cad_tool(summary="Measure a polygon's area and perimeter from its vertices.", cost="read") @mcp.tool( annotations={"title": "Measure Area", "readOnlyHint": True, "idempotentHint": True}, tags={"analysis", "measure"}, ) async def analysis_measure_area( - points: Annotated[list[list[float]], "Polygon vertices as list of [x, y] points (min 3)"], + points: Annotated[ + list[list[float]], + "Polygon vertices, min 3. Each is [x, y] or [x, y, bulge] — the bulge " + "(DXF convention) makes the edge leaving that vertex a circular arc.", + ], ctx: Context = None, ) -> dict: - """Calculate the area of a polygon defined by vertices using the shoelace formula.""" + """Area and perimeter of a polygon you supply the vertices for. + + This measures the numbers in the call, NOT the drawing. To measure something + that exists, use `analysis_measure_entity(handle)` — it reads the real + geometry, including curvature this tool can only see if you pass it. + + Straight-edged polygons are exact. Pass a third `bulge` element per vertex + for arc edges; omitting it on curved geometry under-reports (28% on a + semicircular edge), which is why `assumes` says what was taken on faith. + """ if len(points) < 3: raise ToolError("At least 3 points are required to calculate area.") - area = await _backend(ctx).analysis_measure_area(points) - # Also compute perimeter - perimeter = sum( - math.sqrt( - (points[(i + 1) % len(points)][0] - points[i][0]) ** 2 - + (points[(i + 1) % len(points)][1] - points[i][1]) ** 2 - ) - for i in range(len(points)) - ) + vertices = [(float(p[0]), float(p[1]), float(p[2]) if len(p) > 2 else 0.0) for p in points] + area, perimeter = polygon_area_perimeter(vertices, closed=True) + curved = any(abs(v[2]) > 1e-12 for v in vertices) return { "area": round(area, 6), "perimeter": round(perimeter, 6), "vertex_count": len(points), + "exact": True, + "assumes": ( + "the vertices given are the whole boundary and it is closed" + if curved + else "every edge is straight, and the vertices given are the whole " + "closed boundary — pass [x, y, bulge] if any edge is an arc" + ), + "self_intersecting": is_self_intersecting([(v[0], v[1]) for v in vertices]), } +@cad_tool( + summary="Area and perimeter of an existing entity, by handle.", + cost="read", +) +@mcp.tool( + annotations={"title": "Measure Entity", "readOnlyHint": True, "idempotentHint": True}, + tags={"analysis", "measure"}, +) +async def analysis_measure_entity( + handle: Annotated[str, "Entity handle (hex string) from create/list/select"], + flatten_tolerance: Annotated[ + float, "Max chord deviation when geometry has no closed form (splines, partial ellipses)" + ] = 0.001, + ctx: Context = None, +) -> dict: + """Measure something already in the drawing, by handle. + + Reads the real geometry, so polyline bulges (arc edges) are included — + reading vertices back and shoelacing them yourself loses 28% of the area on + a semicircular edge, silently. + + Measurable: LWPOLYLINE, 2D POLYLINE, CIRCLE, ELLIPSE, SPLINE, HATCH, SOLID, + TRACE, 3DFACE. REGION and 3DSOLID need the live COM backend (their area is + in ACIS data ezdxf cannot evaluate) and refuse with + `capability: "measure_area_acis"`. LINE/TEXT/INSERT bound no area on any + engine and are a plain error, not a capability gap. + + The payload states its own accuracy: `exact` is false when the shape had to + be flattened (then `flatten_tolerance` says how finely), `assumed_closed` is + true when an open boundary was closed the way AutoCAD's AREA does, and + `self_intersecting` warns when the shoelace cancelled crossed lobes — a + bowtie measures 0.0 and that number is worse than useless unflagged. + """ + await ctx.debug(f"Measuring entity {handle}") + return await _backend(ctx).entity_measure(handle, flatten_tolerance) + + +@cad_tool(summary="Get the overall extents of everything drawn.", cost="read") @mcp.tool( annotations={"title": "Drawing Bounding Box", "readOnlyHint": True}, tags={"analysis", "query"}, @@ -2087,24 +3229,45 @@ async def analysis_bounding_box(ctx: Context = None) -> dict: return await _backend(ctx).analysis_bounding_box() +@cad_tool(summary="Grab the handles of everything sitting on one layer.", cost="read") @mcp.tool( annotations={"title": "Select Entities By Layer", "readOnlyHint": True}, tags={"analysis", "query"}, ) async def analysis_select_by_layer( layer_name: Annotated[str, "Layer name to select entities from"], + fields: ResultFields = None, + compact: ResultCompact = False, ctx: Context = None, -) -> list[dict]: - """Get all entities on a specific layer. Returns entity list with handles.""" +) -> list[dict] | dict: + """Get all entities on a specific layer. Returns entity list with handles. + + Capped at MAX_LIST_LIMIT (default 5000). The plain list cannot say it was + capped — the warning goes to the log stream, which most clients never show + the model — so use `compact=True` when the count matters: its `total` is the + layer's real population and `truncated` states whether the cap fired. + """ await ctx.info(f"Selecting all entities on layer '{layer_name}'") result = await _backend(ctx).analysis_select_by_layer(layer_name) + total = len(result) cap = config.settings.max_list_limit - if len(result) > cap: - await ctx.warning(f"Layer has {len(result)} entities; truncated to {cap}") + if total > cap: + await ctx.warning(f"Layer has {total} entities; truncated to {cap}") result = result[:cap] - return [_dc(e) for e in result] + return _shape_rows( + result, + spec=EntityInfo, + fields=fields, + compact=compact, + tool="analysis_select_by_layer", + total=total, + ) +@cad_tool( + summary="Grab the handles of every entity of one type: all the circles, all the lines.", + cost="read", +) @mcp.tool( annotations={"title": "Select Entities By Type", "readOnlyHint": True}, tags={"analysis", "query"}, @@ -2114,18 +3277,33 @@ async def analysis_select_by_type( str, "Entity type: LINE, CIRCLE, ARC, LWPOLYLINE, TEXT, MTEXT, INSERT, HATCH, SPLINE, ELLIPSE", ], + fields: ResultFields = None, + compact: ResultCompact = False, ctx: Context = None, -) -> list[dict]: - """Get all entities of a specific type. Returns entity list with handles.""" +) -> list[dict] | dict: + """Get all entities of a specific type. Returns entity list with handles. + + Capped at MAX_LIST_LIMIT (default 5000); as with analysis_select_by_layer, + `compact=True` is the only shape that reports `total` and `truncated`. + """ await ctx.info(f"Selecting all {entity_type} entities") result = await _backend(ctx).analysis_select_by_type(entity_type) + total = len(result) cap = config.settings.max_list_limit - if len(result) > cap: - await ctx.warning(f"Found {len(result)} entities of type {entity_type}; truncated to {cap}") + if total > cap: + await ctx.warning(f"Found {total} entities of type {entity_type}; truncated to {cap}") result = result[:cap] - return [_dc(e) for e in result] + return _shape_rows( + result, + spec=EntityInfo, + fields=fields, + compact=compact, + tool="analysis_select_by_type", + total=total, + ) +@cad_tool(summary="Report how many entities, and which types, sit on each layer.", cost="read") @mcp.tool( annotations={"title": "Layer Statistics", "readOnlyHint": True}, tags={"analysis", "query", "layer"}, @@ -2157,10 +3335,930 @@ async def analysis_layer_stats(ctx: Context = None) -> dict: # --------------------------------------------------------------------------- -# ── SECTION 8b: Batch Operations (2 tools) ─────────────────────────────── +# ── SECTION 8b: Batch Operations (3 tools) ─────────────────────────────── # --------------------------------------------------------------------------- +# +# Three tools, two jobs, no overlap: +# +# cad_batch ordered, heterogeneous, any tool, with binding between +# steps. The general executor. +# entity_batch_create one dense dict per entity ({"type": "line", ...}) with +# entity_batch_modify no per-step tool name and no binding. Roughly 30 fewer +# characters per entity than the equivalent cad_batch +# step, which is real money at a few hundred entities. +# +# They compose rather than compete: a cad_batch step may call +# entity_batch_create for the bulk half of a drawing. What must never happen is +# a second general executor — hence cad_batch is on its own deny set and cannot +# nest inside itself. +# ── cad_batch: the ordered multi-tool executor ────────────────────────────── + +#: What ``on_error`` accepts. ``stop`` is the default because it is the only +#: mode whose guarantee is identical on both backends (see +#: :func:`_batch_rollback_guarantee`). +BATCH_ON_ERROR_MODES = ("stop", "continue", "rollback") + +#: Every ``error.kind`` a step row can carry. Closed on purpose: a client that +#: branches on the taxonomy needs it to be enumerable. +BATCH_ERROR_KINDS = ( + "denied", # on cad_batch's deny set; never executed + "malformed_step", # not a {tool, args, bind} object + "unknown_tool", # not registered, or hidden by TOOL_PROFILE / ENABLE_3D + "invalid_args", # rejected by the tool's own JSON Schema + "unresolved_ref", # a $reference that no earlier step successfully bound + "unsupported", # this backend cannot do it (carries `capability`) + "refused", # the tool declined on purpose (path/command validation, ...) + "failed", # attempted and broke +) + +#: What a rollback is actually worth, per backend transaction implementation. +BATCH_ROLLBACK_GUARANTEES = ("snapshot", "best_effort_undo", "unverified", "none") + +#: A hard ceiling on one batch. Steps run sequentially inside a single tool +#: call that no client can cancel midway, so an unbounded list is a foot-gun +#: rather than a feature. +MAX_BATCH_STEPS = 250 + +#: Reference sigil. ``$name`` / ``$name.path.0``; ``$$`` escapes a literal ``$``. +_BATCH_REF_SIGIL = "$" + +#: Stands in for an unresolved reference while a step is validated against the +#: real JSON Schema. A bound value's *type* is only knowable at run time, so +#: errors reported against this exact scalar are dropped and everything else +#: (required keys, unknown keys, the types of every literal argument) still +#: applies. NUL-delimited so no real argument can collide with it. +_BATCH_REF_SENTINEL = "\x00cad_batch:unresolved-reference\x00" + +#: Step-row messages are compacted to this many characters. A pydantic failure +#: prints four paragraphs; re-emitting that per step would hand back the tokens +#: the batch just saved. +_BATCH_MESSAGE_CHARS = 240 + +_MISSING = object() + + +class BatchReferenceError(ValueError): + """A ``$reference`` that cannot be resolved against the current bindings.""" + + +def _batch_denied_tools() -> frozenset[str]: + """Tools ``cad_batch`` will never call, derived from the live registry. + + A single ``cad_batch`` grant would otherwise reach *every* tool, including + ``system_run_command`` / ``system_run_lisp`` — defeating a client's per-tool + allowlist and routing around the point of having the raw escape hatches be + separately opt-in in the first place. The set is derived from the + ``@cad_tool(cost="escape")`` cards rather than hand-listed, so a future + escape hatch is denied the day it is registered, not the day someone + remembers this function exists. + + The denial is unconditional. ``DANGEROUS_COMMANDS_ENABLED`` governs whether + :mod:`security` will *sanitize* a command string; it says nothing about + which tool a client may reach, and reusing it here would let one env var + silently re-open the escalation path. The escape hatches stay fully + available — called directly, where their own gate applies. + + ``cad_batch`` denies itself too: no recursion, and no laundering a denied + step through a nested executor. + """ + denied = {"cad_batch"} + for tool in _local_tool_components(): + cad = (getattr(tool, "meta", None) or {}).get("cad") or {} + if cad.get("cost") == "escape": + denied.add(tool.name) + return frozenset(denied) + + +def _batch_read_only_tools() -> frozenset[str]: + """Tools whose ``ok: False`` is a *finding*, not a failure to act. + + ``validation_check`` / ``drawing_critique`` / ``drawing_preflight`` are + ``readOnlyHint=True`` and answer ``{"ok": len(issues) == 0}``, so on any + drawing with something to report they return ``ok: False`` having worked + perfectly. A mutator answering ``ok: False`` means the opposite — it did not + mutate. Reading both the same way made a read-only check trigger a rollback + that destroyed the geometry the batch had just drawn, so the discriminator + is the annotation the tool already publishes. + """ + return frozenset( + tool.name + for tool in _local_tool_components() + if getattr(getattr(tool, "annotations", None), "readOnlyHint", None) is True + ) + + +# ── error typing ──────────────────────────────────────────────────────────── +# +# `ctx.fastmcp.call_tool` re-raises FastMCP's own exceptions untouched but wraps +# everything else as `ToolError(f"Error calling tool {name!r}: {e}") from e`. The +# `from e` is what makes a typed taxonomy possible: the real exception is still +# on the `__cause__` chain, so nothing here ever has to read message text. It +# must not, either — "this backend cannot write DWG" and a RuntimeError that +# happens to say the same words are different facts, and only the type knows. + +_BATCH_CAUSE_CHAIN_LIMIT = 8 + +_capability_error_types: tuple[type, ...] | None = None + + +def _capability_refusal_types() -> tuple[type, ...]: + """Backend exception classes meaning "this engine cannot do that". + + Imported lazily, exactly as the backends themselves are: server.py must + still import on a box where a backend's dependencies are missing. + """ + global _capability_error_types + if _capability_error_types is None: + found: list[type] = [] + try: + from backends.ezdxf_backend import UnsupportedCapabilityError + + found.append(UnsupportedCapabilityError) + except Exception as exc: # pragma: no cover - ezdxf is a core dependency + log.debug("capability refusal type unavailable: %s", exc) + _capability_error_types = tuple(found) + return _capability_error_types + + +def _batch_cause_chain(exc: BaseException) -> list[BaseException]: + """``exc`` and its ``__cause__`` ancestors, outermost first, bounded.""" + chain: list[BaseException] = [] + seen: set[int] = set() + current: BaseException | None = exc + while current is not None and len(chain) < _BATCH_CAUSE_CHAIN_LIMIT: + if id(current) in seen: + break + seen.add(id(current)) + chain.append(current) + current = current.__cause__ + return chain + + +def _batch_capability_of(exc: BaseException) -> str | None: + """The backend capability ``exc`` refuses, or None. + + Recognised by type, and — because a backend may ship its own refusal class — + also by the contract every such refusal implements: a non-empty + ``capability`` string plus the ``to_dict()`` that carries the + ``{ok, error, capability}`` payload. Structural, not textual. + """ + types = _capability_refusal_types() + for error in _batch_cause_chain(exc): + if types and isinstance(error, types): + return str(getattr(error, "capability", "") or "unknown") + capability = getattr(error, "capability", None) + if isinstance(capability, str) and capability and callable(getattr(error, "to_dict", None)): + return capability + return None + + +def _refusal_message(exc: BaseException) -> str: + """The most specific message on the cause chain, in full. + + Deliberately not ``_batch_message``: that truncates to + ``_BATCH_MESSAGE_CHARS`` for a compact step row, and a refusal that gets cut + off mid-word ("...or switc") loses the escape hatch it was pointing at. + """ + for error in reversed(_batch_cause_chain(exc)): + text = " ".join(str(error).split()) + if text: + return text + return type(exc).__name__ + + +def _compact_batch_message(text: str) -> str: + """One short line, for a step row that sits alongside dozens of others.""" + text = " ".join(text.split()) + if len(text) > _BATCH_MESSAGE_CHARS: + text = text[: _BATCH_MESSAGE_CHARS - 3].rstrip() + "..." + return text + + +def _batch_message(exc: BaseException) -> str: + """The most specific message on the chain, collapsed onto one short line.""" + chain = _batch_cause_chain(exc) + text = "" + for error in reversed(chain): + text = str(error).strip() + if text: + break + return _compact_batch_message(text) or type(chain[0]).__name__ + + +def _classify_batch_error(exc: BaseException) -> dict: + """Turn an exception from a nested tool call into a typed step error.""" + from fastmcp.exceptions import NotFoundError + from fastmcp.exceptions import ValidationError as FastMCPValidationError + from pydantic import ValidationError as PydanticValidationError + + capability = _batch_capability_of(exc) + if capability: + return { + "kind": "unsupported", + "capability": capability, + "message": _batch_message(exc), + } + chain = _batch_cause_chain(exc) + if any(isinstance(error, NotFoundError) for error in chain): + return {"kind": "unknown_tool", "message": _batch_message(exc)} + if any(isinstance(error, (FastMCPValidationError, PydanticValidationError)) for error in chain): + return {"kind": "invalid_args", "message": _batch_message(exc)} + if isinstance(chain[-1], ToolError): + # call_tool re-raises a FastMCPError untouched, so an *unwrapped* + # ToolError is the tool itself declining on purpose: a rejected path, a + # sanitized command, an unavailable backend. + return {"kind": "refused", "message": _batch_message(exc)} + return {"kind": "failed", "message": _batch_message(exc)} + + +# ── $references ───────────────────────────────────────────────────────────── + + +def _batch_ref_name(value: Any) -> str | None: + """The binding name a value references, or None. + + Only a string that is *entirely* a reference counts. Partial interpolation + ("part-$edge") is deliberately unsupported: it cannot be told apart from a + text string that happens to contain a dollar sign, and silently rewriting + drawing text would be worse than not offering the feature. + """ + if not isinstance(value, str) or not value.startswith(_BATCH_REF_SIGIL): + return None + if value.startswith(_BATCH_REF_SIGIL * 2): # "$$" escapes a literal "$" + return None + body = value[1:] + if not body: + return None + return body.split(".", 1)[0] + + +def _resolve_batch_ref(value: str, bindings: dict[str, Any]) -> Any: + """Resolve one whole-string ``$reference`` against ``bindings``.""" + name, _, path = value[1:].partition(".") + if name not in bindings: + raise BatchReferenceError( + f"'{value}' references an unbound name: no earlier step bound {name!r}. " + f"Bound so far: {sorted(bindings) or 'nothing'}." + ) + current = bindings[name] + if not path: + if isinstance(current, dict): + handle = current.get("handle", _MISSING) + if handle is _MISSING: + raise BatchReferenceError( + f"'{value}' asks for the handle of step bound as {name!r}, but that " + f"step returned no 'handle' (keys: {sorted(current)}). Reference a " + f"field instead, e.g. '{value}.{sorted(current)[0]}'." + if current + else f"'{value}' asks for a handle, but step {name!r} returned nothing." + ) + return handle + return current + for segment in path.split("."): + if isinstance(current, dict) and segment in current: + current = current[segment] + continue + if isinstance(current, (list, tuple)) and segment.lstrip("-").isdigit(): + index = int(segment) + if -len(current) <= index < len(current): + current = current[index] + continue + raise BatchReferenceError( + f"'{value}' cannot be resolved: {segment!r} is not present in the result " + f"bound as {name!r}." + ) + return current + + +def _substitute_batch_refs(value: Any, bindings: dict[str, Any]) -> Any: + """Replace every whole-string reference inside an argument tree.""" + if isinstance(value, str): + if value.startswith(_BATCH_REF_SIGIL * 2): + return value[1:] + if _batch_ref_name(value) is not None: + return _resolve_batch_ref(value, bindings) + return value + if isinstance(value, dict): + return {key: _substitute_batch_refs(item, bindings) for key, item in value.items()} + if isinstance(value, list): + return [_substitute_batch_refs(item, bindings) for item in value] + return value + + +def _batch_refs_in(value: Any, found: set[str] | None = None) -> set[str]: + """Every binding name referenced anywhere in an argument tree.""" + found = set() if found is None else found + name = _batch_ref_name(value) + if name is not None: + found.add(name) + elif isinstance(value, dict): + for item in value.values(): + _batch_refs_in(item, found) + elif isinstance(value, list): + for item in value: + _batch_refs_in(item, found) + return found + + +def _batch_sentinel_refs(value: Any) -> Any: + """Replace references with the validation sentinel, leaving the rest intact.""" + if isinstance(value, str): + if value.startswith(_BATCH_REF_SIGIL * 2): + return value[1:] + return _BATCH_REF_SENTINEL if _batch_ref_name(value) is not None else value + if isinstance(value, dict): + return {key: _batch_sentinel_refs(item) for key, item in value.items()} + if isinstance(value, list): + return [_batch_sentinel_refs(item) for item in value] + return value + + +# ── schema validation (dry_run, and the pre-flight of every real run) ─────── + + +def _validate_batch_args(schema: dict, args: dict) -> str | None: + """Validate ``args`` against a tool's real JSON Schema. Returns a problem, or None. + + ``jsonschema`` ships with the ``mcp`` package this server already depends on, + so it is always importable in practice; a missing one degrades to "no schema + check" rather than to a bogus pass, and says so. + + References are validated as a wildcard: errors reported against the sentinel + scalar are dropped, because a bound value's type is genuinely unknown until + the step that produces it has run. Everything else still applies — required + keys, unknown keys, and the type of every literal argument. + """ + try: + import jsonschema + except ImportError: # pragma: no cover - jsonschema arrives with `mcp` + return None + instance = _batch_sentinel_refs(args) + try: + validator = jsonschema.Draft202012Validator(schema) + problems = [ + error + for error in validator.iter_errors(instance) + if error.instance != _BATCH_REF_SENTINEL + ] + except Exception as exc: # a malformed schema must not take the batch down + log.debug("schema validation skipped: %s", exc) + return None + if not problems: + return None + problems.sort(key=lambda error: list(error.absolute_path)) + first = problems[0] + where = "/".join(str(part) for part in first.absolute_path) + detail = " ".join(str(first.message).split()) + text = f"{where}: {detail}" if where else detail + if len(problems) > 1: + text = f"{text} (+{len(problems) - 1} more)" + return text[:_BATCH_MESSAGE_CHARS] + + +# ── atomicity ─────────────────────────────────────────────────────────────── + +_BATCH_GUARANTEE_NOTES = { + "snapshot": ( + "Rollback restores a full document snapshot taken before step 1; the restore " + "is a synchronous document replacement that raises if it fails, and the " + "before/after fingerprint below is checked. Cost: the checkpoint writes the " + "whole document to a temporary DXF, so it scales with drawing size." + ), + "best_effort_undo": ( + "Rollback ends the AutoCAD undo mark and sends '_UNDO B' to the command line. " + "AutoCAD executes that asynchronously and does not confirm it landed, so this " + "is best-effort, NOT atomic. The before/after fingerprint below is the only " + "evidence available; treat a mismatch as 'the undo has not landed (yet)' and " + "check the drawing." + ), + "unverified": ( + "This backend reports a transaction implementation this server does not " + "recognise, so no claim is made about what rollback restores. The before/after " + "fingerprint below is the only evidence." + ), + "none": ( + "This backend does not support transactions, so on_error='rollback' cannot be " + "honoured. Use on_error='stop' and read `results` for what landed." + ), +} + +_BATCH_NO_CLAIM_NOTE = "on_error={mode!r}: whatever ran stays applied; `results` is the record." + + +def _batch_rollback_guarantee(backend) -> tuple[str, str]: + """What a rollback on this backend is actually worth, and the honest note. + + Keyed off the backend's own ``transactions`` capability *mode* rather than + its name, so a third backend gets classified by what it declares it does. + """ + try: + feature = backend.capabilities().features.get("transactions") + except Exception as exc: # a capability probe must never break a tool call + log.debug("transaction capability probe failed: %s", exc) + return "unverified", _BATCH_GUARANTEE_NOTES["unverified"] + if feature is None or not feature.supported: + return "none", _BATCH_GUARANTEE_NOTES["none"] + guarantee = {"snapshot": "snapshot", "undo_mark": "best_effort_undo"}.get( + feature.mode or "", "unverified" + ) + return guarantee, _BATCH_GUARANTEE_NOTES[guarantee] + + +def _batch_transaction_ok(outcome) -> tuple[bool, str | None]: + """Did the backend actually perform the commit/rollback we asked for? + + Both backends answer with a dict carrying ``ok``; the headless one returns + ``{"ok": False, "error": "No active transaction to rollback"}`` when the + checkpoint stack is empty. Returns ``(performed, declined_reason)`` so the + payload can report the refusal instead of silently claiming the happy path. + A non-dict answer is treated as success — that is the pre-existing contract + for backends that return nothing meaningful. + """ + if not isinstance(outcome, dict): + return True, None + if outcome.get("ok", True): + return True, None + return False, str(outcome.get("error") or "backend declined") + + +async def _batch_fingerprint(backend) -> dict | None: + """A cheap, backend-neutral summary of document state. + + Counts plus extents: enough to *evidence* that a rollback landed rather than + merely assert it. Necessary, not sufficient — an in-place edit that changes + neither a count nor the extents would pass — which the payload says. + """ + try: + info = _dc(await backend.drawing_info()) + except Exception as exc: + log.debug("batch fingerprint unavailable: %s", exc) + return None + return { + "entity_count": info.get("entity_count"), + "layer_count": info.get("layer_count"), + "block_count": info.get("block_count"), + "extents_min": [round(float(v), 6) for v in (info.get("extents_min") or ())], + "extents_max": [round(float(v), 6) for v in (info.get("extents_max") or ())], + } + + +# ── result compaction ─────────────────────────────────────────────────────── + + +def _compact_batch_result(result: Any, verbose: bool) -> Any: + """Trim a step result to what is not recoverable from the drawing. + + A dict carrying a ``handle`` is an EntityInfo (or an insert/copy that + returns one): every other field of it is one ``entity_get`` away, and + re-emitting all of them per step gives back exactly the tokens batching + just saved. Anything without a handle — a score, a report, a measured + value, a point — is kept whole, because nothing else can produce it. + """ + if verbose or not isinstance(result, dict): + return result + handle = result.get("handle", _MISSING) + if handle is _MISSING: + return result + return {"handle": handle} + + +_BATCH_CHECK_NOTE = ( + "document fingerprint (entity/layer/block counts + extents) captured before " + "step 1 and re-read after the rollback. Equality is evidence, not proof: an " + "edit changing neither a count nor the extents would pass it." +) + + +def _batch_atomicity(*, transaction: bool = False, **fields) -> dict: + """The atomicity block. + + ``mode`` / ``guarantee`` / ``transaction`` / ``rolled_back`` are always + present — those are what a caller branches on. The evidence keys + (``committed``, ``verified``, ``before``, ``after``, ``check``) appear only + when a checkpoint was actually opened: on a ``stop``/``continue`` batch they + would be four nulls and two paragraphs describing a rollback that was never + on the table, and that boilerplate measured 61% of a short batch's whole + reply — a token bill for saying nothing. + """ + block: dict[str, Any] = { + "mode": "stop", + "guarantee": "none", + "transaction": transaction, + "rolled_back": False, + "note": "", + } + if transaction: + block.update( + {"committed": False, "verified": None, "before": None, "after": None}, + ) + block["check"] = _BATCH_CHECK_NOTE + block.update(fields) + return block + + +@cad_tool( + summary="Run an ordered list of tool calls in one round trip, binding results between steps.", + cost="destructive", +) +@mcp.tool( + annotations={"title": "Batch: Run Tools", "readOnlyHint": False, "destructiveHint": True}, + tags={"batch"}, +) +async def cad_batch( + steps: Annotated[ + list[dict], + Field( + description=( + "Ordered steps. Each: {'tool': , 'args': {...}, " + "'bind': }. 'bind' names this step's result so a later " + "step can reference it as '$name' (its handle), '$name.field' or " + "'$name.list.0'. '$$' is a literal dollar sign." + ) + ), + ], + on_error: Annotated[ + str, + Field( + description=( + "stop (default): halt at the first failure, keep what already ran. " + "continue: run every step. rollback: open a checkpoint first and undo " + "on failure - read the returned `atomicity` block for what that is " + "worth on this backend." + ) + ), + ] = "stop", + dry_run: Annotated[ + bool, + "Validate every step against its tool's real JSON Schema and execute nothing.", + ] = False, + verbose: Annotated[ + bool, + "Return each step's full result instead of just its handle.", + ] = False, + ctx: Context = None, +) -> dict: + """Execute an ordered list of tool calls in ONE round trip. + + N calls collapse into one request/response pair, and `bind` lets a later + step reference an earlier step's result so handles never have to be echoed + back through the model. + + steps=[ + {"tool": "entity_create_line", "args": {...}, "bind": "edge"}, + {"tool": "point_from_snap", "args": {"handle": "$edge", "snap": "mid"}, + "bind": "mid"}, + {"tool": "entity_create_circle","args": {"cx": "$mid.x", "cy": "$mid.y", + "radius": 4}}, + ] + + Successful steps report only their handle; pass verbose=True for the full + result. Anything without a handle is returned whole. + + VALIDATION runs first, always: an unknown tool, a schema-invalid argument or + a reference no earlier step binds refuses the whole batch before anything + executes. `on_error` governs run-time failures only. dry_run=True returns + that validation report and executes nothing. + + ERRORS are typed, never text: each failed step carries error.kind - one of + unsupported (with the backend `capability`), invalid_args, refused, failed, + unknown_tool, unresolved_ref, denied, malformed_step. + + ATOMICITY is reported, not assumed. Read the `atomicity` block: on the + headless backend rollback restores a full document snapshot; on live AutoCAD + it sends an UNDO whose landing AutoCAD never confirms. The default + on_error="stop" claims nothing and is exact on both. + + NOT CALLABLE from a batch: the raw command/LISP escape hatches, and + cad_batch itself. Call those directly. + + For a few hundred entities of the same kind, entity_batch_create is denser + still (no per-step tool name) - and it can be one step of a cad_batch. + """ + mode = (on_error or "stop").lower().strip() + if mode not in BATCH_ON_ERROR_MODES: + raise ToolError( + f"cad_batch: on_error must be one of {BATCH_ON_ERROR_MODES}, got {on_error!r}." + ) + if not steps: + raise ToolError("cad_batch: `steps` is empty; there is nothing to run.") + if len(steps) > MAX_BATCH_STEPS: + raise ToolError( + f"cad_batch: {len(steps)} steps exceeds the {MAX_BATCH_STEPS}-step ceiling. " + "Steps run sequentially inside one uncancellable call - split the work." + ) + + denied = _batch_denied_tools() + read_only = _batch_read_only_tools() + plans: list[dict] = [] + bound_names: set[str] = set() + problems: list[str] = [] + + for index, step in enumerate(steps): + row: dict = {"i": index, "tool": None, "status": "valid"} + + def _invalid(kind: str, message: str, row: dict = row, index: int = index) -> None: + row["status"] = "invalid" + row["error"] = {"kind": kind, "message": message} + problems.append(f"step {index}: {message}") + + if not isinstance(step, dict): + _invalid("malformed_step", f"step {index} is {type(step).__name__}, not an object") + plans.append(row) + continue + name = step.get("tool") + args = step.get("args") if step.get("args") is not None else {} + bind = step.get("bind") + row["tool"] = name if isinstance(name, str) else None + unknown_keys = sorted(set(step) - {"tool", "args", "bind"}) + if not isinstance(name, str) or not name: + _invalid("malformed_step", f"step {index} has no 'tool' name") + elif not isinstance(args, dict): + _invalid("malformed_step", f"step {index} 'args' is not an object") + elif bind is not None and (not isinstance(bind, str) or not bind): + _invalid("malformed_step", f"step {index} 'bind' must be a non-empty string") + elif unknown_keys: + _invalid( + "malformed_step", + f"step {index} has unexpected key(s) {unknown_keys}; a step is " + "{'tool', 'args', 'bind'}", + ) + elif name in denied: + _invalid( + "denied", + f"{name!r} is denied inside cad_batch: one batch grant must not reach " + "the raw escape hatches (or another batch executor). Call it directly, " + "where its own gate applies.", + ) + else: + tool = await ctx.fastmcp.get_tool(name) + card = (getattr(tool, "meta", None) or {}).get("cad") or {} + if tool is None: + _invalid( + "unknown_tool", + f"{name!r} is not a callable tool here (unregistered, or hidden by " + "the active TOOL_PROFILE / ENABLE_3D gate).", + ) + elif card.get("cost") == "escape": + # The deny set is computed from the local registry; this repeats + # the check against the *resolved* tool, which is what actually + # runs. A mounted or transformed provider can supply a tool the + # local snapshot never saw. + _invalid( + "denied", + f"{name!r} is an escape-hatch tool (cost='escape') and is denied " + "inside cad_batch. Call it directly, where its own gate applies.", + ) + elif not card: + # Fail closed on unknown provenance. This is not a nicety: under + # DISCOVERY_MODE=search the advertised surface is search_tools + + # `call_tool`, and `call_tool` invokes any tool by name. It is a + # general-purpose proxy living in the same server as this + # denylist, and being card-less is the ONLY thing that stops + # cad_batch -> call_tool -> system_run_command. The deny set + # above cannot see it: it is transform-supplied, not a local + # component with a cost card. + # + # So: giving `call_tool` a @cad_tool card - reasonable-sounding, + # since it would make the proxy costed and discoverable - + # reopens the escalation path. Do that only alongside an + # explicit denial for it. tests/test_cad_batch.py pins this. + _invalid( + "denied", + f"{name!r} carries no @cad_tool discovery card, so cad_batch cannot " + "tell what it costs and will not run it blind. Call it directly.", + ) + else: + missing = sorted(_batch_refs_in(args) - bound_names) + if missing: + _invalid( + "unresolved_ref", + f"step {index} references {missing} which no earlier step binds " + f"(bound by step {index}: {sorted(bound_names) or 'nothing'}).", + ) + else: + detail = _validate_batch_args(tool.parameters or {}, args) + if detail: + _invalid("invalid_args", f"{name}: {detail}") + else: + row["_args"] = args + row["_bind"] = bind + if bind: + bound_names.add(bind) + plans.append(row) + + if dry_run: + invalid = sum(1 for row in plans if row["status"] == "invalid") + guarantee, note = ("none", _BATCH_NO_CLAIM_NOTE.format(mode=mode)) + if mode == "rollback": + guarantee, note = _batch_rollback_guarantee(_backend(ctx)) + note = f"Planned only - a dry run opens no checkpoint. {note}" + return { + "ok": invalid == 0, + "dry_run": True, + "on_error": mode, + "steps": len(steps), + "executed": 0, + "valid": len(plans) - invalid, + "invalid": invalid, + "bindings": sorted(bound_names), + "results": [{k: v for k, v in row.items() if not k.startswith("_")} for row in plans], + "atomicity": _batch_atomicity(mode=mode, guarantee=guarantee, note=note), + "note": ( + "Validated against each tool's real JSON Schema; nothing was executed. " + "A '$reference' validates as a wildcard because its type is only known " + "once the step that binds it has run." + ), + } + + if problems: + raise ToolError( + f"cad_batch refused all {len(steps)} steps - nothing was executed. " + + "; ".join(problems[:5]) + + ("" if len(problems) <= 5 else f" (+{len(problems) - 5} more)") + ) + + backend = _backend(ctx) + guarantee, note = "none", _BATCH_NO_CLAIM_NOTE.format(mode=mode) + transaction = False + before = None + if mode == "rollback": + guarantee, note = _batch_rollback_guarantee(backend) + if guarantee == "none": + raise ToolError(f"cad_batch: on_error='rollback' is unavailable here. {note}") + before = await _batch_fingerprint(backend) + opened = await backend.transaction_begin() + if not (isinstance(opened, dict) and opened.get("ok")): + reason = (opened or {}).get("error", "transaction_begin declined") + return { + "ok": False, + "dry_run": False, + "on_error": mode, + "steps": len(steps), + "executed": 0, + "succeeded": 0, + "failed": 0, + "skipped": len(steps), + "bindings": [], + "results": [ + {"i": row["i"], "tool": row["tool"], "status": "skipped", "reason": reason} + for row in plans + ], + "atomicity": _batch_atomicity( + mode=mode, + guarantee=guarantee, + note=( + f"No checkpoint was opened ({reason}), so nothing ran: a " + "transaction this call did not open is not one it can roll back " + f"to. {note}" + ), + ), + } + transaction = True + + bindings: dict[str, Any] = {} + results: list[dict] = [] + succeeded = failed = executed = 0 + total = len(plans) + await ctx.info(f"cad_batch: {total} step(s), on_error={mode}") + + for position, plan in enumerate(plans): + await ctx.report_progress(position, total) + name = plan["tool"] + row: dict = {"i": plan["i"], "tool": name} + if failed and mode == "stop": + row["status"] = "skipped" + row["reason"] = "an earlier step failed (on_error='stop')" + results.append(row) + continue + try: + args = _substitute_batch_refs(plan["_args"], bindings) + except BatchReferenceError as exc: + failed += 1 + row["status"] = "error" + row["error"] = {"kind": "unresolved_ref", "message": _batch_message(exc)} + log.warning("BATCH %d/%d %-38s unresolved_ref", position + 1, total, name) + results.append(row) + continue + try: + # run_middleware=False: the cad_batch call itself is already through + # the audit/timing chain, and skipping it keeps the error one wrap + # deep instead of three, which is what makes the taxonomy above + # readable. Each step is logged here instead, tagged as a batch step. + outcome = await ctx.fastmcp.call_tool(name, args, run_middleware=False) + except Exception as exc: + failed += 1 + executed += 1 + row["status"] = "error" + row["error"] = _classify_batch_error(exc) + log.warning("BATCH %d/%d %-38s %s", position + 1, total, name, row["error"]["kind"]) + results.append(row) + if mode == "rollback": + break + continue + executed += 1 + structured = getattr(outcome, "structured_content", None) + # A tool may decline *by value* instead of raising: an `is_error` result + # from the refusal middleware, or an `{"ok": False, ...}` payload. Both + # mean the step did not do its work, and counting either as succeeded is + # the same class of lie as the DWG bytes and the unverified rollback — + # the batch would report succeeded:1 over work that never happened. + # + # `ok: False` only carries that meaning for a tool that was supposed to + # ACT. On a read-only checker it means "I looked and found something", + # and treating that as a refusal is how a `validation_check` step came + # to trigger a rollback that destroyed the batch's own geometry. + refused = bool(getattr(outcome, "is_error", False)) or ( + name not in read_only and isinstance(structured, dict) and structured.get("ok") is False + ) + if refused: + failed += 1 + declined_payload = structured if isinstance(structured, dict) else {} + capability = declined_payload.get("capability") + row["status"] = "error" + row["error"] = { + "kind": "unsupported" if capability else "refused", + **({"capability": capability} if capability else {}), + "message": _compact_batch_message( + str(declined_payload.get("error") or "the tool declined") + ), + } + log.warning("BATCH %d/%d %-38s %s", position + 1, total, name, row["error"]["kind"]) + results.append(row) + # `failed` is what on_error='stop' reads at the top of the loop, so + # incrementing it above is the whole stop mechanism. + if mode == "rollback": + break + continue + succeeded += 1 + if plan["_bind"]: + bindings[plan["_bind"]] = structured + row["bind"] = plan["_bind"] + row["status"] = "ok" + row["result"] = _compact_batch_result(structured, verbose) + log.info("BATCH %d/%d %-38s ok", position + 1, total, name) + results.append(row) + + await ctx.report_progress(total, total) + + committed = rolled_back = False + verified = None + after = None + declined = None + if transaction: + if failed: + await ctx.warning("cad_batch: rolling back") + # Read the backend's answer instead of asserting success from + # control flow. A step is free to call transaction_commit and pop + # the checkpoint out from under us, in which case the headless + # backend returns {"ok": False, "error": "No active transaction to + # rollback"} and the caller's work is still there. Reporting + # rolled_back=True off the bare await would be the same class of + # lie as writing DXF bytes into a .dwg. + rolled_back, declined = _batch_transaction_ok(await backend.transaction_rollback()) + after = await _batch_fingerprint(backend) + verified = None if (before is None or after is None) else before == after + else: + committed, declined = _batch_transaction_ok(await backend.transaction_commit()) + + ok = failed == 0 + atomicity = _batch_atomicity( + mode=mode, guarantee=guarantee, transaction=transaction, rolled_back=rolled_back, note=note + ) + if transaction: + atomicity.update( + {"committed": committed, "verified": verified, "before": before, "after": after} + ) + payload = { + "ok": ok, + "dry_run": False, + "on_error": mode, + "steps": total, + "executed": executed, + "succeeded": succeeded, + "failed": failed, + "skipped": sum(1 for row in results if row["status"] == "skipped"), + "bindings": sorted(bindings), + "results": results, + "atomicity": atomicity, + } + if declined: + payload["atomicity"]["note"] = ( + f"BACKEND DECLINED THE {'ROLLBACK' if failed else 'COMMIT'} ({declined}). " + "The checkpoint was gone before we reached it - most likely a step called " + "transaction_commit or transaction_rollback itself. Nothing was undone; " + f"the steps that succeeded are still in the document. {note}" + ) + elif rolled_back and verified is False: + payload["atomicity"]["note"] = ( + "ROLLBACK NOT VERIFIED - the document fingerprint differs from the one " + f"taken before step 1. {note}" + ) + return payload + + +@cad_tool(summary="Draw many entities in one call instead of one round trip each.", cost="mutate") @mcp.tool( annotations={"title": "Batch Create Entities", "readOnlyHint": False}, tags={"entity", "create", "batch"}, @@ -2176,6 +4274,10 @@ async def entity_batch_create( Each entity dict must have a 'type' key and the parameters for that type. Example: [{"type": "line", "x1": 0, "y1": 0, "x2": 100, "y2": 0}, {"type": "circle", "cx": 50, "cy": 50, "radius": 25}] + + Denser than cad_batch for many entities of the same kind (no per-step tool + name), and usable as one step *of* a cad_batch. Use cad_batch when the calls + differ, must be ordered, or must feed each other. """ b = _backend(ctx) results = [] @@ -2216,6 +4318,10 @@ async def entity_batch_create( return {"created": len(results), "errors": errors, "entities": results} +@cad_tool( + summary="Move, rotate, scale, restyle or delete many entities in one call.", + cost="destructive", +) @mcp.tool( annotations={"title": "Batch Modify Entities", "readOnlyHint": False}, tags={"entity", "modify", "batch"}, @@ -2230,6 +4336,9 @@ async def entity_batch_modify( """Apply multiple modifications in a single call. Example: [{"handle": "1A", "action": "move", "dx": 10, "dy": 20}, {"handle": "2B", "action": "delete"}] + + Covers move/rotate/scale/delete/set_properties only. For anything else, for + ordering, or to feed one step's result into the next, use cad_batch. """ b = _backend(ctx) results = [] @@ -2324,6 +4433,10 @@ _LAYER_TEMPLATES = { } +@cad_tool( + summary="Create a standard layer set: architectural, mechanical, electrical or piping.", + cost="mutate", +) @mcp.tool( annotations={"title": "Apply Layer Template", "readOnlyHint": False}, tags={"template", "layer"}, @@ -2359,6 +4472,7 @@ async def template_apply_layers( return {"ok": True, "template": template_key, "layers_created": created, "count": len(created)} +@cad_tool(summary="Show the available layer templates and the layers in each.", cost="read") @mcp.tool( annotations={"title": "List Available Templates", "readOnlyHint": True}, tags={"template", "query"}, @@ -2379,6 +4493,10 @@ async def template_list(ctx: Context = None) -> dict: # --------------------------------------------------------------------------- +@cad_tool( + summary="Sanity-check the drawing for empty layers, zero-length lines and duplicates.", + cost="read", +) @mcp.tool( annotations={"title": "Validate Drawing", "readOnlyHint": True}, tags={"analysis", "validation"}, @@ -2451,6 +4569,7 @@ async def validation_check( # --------------------------------------------------------------------------- +@cad_tool(summary="Zoom out until every entity fits on screen.", cost="safe") @mcp.tool( annotations={"title": "Zoom Extents", "readOnlyHint": False, "destructiveHint": False}, tags={"view"}, @@ -2460,6 +4579,7 @@ async def view_zoom_extents(ctx: Context = None) -> dict: return await _backend(ctx).view_zoom_extents() +@cad_tool(summary="Zoom in on a rectangular region of the drawing.", cost="safe") @mcp.tool( annotations={"title": "Zoom Window", "readOnlyHint": False, "destructiveHint": False}, tags={"view"}, @@ -2475,16 +4595,31 @@ async def view_zoom_window( return await _backend(ctx).view_zoom_window(x1, y1, x2, y2) +@cad_tool(summary="Capture a PNG picture of the drawing as it currently looks.", cost="read") @mcp.tool( annotations={"title": "Screenshot", "readOnlyHint": True}, tags={"view", "screenshot"}, ) -async def view_screenshot(ctx: Context = None): +async def view_screenshot( + overlay_handles: Annotated[ + bool, + "Label each entity with its handle, so what you see maps to what you " + "can modify. Headless backend only.", + ] = False, + ctx: Context = None, +): """Capture a screenshot of the current drawing view. COM backend: captures live AutoCAD window at current view. ezdxf backend: renders via matplotlib to PNG. + With `overlay_handles`, each entity is labelled with its handle at its own + centre — every modify tool takes a handle, and without the labels there is + nothing connecting "the circle at the top-left" to a hex string you can act + on. Crowded drawings are capped and the image says how many of how many were + labelled. Live AutoCAD captures its own window, so there is no render to + label there; it refuses with `capability: "handle_overlay"`. + Returns an Image content block with the PNG data. """ from fastmcp.utilities.types import Image @@ -2493,7 +4628,7 @@ async def view_screenshot(ctx: Context = None): await ctx.report_progress(0, 100) b = _backend(ctx) - png_bytes = await b.view_screenshot() + png_bytes = await b.view_screenshot(overlay_handles=overlay_handles) await ctx.report_progress(100, 100) @@ -2507,6 +4642,10 @@ async def view_screenshot(ctx: Context = None): return Image(data=png_bytes, format="png") +@cad_tool( + summary="Fit the drawing on screen, then capture it: the quickest visual check.", + cost="read", +) @mcp.tool( annotations={"title": "Zoom and Screenshot", "readOnlyHint": True}, tags={"view", "screenshot"}, @@ -2548,6 +4687,7 @@ async def view_zoom_and_screenshot( # --------------------------------------------------------------------------- +@cad_tool(summary="Open a rollback checkpoint before a risky edit.", cost="mutate") @mcp.tool( annotations={"title": "Begin Transaction", "readOnlyHint": False, "destructiveHint": False}, tags={"transaction"}, @@ -2567,6 +4707,7 @@ async def transaction_begin(ctx: Context = None) -> dict: return await _backend(ctx).transaction_begin() +@cad_tool(summary="Keep the changes and drop the checkpoint.", cost="safe") @mcp.tool( annotations={"title": "Commit Transaction", "readOnlyHint": False, "destructiveHint": False}, tags={"transaction"}, @@ -2581,6 +4722,10 @@ async def transaction_commit(ctx: Context = None) -> dict: return await _backend(ctx).transaction_commit() +@cad_tool( + summary="Throw away every change made since transaction_begin.", + cost="destructive", +) @mcp.tool( annotations={"title": "Rollback Transaction", "readOnlyHint": False, "destructiveHint": True}, tags={"transaction"}, @@ -2602,6 +4747,10 @@ async def transaction_rollback(ctx: Context = None) -> dict: # --------------------------------------------------------------------------- +@cad_tool( + summary="Check the connection: which engine is live and what document is open.", + cost="read", +) @mcp.tool( annotations={"title": "Server Status", "readOnlyHint": True}, tags={"system"}, @@ -2640,6 +4789,7 @@ async def system_status(ctx: Context = None) -> dict: return status +@cad_tool(summary="Ask the active backend which features it really supports.", cost="read") @mcp.tool( annotations={"title": "Backend Capabilities", "readOnlyHint": True}, tags={"system", "query"}, @@ -2649,6 +4799,7 @@ async def system_capabilities(ctx: Context = None) -> dict: return _backend(ctx).capabilities().to_dict() +@cad_tool(summary="Read one AutoCAD system variable, such as DIMSCALE or LTSCALE.", cost="read") @mcp.tool( annotations={"title": "Get System Variable", "readOnlyHint": True}, tags={"system"}, @@ -2664,6 +4815,7 @@ async def system_get_variable( return {"variable": name, "value": value} +@cad_tool(summary="Set one AutoCAD system variable by name.", cost="safe") @mcp.tool( annotations={"title": "Set System Variable", "readOnlyHint": False}, tags={"system"}, @@ -2677,6 +4829,10 @@ async def system_set_variable( return await _backend(ctx).system_set_variable(name, value) +@cad_tool( + summary="Read or change drawing units, precision, linetype and dimension scale by name.", + cost="safe", +) @mcp.tool( annotations={ "title": "Drawing Settings (read / change)", @@ -2712,6 +4868,10 @@ async def drawing_settings( return await _backend(ctx).drawing_settings(settings) +@cad_tool( + summary="Send a raw command string to the AutoCAD command line (COM only).", + cost="escape", +) @mcp.tool( annotations={"title": "Run AutoCAD Command", "readOnlyHint": False}, tags={"system"}, @@ -2728,12 +4888,19 @@ async def system_run_command( -STYLE return to '[?/Create/Load/Set]:' after their action) need an EXTRA blank line or '_X\\n' to exit, otherwise AutoCAD stays at a prompt and the next COM call will deadlock. Example: '_-LINETYPE _LOAD CENTER acad.lin\\n\\n'. + + A verb denylist refuses obviously destructive commands, but it is a guardrail + against issuing `ERASE ALL` by accident, NOT a security boundary — AutoCAD + accepts hundreds of commands and any loaded ARX/LISP adds more. Prefer the + typed tools (entity_delete, drawing_save_as, block_insert, drawing_purge): + they validate their arguments, which a free-text command string cannot. """ sanitize_command(command) await ctx.warning(f"Running command: {command}") return await _backend(ctx).system_run_command(command) +@cad_tool(summary="Evaluate an AutoLISP expression inside AutoCAD (COM only).", cost="escape") @mcp.tool( annotations={"title": "Execute AutoLISP", "readOnlyHint": False}, tags={"system"}, @@ -2745,12 +4912,21 @@ async def system_run_lisp( """Execute an AutoLISP expression (COM backend only). Example: '(setvar \"DIMSCALE\" 1.0)' + + A symbol denylist refuses the known code-execution and file-I/O channels; + text inside double quotes is treated as data, so drawing notes are not + mistaken for code. It is a guardrail, NOT a security boundary — AutoLISP has + more write channels than any denylist enumerates. Prefer the typed tools. """ sanitize_lisp(expression) await ctx.warning(f"Running LISP: {expression[:80]}") return await _backend(ctx).system_run_lisp(expression) +@cad_tool( + summary="See what this server can do: version, tool groups and active profile.", + cost="read", +) @mcp.tool( annotations={"title": "Backend Info", "readOnlyHint": True}, tags={"system"}, @@ -2786,6 +4962,10 @@ async def system_about(ctx: Context = None) -> dict: # --------------------------------------------------------------------------- +@cad_tool( + summary="Draw a helical gear front view: true involute teeth, circles, bore and keyway.", + cost="mutate", +) @mcp.tool( annotations={"title": "Gear: Helical Front View", "destructiveHint": False}, tags={"engineering", "gear"}, @@ -2846,6 +5026,10 @@ async def gear_draw_helical_front_view( ) +@cad_tool( + summary="Draw a straight-tooth spur gear front view with true involute teeth.", + cost="mutate", +) @mcp.tool( annotations={"title": "Gear: Spur Front View", "destructiveHint": False}, tags={"engineering", "gear"}, @@ -2878,6 +5062,10 @@ async def gear_draw_spur_front_view( ) +@cad_tool( + summary="Draw the hatched side cross-section A-A of a gear you already drew.", + cost="mutate", +) @mcp.tool( annotations={"title": "Gear: Section A-A View", "destructiveHint": False}, tags={"engineering", "gear"}, @@ -2908,6 +5096,10 @@ async def gear_draw_section_aa( ) +@cad_tool( + summary="Draw a bore with a DIN 6885 keyway, auto-sized from the bore diameter.", + cost="mutate", +) @mcp.tool( annotations={"title": "Keyway: Keyed Bore (front view)", "destructiveHint": False}, tags={"engineering", "keyway"}, @@ -2935,6 +5127,7 @@ async def keyway_draw_keyed_bore( ) +@cad_tool(summary="Draw the side cross-section of a keyed bore.", cost="mutate") @mcp.tool( annotations={"title": "Keyway: Side Section", "destructiveHint": False}, tags={"engineering", "keyway"}, @@ -2962,6 +5155,10 @@ async def keyway_draw_section( ) +@cad_tool( + summary="Stamp an ISO 7200 title block and sheet frame onto an A3 drawing.", + cost="mutate", +) @mcp.tool( annotations={"title": "TitleBlock: ISO A3", "destructiveHint": False}, tags={"engineering", "titleblock"}, @@ -2981,9 +5178,20 @@ async def titleblock_apply_iso_a3( company: Annotated[str, Field(default="Anka-Makine")] = "Anka-Makine", origin_x: Annotated[float, Field(default=0.0)] = 0.0, origin_y: Annotated[float, Field(default=0.0)] = 0.0, + layout: Annotated[ + str, + "Paper-space layout to draw the sheet on (create it with layout_create). " + "Empty draws in the current space, as before.", + ] = "", ctx: Context = None, ) -> dict: - """ISO 7200 / A3 (420x297 mm) title block. Title text is used verbatim.""" + """ISO 7200 / A3 (420x297 mm) title block. Title text is used verbatim. + + Pass `layout` to put the sheet on a paper-space layout, which is where a + title block belongs — the border frames the printed sheet, not the model. + Your current space is restored afterwards, so asking for a border does not + move you onto the sheet. + """ from engineering import TitleBlockMetadata, apply_iso_a3_titleblock backend = _backend(ctx) @@ -3001,9 +5209,18 @@ async def titleblock_apply_iso_a3( revision=revision, company=company, ) - return await apply_iso_a3_titleblock(backend, metadata=metadata, origin=(origin_x, origin_y)) + return await apply_iso_a3_titleblock( + backend, + metadata=metadata, + origin=(origin_x, origin_y), + layout=layout or None, + ) +@cad_tool( + summary="Finish the drawing: validate, critique, save, screenshot and score it.", + cost="destructive", +) @mcp.tool( annotations={ "title": "Drawing: Finalize (validate + save + screenshot)", @@ -3117,6 +5334,10 @@ async def drawing_finalize( return payload +@cad_tool( + summary="Hand the drawing off: a hashed, validated bundle of files plus a manifest.", + cost="destructive", +) @mcp.tool( annotations={ "title": "Drawing: Deliver Auditable Bundle", @@ -3174,6 +5395,10 @@ async def drawing_deliver( # See `.claude/skills/autocad-mcp-premium/` for the full discipline. +@cad_tool( + summary="Check the brief before you draw: units, part type, tolerances, missing inputs.", + cost="read", +) @mcp.tool( annotations={"title": "Drawing: Preflight", "readOnlyHint": True}, tags={"premium", "planning", "validation"}, @@ -3212,6 +5437,10 @@ async def drawing_preflight( return result.to_dict() +@cad_tool( + summary="Commit sheet size, scale, layer set and dimension style before any geometry.", + cost="safe", +) @mcp.tool( annotations={"title": "Drawing: Plan (commit intent before drawing)", "destructiveHint": False}, tags={"premium", "planning"}, @@ -3260,6 +5489,10 @@ async def drawing_plan( return _dc(plan) +@cad_tool( + summary="Review the drawing for drafting mistakes: must come back empty before finalize.", + cost="read", +) @mcp.tool( annotations={ "title": "Drawing: Critique (premium quality checks)", @@ -3287,6 +5520,10 @@ async def drawing_critique( return [_dc(i) for i in issues] +@cad_tool( + summary="Auto-repair what the critique found, then re-check, up to three rounds.", + cost="destructive", +) @mcp.tool( annotations={"title": "Drawing: Refine", "destructiveHint": True}, tags={"premium", "validation", "modify"}, @@ -3315,6 +5552,10 @@ async def drawing_refine( return result.to_dict() +@cad_tool( + summary="Get an exact endpoint, midpoint, centre, quadrant or perpendicular foot.", + cost="read", +) @mcp.tool( annotations={"title": "Point: Snap (deterministic OSNAP)", "readOnlyHint": True}, tags={"premium", "snap"}, @@ -3335,6 +5576,7 @@ async def point_from_snap( return {"x": float(pt[0]), "y": float(pt[1])} +@cad_tool(summary="Find where two lines or circles cross, exactly.", cost="read") @mcp.tool( annotations={"title": "Point: Intersection (deterministic)", "readOnlyHint": True}, tags={"premium", "snap"}, @@ -3356,6 +5598,7 @@ async def point_intersection( return {"x": float(pt[0]), "y": float(pt[1])} +@cad_tool(summary="Find where a line from an outside point touches a circle.", cost="read") @mcp.tool( annotations={"title": "Point: Tangent from external point", "readOnlyHint": True}, tags={"premium", "snap"}, @@ -3377,6 +5620,7 @@ async def point_tangent( return {"x": float(pt[0]), "y": float(pt[1])} +@cad_tool(summary="Lay down an infinite guide line to build geometry against.", cost="mutate") @mcp.tool( annotations={"title": "Construction: XLine (infinite reference)", "destructiveHint": False}, tags={"premium", "construction"}, @@ -3395,6 +5639,10 @@ async def construction_xline( return _dc(result) +@cad_tool( + summary="Wipe the construction scaffolding off the drawing before finalize.", + cost="destructive", +) @mcp.tool( annotations={"title": "Construction: Clear (delete scaffold)", "destructiveHint": True}, tags={"premium", "construction"}, @@ -3409,6 +5657,10 @@ async def construction_clear( return await _backend(ctx).construction_clear(layer) +@cad_tool( + summary="Set up a standard ISO layer set with the right colours and lineweights.", + cost="mutate", +) @mcp.tool( annotations={"title": "Drawing: Apply ISO Layer Set (bootstrap)", "destructiveHint": False}, tags={"premium", "layers"}, @@ -3429,6 +5681,10 @@ async def drawing_apply_iso_layers( return await _backend(ctx).drawing_apply_iso_layers(standard) +@cad_tool( + summary="Dimension a set of entities at once, as a chain, baseline or ordinate run.", + cost="mutate", +) @mcp.tool( annotations={ "title": "Dimension: Auto (chain / baseline / ordinate)", @@ -3453,6 +5709,10 @@ async def dimension_auto( return [_dc(e) for e in result] +@cad_tool( + summary="Pick every entity matching a description: type, layer, colour, length, location.", + cost="read", +) @mcp.tool( annotations={"title": "Entity: Smart Select (semantic predicate)", "readOnlyHint": True}, tags={"premium", "select"}, @@ -3468,11 +5728,24 @@ async def entity_select_smart( ) ), ], + fields: ResultFields = None, + compact: ResultCompact = False, ctx: Context = None, -) -> list[dict]: - """Select entities by semantic predicate instead of memorising handles.""" +) -> list[dict] | dict: + """Select entities by semantic predicate instead of memorising handles. + + Uncapped. The usual next step is dimension_auto(handles), so + fields=["handle"] is normally all this needs to return. + """ result = await _backend(ctx).entity_select_smart(predicate) - return [_dc(e) for e in result] + return _shape_rows( + result, + spec=EntityInfo, + fields=fields, + compact=compact, + tool="entity_select_smart", + total=len(result), + ) # --------------------------------------------------------------------------- @@ -3483,6 +5756,10 @@ async def entity_select_smart( # consistency rule is enforced by the `gdt` critique focus at finalize time. +@cad_tool( + summary="Draw an ISO 1101 feature control frame: symbol, tolerance zone and datums.", + cost="mutate", +) @mcp.tool( annotations={"title": "GD&T: Feature Control Frame (ISO 1101)", "destructiveHint": False}, tags={"engineering", "gdt"}, @@ -3542,6 +5819,10 @@ async def gd_frame( ) +@cad_tool( + summary="Mark a datum on a feature: filled triangle plus the boxed datum letter.", + cost="mutate", +) @mcp.tool( annotations={"title": "GD&T: Datum Feature (ISO 1101)", "destructiveHint": False}, tags={"engineering", "gdt"}, @@ -3563,10 +5844,11 @@ async def datum_feature( # --------------------------------------------------------------------------- -# ── SECTION 15: Layouts & Paper Space (4 tools) ───────────────────────────── +# ── SECTION 15: Layouts & Paper Space (12 tools) ──────────────────────────── # --------------------------------------------------------------------------- +@cad_tool(summary="List the sheet tabs: Model plus every paper-space layout.", cost="read") @mcp.tool( annotations={"title": "List Layouts", "readOnlyHint": True}, tags={"layout", "query"}, @@ -3576,6 +5858,7 @@ async def layout_list(ctx: Context = None) -> dict: return await _backend(ctx).layout_list() +@cad_tool(summary="Add a new paper-space sheet tab to the drawing.", cost="mutate") @mcp.tool( annotations={"title": "Create Layout", "destructiveHint": False}, tags={"layout"}, @@ -3589,6 +5872,7 @@ async def layout_create( return await _backend(ctx).layout_create(name) +@cad_tool(summary="Switch to a sheet tab, or back to model space.", cost="safe") @mcp.tool( annotations={"title": "Set Current Layout"}, tags={"layout"}, @@ -3601,6 +5885,10 @@ async def layout_set_current( return await _backend(ctx).layout_set_current(name) +@cad_tool( + summary="Put a scaled window onto model space on a sheet, at 1:1, 1:2 and so on.", + cost="mutate", +) @mcp.tool( annotations={"title": "Create Viewport", "destructiveHint": False}, tags={"layout"}, @@ -3630,6 +5918,178 @@ async def viewport_create( ) +@cad_tool(summary="Delete a sheet tab and everything drawn on it.", cost="destructive") +@mcp.tool( + annotations={"title": "Delete Layout", "destructiveHint": True}, + tags={"layout"}, +) +async def layout_delete( + name: Annotated[str, "Paper-space layout tab to delete (never 'Model')."], + ctx: Context = None, +) -> dict: + """Delete a paper-space layout and every entity on it. + + Refuses model space, a blank name, and the last remaining sheet. If the + deleted tab was the current one, the returned `current` is where geometry + goes next — and handles from the deleted sheet stop resolving. + """ + await ctx.info(f"Deleting layout {name}") + return await _backend(ctx).layout_delete(name) + + +@cad_tool(summary="Rename a sheet tab, keeping its geometry and handles.", cost="mutate") +@mcp.tool( + annotations={"title": "Rename Layout", "destructiveHint": False}, + tags={"layout"}, +) +async def layout_rename( + old_name: Annotated[str, "Existing layout tab name."], + new_name: Annotated[str, "New name; must not be blank or contain / \\ * ? : ; , = `"], + ctx: Context = None, +) -> dict: + """Rename a paper-space layout. Entity handles are unaffected.""" + return await _backend(ctx).layout_rename(old_name, new_name) + + +@cad_tool(summary="Duplicate a sheet with its page setup and geometry.", cost="mutate") +@mcp.tool( + annotations={"title": "Copy Layout", "destructiveHint": False}, + tags={"layout"}, +) +async def layout_copy( + source: Annotated[str, "Layout tab to copy from (never 'Model')."], + new_name: Annotated[str, "Name for the new layout tab."], + ctx: Context = None, +) -> dict: + """Copy a paper-space layout: page setup, plot settings and all geometry. + + `skipped` names any DXF types that could not be cloned — check it rather + than trusting `ok` alone. Associative hatch boundaries are re-pointed at the + cloned entities; `associativity_dropped` counts those that referenced + something outside the source layout and had to be cleared. + """ + await ctx.info(f"Copying layout {source} to {new_name}") + return await _backend(ctx).layout_copy(source, new_name) + + +@cad_tool(summary="List the viewports on a sheet with their scales and locks.", cost="read") +@mcp.tool( + annotations={"title": "List Viewports", "readOnlyHint": True}, + tags={"layout", "query"}, +) +async def viewport_list( + layout: Annotated[ + str, + "Restrict to one paper-space layout. Empty covers every sheet.", + ] = "", + ctx: Context = None, +) -> dict: + """List paper-space viewports: handle, geometry, scale and lock state. + + The layout's own main viewport is included with `is_main: true` — it is the + tab's pan/zoom state rather than a drafting viewport, and it is what remains + after every drafting viewport is deleted. `scale` and `locked` are null on + documents that cannot store them (R12) rather than fabricated. + """ + return await _backend(ctx).viewport_list(layout or None) + + +@cad_tool(summary="Set a viewport's scale, e.g. 1:2 or 1:50.", cost="mutate") +@mcp.tool( + annotations={"title": "Set Viewport Scale", "destructiveHint": False}, + tags={"layout"}, +) +async def viewport_set_scale( + handle: Annotated[str, "Viewport entity handle (from viewport_list)."], + scale: Annotated[ + float, + Field(gt=0, description="Paper:model scale (1.0 = 1:1, 0.5 = 1:2, 0.02 = 1:50)."), + ], + ctx: Context = None, +) -> dict: + """Rescale a viewport by adjusting its view height. + + Geometric scale only: annotative text and dimensions do not resize with it. + Refuses the layout's main viewport, whose view height is the tab's own + pan/zoom state rather than a drafting scale. + """ + return await _backend(ctx).viewport_set_scale(handle, scale) + + +@cad_tool(summary="Lock a viewport so its scale cannot be zoomed away.", cost="safe") +@mcp.tool( + annotations={"title": "Lock Viewport"}, + tags={"layout"}, +) +async def viewport_lock( + handle: Annotated[str, "Viewport entity handle (from viewport_list)."], + locked: Annotated[bool, "True to lock the display scale, False to unlock."] = True, + ctx: Context = None, +) -> dict: + """Lock or unlock a viewport's display scale.""" + return await _backend(ctx).viewport_lock(handle, locked) + + +@cad_tool(summary="Remove a viewport from a sheet.", cost="destructive") +@mcp.tool( + annotations={"title": "Delete Viewport", "destructiveHint": True}, + tags={"layout"}, +) +async def viewport_delete( + handle: Annotated[str, "Viewport entity handle (from viewport_list)."], + force: Annotated[bool, "Allow deleting the layout's main viewport."] = False, + ctx: Context = None, +) -> dict: + """Delete a viewport. + + The layout's main viewport needs `force=true`; deleting it removes the tab's + own view state, and the layout's current-viewport pointer is repaired so the + file does not carry a dangling reference that only CAD would notice. + """ + return await _backend(ctx).viewport_delete(handle, force) + + +@cad_tool( + summary="Move entities between model space and a sheet through a viewport.", + cost="mutate", +) +@mcp.tool( + annotations={"title": "Change Space", "destructiveHint": False}, + tags={"layout", "modify"}, +) +async def entity_change_space( + handles: Annotated[list[str], "Entity handles to move."], + viewport_handle: Annotated[str, "Viewport that defines the model-to-paper mapping."], + direction: Annotated[ + str, + Field( + default="to_paper", + description="to_paper (model -> sheet) or to_model (sheet -> model).", + ), + ] = "to_paper", + freeze_dimensions: Annotated[ + bool, + "Bake each dimension's current measurement into its text before scaling.", + ] = False, + ctx: Context = None, +) -> dict: + """AutoCAD's CHSPACE: move entities across spaces, rescaled by the viewport. + + Geometry is transformed by the viewport's own matrix so it stays the same + size on screen — a move without that transform would leave a 100 mm feature + as 100 mm of paper inside a 1:2 viewport. + + Refused per entity for dimensions (unless `freeze_dimensions`), ACIS solids, + tables and proxies, viewports, and entities already in the target space; + refused outright for a twisted or non-plan viewport. Entities that end up + outside the viewport or off the sheet are moved and flagged, not refused. + """ + await ctx.info(f"Changing space for {len(handles)} entities ({direction})") + return await _backend(ctx).entity_change_space( + handles, viewport_handle, direction, freeze_dimensions + ) + + # --------------------------------------------------------------------------- # ── SECTION 16: 3D Solids (5 tools) — opt-in via ENABLE_3D ────────────────── # --------------------------------------------------------------------------- @@ -3644,6 +6104,7 @@ def _require_3d() -> None: ) +@cad_tool(summary="Create a 3D solid box (live AutoCAD, opt-in via ENABLE_3D).", cost="mutate") @mcp.tool( annotations={"title": "Solid: Box", "destructiveHint": False}, tags={"solid"}, @@ -3662,6 +6123,10 @@ async def solid_box( return await _backend(ctx).solid_box(cx, cy, cz, length, width, height) +@cad_tool( + summary="Create a 3D solid cylinder or shaft (live AutoCAD, opt-in via ENABLE_3D).", + cost="mutate", +) @mcp.tool( annotations={"title": "Solid: Cylinder", "destructiveHint": False}, tags={"solid"}, @@ -3679,6 +6144,10 @@ async def solid_cylinder( return await _backend(ctx).solid_cylinder(cx, cy, cz, radius, height) +@cad_tool( + summary="Pull a closed profile up into a 3D solid (live AutoCAD, opt-in via ENABLE_3D).", + cost="mutate", +) @mcp.tool( annotations={"title": "Solid: Extrude", "destructiveHint": False}, tags={"solid"}, @@ -3696,6 +6165,10 @@ async def solid_extrude( return await _backend(ctx).solid_extrude(profile_handle, height, taper_angle) +@cad_tool( + summary="Spin a closed profile around an axis into a 3D solid (opt-in via ENABLE_3D).", + cost="mutate", +) @mcp.tool( annotations={"title": "Solid: Revolve", "destructiveHint": False}, tags={"solid"}, @@ -3718,6 +6191,10 @@ async def solid_revolve( ) +@cad_tool( + summary="Union, subtract or intersect two 3D solids; the tool solid is consumed.", + cost="destructive", +) @mcp.tool( annotations={"title": "Solid: Boolean", "destructiveHint": True}, tags={"solid"}, diff --git a/tests/conftest.py b/tests/conftest.py index e0e5612..7a18051 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,37 @@ """Shared fixtures for AutoCAD MCP tests.""" +import pytest import pytest_asyncio from backends.ezdxf_backend import EzdxfBackend +@pytest.fixture(autouse=True) +def pin_headless_backend(request, monkeypatch): + """Every test talks to the headless engine unless it says otherwise. + + This suite runs on developer machines that have AutoCAD installed, and a + test that opens ``Client(server.mcp)`` without pinning would run the real + lifespan: ``AUTOCAD_MCP_BACKEND`` unset means ``auto``, which on Windows + means attaching to the operator's open, unsaved drawing and creating + entities in it. That has actually happened here. Defaulting the whole suite + to ``ezdxf`` is the only guard that covers tests nobody has written yet. + + Opt out with ``@pytest.mark.live_backend`` for a test that deliberately + exercises backend selection. + """ + if request.node.get_closest_marker("live_backend"): + return + monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf") + + +def pytest_configure(config): + config.addinivalue_line( + "markers", + "live_backend: test drives backend selection itself; do not pin AUTOCAD_MCP_BACKEND", + ) + + @pytest_asyncio.fixture async def backend(): """Create an ezdxf backend with a fresh empty document.""" diff --git a/tests/data/__init__.py b/tests/data/__init__.py new file mode 100644 index 0000000..f3ab12a --- /dev/null +++ b/tests/data/__init__.py @@ -0,0 +1 @@ +"""Static fixtures for the test suite.""" diff --git a/tests/data/discovery_golden.py b/tests/data/discovery_golden.py new file mode 100644 index 0000000..3ed8b4c --- /dev/null +++ b/tests/data/discovery_golden.py @@ -0,0 +1,179 @@ +"""Golden query set for tool discovery: English in, one right tool out. + +Sixty-six cases (51 tuning + 15 holdout) split into a **tuning** set and a +**holdout** set. The split is +the point of the file. Ranking work is measured against +:data:`TUNING_CASES` only; :data:`HOLDOUT_CASES` were written at the same time, +before any scoring parameter was chosen, and were not looked at until the +scorer was frozen. A retrieval number quoted against cases the ranker was fitted +to is not evidence of anything. + +Rules the cases follow: + +* Every ``expect`` is a tool that genuinely, unambiguously does the job. Where + two tools would both be defensible answers (``view_screenshot`` vs + ``view_zoom_and_screenshot``, ``entity_delete`` vs ``entity_delete_many``) + the query is worded to pick one, or the case is left out. A golden set that + scores ambiguity is measuring the author, not the ranker. +* ``kind`` records what the case exercises, so accuracy can be reported per + category instead of as one flattering average. +* ``risk`` is the ceiling a caller would sensibly pass. It is ``"read"`` for + the counting questions — the ones where stock BM25 answers "how many entities + are on the GEOMETRY layer" with ``entity_delete_many``. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +__all__ = ["ALL_CASES", "HOLDOUT_CASES", "TUNING_CASES", "GoldenCase"] + + +@dataclass(frozen=True, slots=True) +class GoldenCase: + """One query and the tool it must find.""" + + query: str + expect: str + kind: str # "command" | "synonym" | "paraphrase" | "counting" + risk: str = "any" + + def __str__(self) -> str: # readable pytest ids + return f"{self.kind}:{self.query}" + + +# ── tuning set ────────────────────────────────────────────────────────────── +# Free to be measured against, argued with and re-measured. + +TUNING_CASES: tuple[GoldenCase, ...] = ( + # AutoCAD command names a drafter types verbatim. + # Re-pointed in v1.5.0 (M8): BPOLY now has a real destination. It was + # parked on entity_create_polyline only because nothing traced a boundary, + # and this is a correction of the expectation, not a tuning of the ranker. + GoldenCase("BPOLY", "boundary_trace", "command"), + GoldenCase("PEDIT", "entity_edit_geometry", "command"), + GoldenCase("WBLOCK", "block_create_from_entities", "command"), + GoldenCase("OVERKILL", "drawing_refine", "command"), + GoldenCase("LAYTRANS", "template_apply_layers", "command"), + GoldenCase("MATCHPROP", "entity_set_properties", "command"), + GoldenCase("QSELECT", "entity_select_smart", "command"), + GoldenCase("DIMLINEAR", "dimension_linear", "command"), + GoldenCase("ARRAYPOLAR", "entity_array_polar", "command"), + GoldenCase("MLEADER", "leader_create_mleader", "command"), + GoldenCase("BHATCH", "entity_create_hatch", "command"), + GoldenCase("LAYISO", "layer_isolate", "command"), + GoldenCase("XLINE", "construction_xline", "command"), + GoldenCase("EXPLODE", "block_explode", "command"), + GoldenCase("DXFOUT", "drawing_export_dxf", "command"), + GoldenCase("QDIM", "dimension_auto", "command"), + GoldenCase("CHA", "entity_chamfer", "command"), + GoldenCase("DLI", "dimension_linear", "command"), + GoldenCase("LAYDEL", "layer_delete", "command"), + GoldenCase("PURGE", "drawing_purge", "command"), + # Shop-floor vocabulary. + GoldenCase("bill of materials", "entity_create_table", "synonym"), + GoldenCase("parts list", "entity_create_table", "synonym"), + GoldenCase("round the corner", "entity_fillet", "synonym"), + GoldenCase("bolt circle", "entity_array_polar", "synonym"), + GoldenCase("title block", "titleblock_apply_iso_a3", "synonym"), + GoldenCase("keyed bore", "keyway_draw_keyed_bore", "synonym"), + GoldenCase("feature control frame", "gd_frame", "synonym"), + GoldenCase("object snap", "point_from_snap", "synonym"), + GoldenCase("zoom to fit", "view_zoom_extents", "synonym"), + GoldenCase("smooth curve", "entity_create_spline", "synonym"), + GoldenCase("true position", "gd_frame", "synonym"), + GoldenCase("datum triangle", "datum_feature", "synonym"), + # Paraphrases — no shared vocabulary with the tool name. + GoldenCase("I need the corner rounded off with a 3 mm radius", "entity_fillet", "paraphrase"), + GoldenCase( + "put an arrow with a note pointing at the hole", "leader_create_mleader", "paraphrase" + ), + GoldenCase("turn a layer off so it does not print", "layer_hide", "paraphrase"), + GoldenCase("make the whole shape smaller by half", "entity_scale", "paraphrase"), + GoldenCase( + "repeat this shape around a centre point six times", "entity_array_polar", "paraphrase" + ), + GoldenCase( + "the two lines overshoot, cut them back to where they cross", "entity_trim", "paraphrase" + ), + GoldenCase("write a paragraph of general notes", "entity_create_mtext", "paraphrase"), + GoldenCase("take a picture of the current drawing", "view_screenshot", "paraphrase"), + GoldenCase("start a checkpoint I can roll back to", "transaction_begin", "paraphrase"), + GoldenCase( + "what is wrong with this drawing before I finish it", "drawing_critique", "paraphrase" + ), + GoldenCase("fill this closed area with section hatching", "entity_create_hatch", "paraphrase"), + GoldenCase("bevel a corner at 45 degrees", "entity_chamfer", "paraphrase"), + GoldenCase("load the dashed hidden linetype", "linetype_load", "paraphrase"), + # Counting / inspection. Under stock BM25 the first of these answers with + # entity_delete_many. + GoldenCase( + "how many entities are on the GEOMETRY layer", + "analysis_entity_stats", + "counting", + risk="read", + ), + GoldenCase("what layers exist in this drawing", "layer_list", "counting", risk="read"), + GoldenCase("how big is the drawing overall", "analysis_bounding_box", "counting", risk="read"), + # Re-pointed when analysis_measure_entity was added, NOT to match the + # ranker: "this closed polyline" names an entity, and until that tool existed + # the only destination was the one tool that cannot look at the drawing. The + # expectation was wrong the whole time; the case is now answerable. + GoldenCase( + "what is the area of this closed polyline", + "analysis_measure_entity", + "counting", + risk="read", + ), + # Withdrawn: "list every circle in the drawing" -> analysis_select_by_type. + # ``entity_list(type_filter="CIRCLE")`` answers it just as correctly (and + # with a layer filter and paging on top), so the case scored the author's + # preference, not the ranker. Replaced rather than re-labelled: relabelling + # a case to whatever the ranker returned is how a golden set stops being + # evidence. + GoldenCase( + "what is inside this rectangular window", + "analysis_find_in_region", + "counting", + risk="read", + ), + GoldenCase( + "where is this block used in the drawing", + "block_find_references", + "counting", + risk="read", + ), +) + + +# ── holdout set ───────────────────────────────────────────────────────────── +# Written alongside the tuning cases, before any scoring constant was chosen, +# and deliberately not measured until the scorer was frozen. Do not tune +# against these. If a holdout case fails, the honest fix is a new tuning case +# that captures the same class of failure — not an edit to this tuple. + +HOLDOUT_CASES: tuple[GoldenCase, ...] = ( + GoldenCase("DDEDIT", "entity_edit_text", "command"), + GoldenCase("MVIEW", "viewport_create", "command"), + GoldenCase("SPL", "entity_create_spline", "command"), + GoldenCase("TOLERANCE", "gd_frame", "command"), + GoldenCase("crosshatch", "entity_create_hatch", "synonym"), + GoldenCase("wall thickness", "entity_offset", "synonym"), + GoldenCase("undo the undo", "drawing_redo", "synonym"), + GoldenCase("pitch circle", "entity_create_circle", "synonym"), + GoldenCase("I want a parallel copy 5 mm inside the outline", "entity_offset", "paraphrase"), + GoldenCase("get rid of the unused blocks and layers", "drawing_purge", "paraphrase"), + GoldenCase("change the wording of this label", "entity_edit_text", "paraphrase"), + GoldenCase("make a scaled window of the model on the sheet", "viewport_create", "paraphrase"), + GoldenCase( + "how far apart are these two points", + "analysis_measure_distance", + "counting", + risk="read", + ), + GoldenCase("which blocks are defined", "block_list", "counting", risk="read"), + GoldenCase("what are the properties of this entity", "entity_get", "counting", risk="read"), +) + + +ALL_CASES: tuple[GoldenCase, ...] = TUNING_CASES + HOLDOUT_CASES diff --git a/tests/test_annotation_objects.py b/tests/test_annotation_objects.py new file mode 100644 index 0000000..aacd0c8 --- /dev/null +++ b/tests/test_annotation_objects.py @@ -0,0 +1,220 @@ +"""M8/F15 — annotation objects: wipeout, revision cloud, MTEXT mask, find/replace. + +Four tools that fill real gaps rather than restating the 141 already here. Each +carries a measured caveat: + +* **WIPEOUT** is a real DXF entity (`add_wipeout`) that round-trips. +* **REVCLOUD** is genuinely available: `ezdxf.revcloud.add_entity()` emits the + bulged LWPOLYLINE that AutoCAD's REVCLOUD *is*, stamps the `RevcloudProps` + xdata and picks the bulge sign from the winding direction — so the result is + recognised by `is_revcloud()` rather than merely resembling a cloud. The trap + is the segment length: pass one larger than the shortest edge and you get a + "cloud" with no arcs at all, which looks like a plain rectangle and reports + success. +* **MTEXT background mask** round-trips (`bg_fill`, `box_fill_scale`) — proven + through saveas/readfile, because a mask that vanishes on save is worse than + no mask. +* **find/replace** is the one with a real scope problem. TEXT, MTEXT, ATTRIB + and ATTDEF are all readable *and* writable, so all four are in scope. A + DIMENSION's `dxf.text` is `'<>'` — the override placeholder, not the + measurement — so replacing text inside dimensions would either do nothing or + destroy the association with the measured value. The tool reports which types + it searched instead of silently skipping some, because "0 replacements" and + "I did not look there" are different answers. +""" + +from __future__ import annotations + +import pytest + +pytestmark = pytest.mark.asyncio + +SQUARE = [[0.0, 0.0], [100.0, 0.0], [100.0, 60.0], [0.0, 60.0]] + + +# ── wipeout ───────────────────────────────────────────────────────────────── + + +async def test_a_wipeout_is_created_and_reports_its_handle(backend): + result = await backend.entity_create_wipeout(SQUARE) + + assert result["ok"] is True + entity = backend._doc.entitydb.get(result["handle"]) + assert entity.dxftype() == "WIPEOUT" + + +async def test_a_wipeout_survives_save_and_reload(backend, tmp_path): + import ezdxf + + created = await backend.entity_create_wipeout(SQUARE) + path = tmp_path / "wipeout.dxf" + await backend.drawing_save_as(str(path)) + + reloaded = ezdxf.readfile(str(path)).entitydb.get(created["handle"]) + assert reloaded is not None and reloaded.dxftype() == "WIPEOUT" + + +@pytest.mark.parametrize("bad", [[], [[0.0, 0.0]], [[0.0, 0.0], [1.0, 1.0]]]) +async def test_a_wipeout_needs_at_least_three_points(backend, bad): + """Two points enclose no area; a zero-area mask masks nothing.""" + result = await backend.entity_create_wipeout(bad) + assert result["ok"] is False + + +# ── revision cloud ────────────────────────────────────────────────────────── + + +async def test_a_revision_cloud_is_a_bulged_polyline(backend): + """The arcs are the cloud. A revcloud without bulges is a rectangle.""" + result = await backend.entity_create_revcloud(SQUARE, segment_length=10.0) + + assert result["ok"] is True + entity = backend._doc.entitydb.get(result["handle"]) + assert entity.dxftype() == "LWPOLYLINE" + bulges = [point[4] for point in entity.get_points()] + assert all(abs(b) > 0.1 for b in bulges), "every segment must carry an arc" + assert result["segments"] == len(bulges) + + +async def test_the_cloud_is_recognisable_as_one(backend): + from ezdxf.revcloud import is_revcloud + + result = await backend.entity_create_revcloud(SQUARE, segment_length=10.0) + assert is_revcloud(backend._doc.entitydb.get(result["handle"])) + + +async def test_a_segment_longer_than_the_path_is_refused(backend): + """Otherwise the result is a plain rectangle reported as a cloud.""" + result = await backend.entity_create_revcloud(SQUARE, segment_length=10_000.0) + assert result["ok"] is False + assert "segment_length" in result["error"] + + +@pytest.mark.parametrize("bad", [0.0, -5.0]) +async def test_a_non_positive_segment_length_is_refused(backend, bad): + result = await backend.entity_create_revcloud(SQUARE, segment_length=bad) + assert result["ok"] is False + + +# ── MTEXT background mask ─────────────────────────────────────────────────── + + +async def test_a_background_mask_round_trips(backend, tmp_path): + """A mask that vanishes on save is worse than no mask.""" + import ezdxf + + mtext = await backend.entity_create_mtext("SECTION A-A", 10, 10) + + result = await backend.text_set_background(mtext.handle, color=2, scale=1.5) + assert result["ok"] is True + + path = tmp_path / "masked.dxf" + await backend.drawing_save_as(str(path)) + reloaded = ezdxf.readfile(str(path)).entitydb.get(mtext.handle) + assert reloaded.dxf.bg_fill == 1 + assert reloaded.dxf.box_fill_scale == pytest.approx(1.5) + + +async def test_a_background_mask_can_be_turned_off_again(backend): + mtext = await backend.entity_create_mtext("X", 10, 10) + await backend.text_set_background(mtext.handle, color=2) + + result = await backend.text_set_background(mtext.handle, enabled=False) + + assert result["ok"] is True + assert result["enabled"] is False + assert backend._doc.entitydb.get(mtext.handle).dxf.get("bg_fill", 0) == 0 + + +async def test_a_background_mask_on_plain_text_is_refused(backend): + """Only MTEXT carries a background fill; TEXT silently would not.""" + text = await backend.entity_create_text("PLAIN", 0, 0) + result = await backend.text_set_background(text.handle, color=2) + assert result["ok"] is False + assert "MTEXT" in result["error"] + + +@pytest.mark.parametrize("bad", [0.5, 0.0, -1.0]) +async def test_a_mask_scale_below_one_is_refused(backend, bad): + """A box smaller than the text is not a mask, it is a stripe.""" + mtext = await backend.entity_create_mtext("X", 0, 0) + result = await backend.text_set_background(mtext.handle, color=2, scale=bad) + assert result["ok"] is False + + +# ── find / replace ────────────────────────────────────────────────────────── + + +async def _tagged_block(backend): + doc = backend._doc + block = doc.blocks.new("TAGGED") + block.add_attdef("PART", (0, 0), text="OLD") + ref = doc.modelspace().add_blockref("TAGGED", (0, 0)) + ref.add_auto_attribs({"PART": "OLD"}) + return ref + + +async def test_find_replace_covers_text_mtext_and_block_attributes(backend): + """Silently skipping attributes inside blocks is this tool's honesty trap. + + Four hits, not three: the ATTDEF in the block *definition* carries the + default value the next insert will use, so leaving it behind would fix the + drawing and reintroduce the old text on the next block reference. + """ + text = await backend.entity_create_text("OLD value", 0, 0) + mtext = await backend.entity_create_mtext("OLD value", 0, 10) + await _tagged_block(backend) + + result = await backend.text_find_replace("OLD", "NEW") + + assert result["ok"] is True + assert result["replaced"] == 4 + changed = {row["type"] for row in result["entities"]} + assert changed == {"TEXT", "MTEXT", "ATTRIB", "ATTDEF"} + assert backend._doc.entitydb.get(text.handle).dxf.text == "NEW value" + assert backend._doc.entitydb.get(mtext.handle).text == "NEW value" + + +async def test_it_reports_which_types_it_searched(backend): + """ "0 replacements" and "I never looked there" are different answers.""" + result = await backend.text_find_replace("NOTHING", "X") + + assert result["ok"] is True + assert result["replaced"] == 0 + assert set(result["searched_types"]) == {"TEXT", "MTEXT", "ATTRIB", "ATTDEF"} + assert "DIMENSION" in result["note"] + + +async def test_a_dry_run_changes_nothing(backend): + text = await backend.entity_create_text("OLD", 0, 0) + + result = await backend.text_find_replace("OLD", "NEW", dry_run=True) + + assert result["replaced"] == 1 + assert result["dry_run"] is True + assert backend._doc.entitydb.get(text.handle).dxf.text == "OLD" + + +async def test_it_can_be_scoped_to_a_layer(backend): + await backend.entity_create_text("OLD", 0, 0, layer="TEXT") + other = await backend.entity_create_text("OLD", 0, 10, layer="DIM") + + result = await backend.text_find_replace("OLD", "NEW", layer="TEXT") + + assert result["replaced"] == 1 + assert backend._doc.entitydb.get(other.handle).dxf.text == "OLD" + + +async def test_an_empty_search_string_is_refused(backend): + """Replacing "" matches between every character and destroys the text.""" + result = await backend.text_find_replace("", "X") + assert result["ok"] is False + + +async def test_case_sensitivity_is_the_callers_choice(backend): + text = await backend.entity_create_text("Old Value", 0, 0) + + result = await backend.text_find_replace("old", "NEW", match_case=False) + + assert result["replaced"] == 1 + assert backend._doc.entitydb.get(text.handle).dxf.text == "NEW Value" diff --git a/tests/test_backend_selection.py b/tests/test_backend_selection.py new file mode 100644 index 0000000..7c612e3 --- /dev/null +++ b/tests/test_backend_selection.py @@ -0,0 +1,101 @@ +"""T0.8-B — which engine a test talks to must not be a matter of luck. + +``_make_backend`` read ``os.environ["AUTOCAD_MCP_BACKEND"]`` directly while +``config.settings.backend`` — the setting that *looks* like the control — had no +production reader at all. So ``monkeypatch.setattr(config.settings, "backend", +"ezdxf")`` was a no-op that read like a safety guard, and on a developer machine +with AutoCAD open an unguarded test would attach to the operator's live unsaved +drawing and start creating entities in it. That is not hypothetical: it happened +during this release's development. + +The fix makes ``Settings.backend`` a property that reads the environment live, +and points ``_make_backend`` at it. One source of truth, and late binding is +preserved so ``monkeypatch.setenv`` — which four call sites and +``benchmarks/token_suite.py`` already use correctly — keeps working. Assigning to +``config.settings.backend`` now raises instead of quietly protecting nothing. +""" + +from __future__ import annotations + +import sys +import types + +import pytest + +import config +import server + +pytestmark = pytest.mark.asyncio + + +class _ComBackendSentinel: + """Stands in for the real COM backend and screams if anyone builds it.""" + + constructed: list[str] = [] + + def __init__(self, *args, **kwargs): + _ComBackendSentinel.constructed.append("ComBackend() CONSTRUCTED") + raise AssertionError("a test tried to construct the live COM backend") + + +@pytest.fixture +def com_sentinel(monkeypatch): + """Replace backends.com_backend with a module that cannot connect anywhere.""" + _ComBackendSentinel.constructed.clear() + fake = types.ModuleType("backends.com_backend") + fake.ComBackend = _ComBackendSentinel + monkeypatch.setitem(sys.modules, "backends.com_backend", fake) + return _ComBackendSentinel + + +async def test_the_env_var_is_what_actually_selects_the_backend(monkeypatch, com_sentinel): + from backends.ezdxf_backend import EzdxfBackend + + monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf") + backend = await server._make_backend() + + assert isinstance(backend, EzdxfBackend) + assert com_sentinel.constructed == [], "the COM backend must never have been touched" + await backend.disconnect() + + +async def test_settings_backend_reports_what_make_backend_will_use(monkeypatch): + """The setting must not be able to disagree with the selector.""" + for value in ("ezdxf", "com", "auto"): + monkeypatch.setenv("AUTOCAD_MCP_BACKEND", value) + assert config.settings.backend == value + + monkeypatch.setenv("AUTOCAD_MCP_BACKEND", " EzDxF ") + assert config.settings.backend == "ezdxf", "must normalise like the selector does" + + monkeypatch.delenv("AUTOCAD_MCP_BACKEND", raising=False) + assert config.settings.backend == "auto" + + +async def test_assigning_to_settings_backend_fails_loudly(monkeypatch): + """The dead monkeypatch that made two tests *look* guarded must now raise. + + Silently accepting the assignment is the whole bug: the test passes, reads + as safe, and talks to live AutoCAD anyway. + """ + monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf") + with pytest.raises(AttributeError): + config.settings.backend = "com" + assert config.settings.backend == "ezdxf" + + +async def test_make_backend_reads_the_setting_not_the_environment_directly(monkeypatch): + """One source of truth: patching the property must steer the selector. + + If ``_make_backend`` goes back to reading ``os.environ`` itself, this fails — + which is the regression guard, because that direct read is what let the + setting and the behaviour drift apart in the first place. + """ + from backends.ezdxf_backend import EzdxfBackend + + monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "com") + monkeypatch.setattr(type(config.settings), "backend", property(lambda self: "ezdxf")) + + backend = await server._make_backend() + assert isinstance(backend, EzdxfBackend) + await backend.disconnect() diff --git a/tests/test_benchmark_v3.py b/tests/test_benchmark_v3.py new file mode 100644 index 0000000..4c29b87 --- /dev/null +++ b/tests/test_benchmark_v3.py @@ -0,0 +1,169 @@ +"""M9 — the v3 task matrix. + +v2 returned 10/10 for this server. That was not a good sign: every task in it +tested something the server was designed around, so the score could only ever +have been full marks. The five tasks added here were chosen because they can +fail, and three of them did fail while being written — a hatch could not be +measured at all, and the boundary tools disagreed with the measurement tool +about the same shape. + +Two assertions in this file exist purely to keep the matrix honest rather than +to test behaviour: + +* the plan's ``tool_discovery_bilingual`` and ``measurement_massprops`` must + **not** appear, because neither capability shipped; and +* the published competitor reports must not carry the new task ids, because + those servers were never run against them and a zero we invented would read + exactly like a zero we measured. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from backends.base import UnsupportedCapabilityError +from benchmarks.adapters.autocad_mcp_pro import AutoCADMCPProAdapter +from benchmarks.adapters.base import BenchmarkAdapter +from benchmarks.run_competitors import run_tasks +from benchmarks.tasks_v2 import TASKS_V2 +from benchmarks.tasks_v3 import MATRICES, NEW_TASKS_V3, TASKS_V3, task_by_id + +PUBLISHED = Path(__file__).resolve().parents[1] / "benchmarks" / "results" / "published" + + +# ── shape of the matrix ───────────────────────────────────────────────────── + + +def test_v3_extends_v2_without_disturbing_it(): + """A renumbered or reweighted v2 task would silently invalidate the + published v1.4 reports, which are keyed by task id.""" + assert TASKS_V3[: len(TASKS_V2)] == TASKS_V2 + assert len(NEW_TASKS_V3) == 5 + assert len(TASKS_V3) == 15 + + +def test_every_task_id_is_unique(): + ids = [task.task_id for task in TASKS_V3] + assert len(ids) == len(set(ids)) + + +def test_the_two_capabilities_that_did_not_ship_are_not_advertised_as_tasks(): + ids = {task.task_id for task in TASKS_V3} + + assert "tool_discovery_bilingual" not in ids, ( + "the Turkish normalizer was built and removed; this repo is English " + "throughout, so a bilingual task would be a benchmark for nothing" + ) + assert "measurement_massprops" not in ids, ( + "no centroid or moment-of-inertia tool ships — F2 was cut to entity_measure" + ) + assert "tool_discovery" in ids + assert "measure_from_handle" in ids + + +def test_task_by_id_can_still_address_the_v2_matrix(): + assert task_by_id("core_geometry", "v2").category == "creation" + with pytest.raises(KeyError): + task_by_id("hatch_islands", "v2") + + +def test_matrices_registry_exposes_both_sets(): + assert MATRICES["v2"] == TASKS_V2 + assert MATRICES["v3"] == TASKS_V3 + + +# ── the reference adapter actually answers them ───────────────────────────── + + +def test_the_reference_adapter_implements_every_v3_task(): + """An unimplemented task scores `unsupported` and is easy to not notice.""" + missing = [ + task.task_id + for task in TASKS_V3 + if not hasattr(AutoCADMCPProAdapter, f"_task_{task.task_id}") + ] + assert missing == [] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("task", NEW_TASKS_V3, ids=lambda t: t.task_id) +async def test_each_new_task_passes_on_the_headless_backend(task, tmp_path): + report = await run_tasks(AutoCADMCPProAdapter(), [task], tmp_path, timeout=120.0) + + result = report["results"][0] + assert result["status"] == "pass", result["message"] + assert result["metrics"], "a task that proves nothing reports no evidence" + + +@pytest.mark.asyncio +async def test_the_full_v3_matrix_runs_green(tmp_path): + report = await run_tasks(AutoCADMCPProAdapter(), TASKS_V3, tmp_path, timeout=180.0) + + assert report["summary"]["attempted"] == 15 + assert report["summary"]["passed"] == 15, [ + (item["task_id"], item["status"], item["message"]) for item in report["results"] + ] + assert report["matrix"] == "v3" + + +# ── a capability refusal is not a failure ─────────────────────────────────── + + +class RefusingAdapter(BenchmarkAdapter): + name = "refusing" + + async def setup(self, artifact_dir): + return None + + async def run_task(self, task): + raise UnsupportedCapabilityError("chspace", "no change-space member on this engine") + + async def cleanup(self): + return None + + +@pytest.mark.asyncio +async def test_a_capability_refusal_is_reported_unsupported_not_failed(tmp_path): + """`fail` and `unsupported` are different claims about a competitor. + + "Your server got this wrong" and "your engine cannot reach this" are not + the same finding, and the runner used to collapse the second into the + first because a refusal is still an exception. + """ + report = await run_tasks(RefusingAdapter(), [NEW_TASKS_V3[0]], tmp_path) + + result = report["results"][0] + assert result["status"] == "unsupported" + assert "chspace" in result["message"] + assert report["summary"]["unsupported"] == 1 + assert report["summary"]["score"] == 0.0 + + +# ── nothing was invented for the competitors ──────────────────────────────── + + +@pytest.mark.parametrize( + "filename", ["beiming183-autocad-mcp.json", "puran-water-autocad-mcp.json"] +) +def test_competitor_reports_carry_no_result_for_the_new_tasks(filename): + report = json.loads((PUBLISHED / filename).read_text(encoding="utf-8")) + scored = {item["task_id"] for item in report["results"]} + new_ids = {task.task_id for task in NEW_TASKS_V3} + + assert scored & new_ids == set(), ( + "these servers were pinned and run against the v2 matrix at v1.4; a " + "result here would be fabricated" + ) + assert report.get("matrix", "v2") == "v2" + + +def test_the_published_reference_report_covers_the_whole_v3_matrix(): + report = json.loads((PUBLISHED / "autocad-mcp-pro.json").read_text(encoding="utf-8")) + scored = {item["task_id"] for item in report["results"]} + + assert scored == {task.task_id for task in TASKS_V3} + assert report["matrix"] == "v3" + assert report["summary"]["score"] == 100.0 diff --git a/tests/test_block_from_entities.py b/tests/test_block_from_entities.py new file mode 100644 index 0000000..079539c --- /dev/null +++ b/tests/test_block_from_entities.py @@ -0,0 +1,168 @@ +"""M5 — `block_create_from_entities` on both engines. + +The live backend refused this outright ("not supported in COM backend. Use +system_run_command with _BLOCK instead"), which pushed users at the free-text +escape hatch for something ActiveX does directly: `Blocks.Add` makes the +definition and `Document.CopyObjects` moves entities into it. Meanwhile the +tool's own docstring said the opposite — "works by using AutoCAD's BLOCK command +(COM backend only)" — so the one backend that could not do it was named as the +one that could. + +Both engines also used to swallow handles they could not resolve: pass five +handles with two typos and you got `entity_count: 3` and no mention of the two. +The count is now accompanied by what was skipped. + +The COM path is unit-tested against a fake ActiveX object. This machine has a +live AutoCAD and connecting to it during development is forbidden, so that path +ships verified at this level only — the CHANGELOG says so rather than implying +hardware coverage. +""" + +from __future__ import annotations + +import types + +import pytest + +from backends.ezdxf_backend import EzdxfBackend + +pytestmark = pytest.mark.asyncio + + +# ── headless ──────────────────────────────────────────────────────────────── + + +async def test_ezdxf_builds_the_definition_from_real_entities(backend): + line = await backend.entity_create_line(0, 0, 10, 0) + circle = await backend.entity_create_circle(5, 5, 2) + + result = await backend.block_create_from_entities("BRACKET", [line.handle, circle.handle]) + + assert result["ok"] is True + assert result["entity_count"] == 2 + assert result["skipped"] == [] + block = backend._doc.blocks.get("BRACKET") + assert sorted(e.dxftype() for e in block) == ["CIRCLE", "LINE"] + + +async def test_ezdxf_reports_handles_it_could_not_resolve(backend): + line = await backend.entity_create_line(0, 0, 10, 0) + + result = await backend.block_create_from_entities("PARTIAL", [line.handle, "NOSUCH", "ALSONO"]) + + assert result["entity_count"] == 1 + assert result["skipped"] == ["NOSUCH", "ALSONO"], ( + "a count with no mention of what was dropped is the same shape of lie " + "as the audit reporting no fixes" + ) + + +async def test_ezdxf_refuses_a_block_with_nothing_in_it(backend): + with pytest.raises(RuntimeError, match="NOSUCH"): + await backend.block_create_from_entities("EMPTY", ["NOSUCH"]) + assert "EMPTY" not in backend._doc.blocks, "a failed call must leave no stub behind" + + +async def test_ezdxf_honours_the_base_point(backend): + line = await backend.entity_create_line(0, 0, 10, 0) + await backend.block_create_from_entities("BASED", [line.handle], base_x=3.0, base_y=4.0) + + block = backend._doc.blocks.get("BASED") + assert tuple(block.block.dxf.base_point)[:2] == (3.0, 4.0) + + +# ── live backend, against a fake ActiveX surface ──────────────────────────── + + +class _FakeBlock: + def __init__(self, name): + self.Name = name + + +class _FakeDocument: + """The three ActiveX members the implementation is allowed to touch.""" + + def __init__(self, handles): + self._handles = handles + self.Blocks = types.SimpleNamespace(Add=self._add_block) + self.copied = None + self.added = None + + def _add_block(self, base_point, name): + self.added = (tuple(base_point.value), name) + return _FakeBlock(name) + + def HandleToObject(self, handle): + if handle not in self._handles: + raise RuntimeError(f"no object with handle {handle}") + return types.SimpleNamespace(Handle=handle, ObjectName="AcDbLine") + + def CopyObjects(self, objects, owner, *rest): + self.copied = ([o.Handle for o in objects.value], owner.Name) + + +@pytest.fixture +def com_backend(monkeypatch): + """A ComBackend whose document is a fake — no COM connection is made.""" + pytest.importorskip("win32com.client", reason="pywin32 not installed") + from backends import com_backend as module + + document = _FakeDocument({"2F", "30"}) + monkeypatch.setattr(module, "_acad_doc", lambda: document) + monkeypatch.setattr(module, "_regen", lambda: None) + backend = module.ComBackend() + + async def _run_inline(func, *args, **kwargs): + return func(*args, **kwargs) + + monkeypatch.setattr(backend, "_run", _run_inline) + return backend, document + + +async def test_com_creates_the_definition_and_copies_into_it(com_backend): + backend, document = com_backend + + result = await backend.block_create_from_entities("BRACKET", ["2F", "30"], 3.0, 4.0) + + assert result["ok"] is True + assert result["entity_count"] == 2 + assert result["skipped"] == [] + assert document.added == ((3.0, 4.0, 0.0), "BRACKET"), "base point must reach Blocks.Add" + assert document.copied == (["2F", "30"], "BRACKET") + + +async def test_com_reports_handles_it_could_not_resolve(com_backend): + backend, document = com_backend + + result = await backend.block_create_from_entities("PARTIAL", ["2F", "NOSUCH"]) + + assert result["entity_count"] == 1 + assert result["skipped"] == ["NOSUCH"] + assert document.copied == (["2F"], "PARTIAL") + + +async def test_com_refuses_a_block_with_nothing_in_it(com_backend): + backend, document = com_backend + + with pytest.raises(RuntimeError, match="NOSUCH"): + await backend.block_create_from_entities("EMPTY", ["NOSUCH"]) + + assert document.copied is None, "nothing may be copied when nothing resolved" + + +async def test_both_backends_return_the_same_key_set(backend, com_backend): + com, _ = com_backend + line = await backend.entity_create_line(0, 0, 10, 0) + + headless = await backend.block_create_from_entities("A", [line.handle]) + live = await com.block_create_from_entities("A", ["2F"]) + + assert headless.keys() == live.keys() + + +async def test_ezdxf_still_declares_no_capability_gap_here(): + """Both engines can do this now, so it must not look like a boundary.""" + features = EzdxfBackend().capabilities().features + assert "block_definition" not in features, ( + "a capability key would imply one engine cannot; both can" + ) diff --git a/tests/test_boundary_trace.py b/tests/test_boundary_trace.py new file mode 100644 index 0000000..3303404 --- /dev/null +++ b/tests/test_boundary_trace.py @@ -0,0 +1,447 @@ +"""F3 — boundary tracing: `boundary_trace` (BOUNDARY/BPOLY) and `boundary_from_entities`. + +Two tools that turn loose edges into one closed LWPOLYLINE. Every assertion here +exists because a measured ezdxf behaviour makes the *plausible* implementation +lie: + +* **A boundary with no boundary is the silent-success case.** A seed dropped in + open space, or inside a "region" whose edges leave a gap, has no enclosing + loop. Returning an empty or open polyline with ``ok: True`` is exactly the + failure mode this repo spent a milestone deleting, so both are refusals — and + the gap case must name the dangling endpoint, because "no loop here" and + "your top edge is 5 mm short" are different problems with different fixes. + +* **`edgeminer.find_all_loops` is O(n!)** — measured 63.9 s at 40 edges and a + TimeoutError at 60. It must never be called; `find_loop_by_edge` is 0.0057 s + at 60 edges. Nothing below asserts on runtime, but the nested-region test + pins the *behaviour* that makes the cheap call correct: the loop returned is + the nearest enclosing one, not the outermost. + +* **`edgesmith.lwpolyline_from_chain` does not close what it chains.** Measured + on four LINEs of a square: five points, the fifth a duplicate of the first, + and ``closed = False``. That polyline draws like a square and behaves like an + open one — HATCH association, area queries and `entity_offset` all see an open + path. So the stored entity is checked for the closed *flag* and for the + absence of the duplicate vertex, not just for looking right. + +* **`edgesmith.loop_area` ignores bulges.** Measured on two semicircular ARCs + chained into a circle: it returns **0.0** where the true area is 314.16. Use + it and every arc-bounded boundary — every fillet, every slot, every keyway + pocket — reports an area of roughly nothing while claiming success. + +* **The reported area comes from the polyline, not from a copy of it.** The + first fix for the bulge problem flattened the chain at a fixed 0.01 sag, + which left `boundary_trace` saying 139.2177 and `entity_measure` saying + 139.2699 about the same figure, with nothing in either payload marking one + as the approximation. The stored vertices carry exact bulges, so both tools + now read those and round the same way. + +Coordinates in and out are WCS, as everywhere else. ``area`` is unsigned: +the same square wound clockwise or counter-clockwise measures 1600.0 either +way, and a caller asking "how big is this pocket" never wants a negative +answer. +""" + +from __future__ import annotations + +import math +import re + +import pytest + +pytestmark = pytest.mark.asyncio + +# A 40 x 40 square: area 1600. +SQUARE = [(0.0, 0.0), (40.0, 0.0), (40.0, 40.0), (0.0, 40.0)] + +_NUMBER = re.compile(r"-?\d+(?:\.\d+)?") + + +def _numbers(text: str) -> list[float]: + """Every number an error message mentions, however it was formatted.""" + return [float(match) for match in _NUMBER.findall(text)] + + +def _points(backend, handle) -> list[tuple[float, float]]: + entity = backend._doc.entitydb.get(handle) + return [(round(float(p[0]), 6), round(float(p[1]), 6)) for p in entity.get_points()] + + +def _bulges(backend, handle) -> list[float]: + entity = backend._doc.entitydb.get(handle) + return [float(p[4]) for p in entity.get_points()] + + +async def _square_of_lines(backend, points=SQUARE, layer=None): + """Four LINEs, corner to corner, returned as handles.""" + handles = [] + for index in range(len(points)): + x1, y1 = points[index] + x2, y2 = points[(index + 1) % len(points)] + line = await backend.entity_create_line(x1, y1, x2, y2, layer=layer) + handles.append(line.handle) + return handles + + +# ── boundary_trace: the happy path, measured ──────────────────────────────── + + +async def test_a_seed_inside_a_square_traces_its_four_edges(backend): + await _square_of_lines(backend) + + result = await backend.boundary_trace(20.0, 20.0) + + assert result["ok"] is True + assert result["closed"] is True + assert result["vertices"] == 4, "four edges, four corners — not five" + assert result["area"] == pytest.approx(1600.0) + + +async def test_the_traced_boundary_is_a_real_lwpolyline(backend): + await _square_of_lines(backend) + + result = await backend.boundary_trace(20.0, 20.0) + + entity = backend._doc.entitydb.get(result["handle"]) + assert entity.dxftype() == "LWPOLYLINE" + assert set(_points(backend, result["handle"])) == set(SQUARE) + + +async def test_the_boundary_is_closed_by_flag_not_by_a_duplicate_vertex(backend): + """`lwpolyline_from_chain` measured: 5 points, last == first, closed False. + + That polyline looks shut and is not. Hatching, offsetting and area queries + all treat it as an open path. + """ + await _square_of_lines(backend) + + result = await backend.boundary_trace(20.0, 20.0) + + entity = backend._doc.entitydb.get(result["handle"]) + assert entity.closed is True + stored = _points(backend, result["handle"]) + assert len(stored) == 4 + assert stored[0] != stored[-1] + + +async def test_the_boundary_survives_save_and_reload(backend, tmp_path): + import ezdxf + + await _square_of_lines(backend) + result = await backend.boundary_trace(20.0, 20.0) + + path = tmp_path / "boundary.dxf" + await backend.drawing_save_as(str(path)) + + reloaded = ezdxf.readfile(str(path)).entitydb.get(result["handle"]) + assert reloaded.dxftype() == "LWPOLYLINE" + assert reloaded.closed is True + assert len(list(reloaded.get_points())) == 4 + + +# ── boundary_trace: the refusals ──────────────────────────────────────────── + + +async def test_a_seed_outside_every_closed_region_is_refused(backend): + """A boundary with no boundary. An empty polyline reported as success is + the exact shape of lie this repo removes tools for.""" + await _square_of_lines(backend) + + result = await backend.boundary_trace(500.0, 500.0) + + assert result["ok"] is False + assert result.get("handle") is None + assert result["error"] + + +async def test_a_seed_in_open_space_with_no_edges_at_all_is_refused(backend): + result = await backend.boundary_trace(0.0, 0.0) + + assert result["ok"] is False + assert result["error"] + + +async def test_a_region_with_a_gap_is_refused_and_the_error_names_the_gap(backend): + """The top edge stops 5 mm short at (35, 40). + + "No loop found" sends the user hunting the whole drawing; naming the + dangling endpoint sends them to the one line that is short. + """ + await backend.entity_create_line(0, 0, 40, 0) + await backend.entity_create_line(40, 0, 40, 40) + await backend.entity_create_line(40, 40, 35, 40) # 5 mm short of the corner + await backend.entity_create_line(0, 40, 0, 0) + + result = await backend.boundary_trace(20.0, 20.0) + + assert result["ok"] is False + assert "gap" in result["error"].lower() + assert 35.0 in _numbers(result["error"]), ( + "the error must carry the dangling endpoint, not just the word 'gap'" + ) + + +async def test_a_gap_inside_the_tolerance_is_closed(backend): + """Real drawings miss corners by microns; tolerance is what it is for.""" + await backend.entity_create_line(0, 0, 40, 0) + await backend.entity_create_line(40, 0, 40, 40) + await backend.entity_create_line(40, 40, 0.001, 40) + await backend.entity_create_line(0, 40, 0, 0) + + result = await backend.boundary_trace(20.0, 20.0, tolerance=0.01) + + assert result["ok"] is True + assert result["closed"] is True + assert result["area"] == pytest.approx(1600.0, rel=1e-3) + + +async def test_the_same_gap_outside_the_tolerance_is_still_refused(backend): + """Otherwise `tolerance` would be decoration.""" + await backend.entity_create_line(0, 0, 40, 0) + await backend.entity_create_line(40, 0, 40, 40) + await backend.entity_create_line(40, 40, 0.001, 40) + await backend.entity_create_line(0, 40, 0, 0) + + result = await backend.boundary_trace(20.0, 20.0, tolerance=1e-9) + + assert result["ok"] is False + + +# ── boundary_trace: which loop, and what it is made of ────────────────────── + + +async def test_nested_regions_return_the_inner_loop(backend): + """AutoCAD's BOUNDARY traces the nearest enclosing loop. + + Returning the outer square here would hatch straight over the island — + and it is also the answer a whole-drawing loop search hands you first. + """ + await _square_of_lines(backend) # 40 x 40, area 1600 + inner = [(10.0, 10.0), (30.0, 10.0), (30.0, 30.0), (10.0, 30.0)] + await _square_of_lines(backend, inner) # 20 x 20, area 400 + + result = await backend.boundary_trace(20.0, 20.0) + + assert result["ok"] is True + assert result["area"] == pytest.approx(400.0) + assert set(_points(backend, result["handle"])) == set(inner) + + +async def test_a_seed_between_the_two_loops_returns_the_outer_ring(backend): + """Same drawing, different seed: the loop that encloses *this* point.""" + await _square_of_lines(backend) + await _square_of_lines(backend, [(10.0, 10.0), (30.0, 10.0), (30.0, 30.0), (10.0, 30.0)]) + + result = await backend.boundary_trace(5.0, 5.0) + + assert result["ok"] is True + assert result["area"] == pytest.approx(1600.0) + + +async def test_source_handles_report_which_entities_formed_the_boundary(backend): + """A traced boundary the caller cannot attribute is a polyline from nowhere.""" + handles = await _square_of_lines(backend) + + result = await backend.boundary_trace(20.0, 20.0) + + assert set(result["source_handles"]) == set(handles) + assert result["handle"] not in result["source_handles"] + + +async def test_the_boundary_is_independent_of_its_sources(backend): + """BOUNDARY makes a new entity, it does not alias the edges. Deleting a + source line must not empty the polyline that was traced from it.""" + handles = await _square_of_lines(backend) + result = await backend.boundary_trace(20.0, 20.0) + + await backend.entity_delete(handles[0]) + + entity = backend._doc.entitydb.get(result["handle"]) + assert entity is not None + assert entity.closed is True + assert set(_points(backend, result["handle"])) == set(SQUARE) + + +async def test_the_layer_filter_restricts_the_candidate_edges(backend): + """A construction line through the square halves it. + + Unfiltered, the nearest enclosing loop around (10, 20) is the left half + (area 800). Scoped to GEOMETRY, the divider is not an edge and the loop is + the whole square (1600). If `layer` were ignored both answers would be 800. + """ + await _square_of_lines(backend, layer="GEOMETRY") + await backend.entity_create_line(20, 0, 20, 40, layer="CONSTRUCTION") + + unfiltered = await backend.boundary_trace(10.0, 20.0) + filtered = await backend.boundary_trace(10.0, 20.0, layer="GEOMETRY") + + assert unfiltered["area"] == pytest.approx(800.0) + assert filtered["area"] == pytest.approx(1600.0) + + +# ── boundary_from_entities: the happy path ────────────────────────────────── + + +async def test_named_entities_chain_into_one_closed_polyline(backend): + """The handles arrive in drawing order, not chain order; chaining is the + tool's whole job.""" + handles = await _square_of_lines(backend) + shuffled = [handles[2], handles[0], handles[3], handles[1]] + + result = await backend.boundary_from_entities(shuffled) + + assert result["ok"] is True + assert result["closed"] is True + assert result["vertices"] == 4 + assert result["area"] == pytest.approx(1600.0) + assert set(_points(backend, result["handle"])) == set(SQUARE) + + +async def test_the_chained_polyline_is_closed_without_a_duplicate_vertex(backend): + handles = await _square_of_lines(backend) + + result = await backend.boundary_from_entities(handles) + + entity = backend._doc.entitydb.get(result["handle"]) + assert entity.closed is True + stored = _points(backend, result["handle"]) + assert len(stored) == 4 and stored[0] != stored[-1] + + +async def test_a_chained_polyline_round_trips(backend, tmp_path): + import ezdxf + + handles = await _square_of_lines(backend) + result = await backend.boundary_from_entities(handles) + + path = tmp_path / "chained.dxf" + await backend.drawing_save_as(str(path)) + + reloaded = ezdxf.readfile(str(path)).entitydb.get(result["handle"]) + assert reloaded.closed is True + assert len(list(reloaded.get_points())) == 4 + + +async def test_two_arcs_close_into_a_circle_whose_area_counts_the_bulges(backend): + """`loop_area` returns 0.0 for this exact chain (measured). + + Two semicircles enclose 314.16 mm^2; their two *vertices* enclose nothing. + An area taken from the vertex polygon reports ~0 for every arc-bounded + pocket in the drawing while still saying ok. + """ + upper = await backend.entity_create_arc(0, 0, 10, 0, 180) + lower = await backend.entity_create_arc(0, 0, 10, 180, 360) + + result = await backend.boundary_from_entities([upper.handle, lower.handle]) + + assert result["ok"] is True + assert result["closed"] is True + assert result["area"] == pytest.approx(math.pi * 100.0, rel=1e-3) + assert all(abs(b) > 0.1 for b in _bulges(backend, result["handle"])), ( + "a circle stored without bulges is a straight line between two points" + ) + + +async def test_the_reported_area_matches_measuring_the_polyline_it_just_made(backend): + """Two tools, one shape, one number. + + `boundary_*` used to report a curve flattened at a fixed 0.01 sag while + `entity_measure` read the stored bulges analytically, so the same figure + came back as 139.2177 from one tool and 139.2699 from the other with + nothing in either payload to say which was the approximation. The polyline + carries exact bulges either way — there was never a reason to answer from + a coarser copy of it. + """ + # 10 x 10 square whose top edge is a semicircle: 100 + pi*25/2. + await backend.entity_create_line(0, 0, 10, 0) + await backend.entity_create_line(10, 0, 10, 10) + await backend.entity_create_line(0, 10, 0, 0) + await backend.entity_create_arc(5, 10, 5, 0, 180) + + traced = await backend.boundary_trace(5.0, 5.0) + measured = await backend.entity_measure(traced["handle"]) + + true_area = 100.0 + math.pi * 25.0 / 2.0 + # rel=1e-8, not tighter: both tools round to six decimals on the way out. + assert measured["area"] == pytest.approx(true_area, rel=1e-8) + assert traced["area"] == pytest.approx(measured["area"], rel=1e-9), ( + "the boundary tool and the measure tool must not disagree about the " + "shape the boundary tool just drew" + ) + + +async def test_boundary_from_entities_reports_the_same_exact_area(backend): + upper = await backend.entity_create_arc(0, 0, 10, 0, 180) + lower = await backend.entity_create_arc(0, 0, 10, 180, 360) + + result = await backend.boundary_from_entities([upper.handle, lower.handle]) + measured = await backend.entity_measure(result["handle"]) + + assert result["area"] == pytest.approx(math.pi * 100.0, rel=1e-8) + assert result["area"] == pytest.approx(measured["area"], rel=1e-12), "same six decimals" + + +# ── boundary_from_entities: the refusals ──────────────────────────────────── + + +async def test_an_open_chain_is_refused_and_names_the_dangling_endpoint(backend): + """Three sides of a rectangle: the ends at (0, 0) and (7, 25) never meet.""" + first = await backend.entity_create_line(0, 0, 40, 0) + second = await backend.entity_create_line(40, 0, 40, 25) + third = await backend.entity_create_line(40, 25, 7, 25) + + result = await backend.boundary_from_entities([first.handle, second.handle, third.handle]) + + assert result["ok"] is False + reported = _numbers(result["error"]) + assert 7.0 in reported and 25.0 in reported, ( + "'not closed' without the loose end leaves the caller measuring by hand" + ) + + +async def test_a_chain_broken_in_the_middle_is_refused(backend): + """Four segments, all four endpoints paired — except two of them are 6 mm + apart. The count is right and the chain is not.""" + first = await backend.entity_create_line(0, 0, 40, 0) + second = await backend.entity_create_line(40, 0, 40, 40) + third = await backend.entity_create_line(34, 40, 0, 40) # starts 6 mm inboard + fourth = await backend.entity_create_line(0, 40, 0, 0) + + result = await backend.boundary_from_entities( + [first.handle, second.handle, third.handle, fourth.handle] + ) + + assert result["ok"] is False + assert 34.0 in _numbers(result["error"]) + + +@pytest.mark.parametrize("count", [0, 1]) +async def test_fewer_than_two_handles_is_refused(backend, count): + """One segment cannot enclose anything; zero is a no-op reported as work.""" + handles = await _square_of_lines(backend) + + result = await backend.boundary_from_entities(handles[:count]) + + assert result["ok"] is False + + +async def test_a_non_linear_entity_is_refused_by_type(backend): + """TEXT has no endpoints. Skipping it quietly would chain three of four + sides and then blame the geometry for not closing.""" + handles = await _square_of_lines(backend) + text = await backend.entity_create_text("NOTE", 20, 20) + + result = await backend.boundary_from_entities(handles + [text.handle]) + + assert result["ok"] is False + assert "TEXT" in result["error"] + assert text.handle in result["error"] + + +async def test_an_unresolvable_handle_is_refused_by_name(backend): + handles = await _square_of_lines(backend) + + result = await backend.boundary_from_entities(handles + ["NOSUCH"]) + + assert result["ok"] is False + assert "NOSUCH" in result["error"] diff --git a/tests/test_cad_batch.py b/tests/test_cad_batch.py new file mode 100644 index 0000000..815ec61 --- /dev/null +++ b/tests/test_cad_batch.py @@ -0,0 +1,935 @@ +"""``cad_batch`` — the ordered multi-tool executor. + +One round trip instead of N, with a later step able to reference an earlier +one's handle so the model never has to echo it back. + +Where the win actually comes from, measured on a 17-step mounting-plate task +(ISO layers, 4 outline lines, 2 fillets, 2 snapped midpoints, 5 circles, 2 +dimensions) against the real in-memory server: + + * **Payload alone: 1.28x** — and *0.74x* with ``verbose=True``. The batched + request is the LARGER of the two (1,579 vs 1,357 chars: every step carries a + ``{tool, args, bind}`` wrapper the sequential arm gets free from the protocol + envelope). Batching does not win by making payloads smaller. + * **Agent loop: 9.07x** — 340,447 -> 37,548 input tokens. This is the real + mechanism, and it is *turn elimination*: 18 billed turns become 2, and each + turn re-sends the whole conversation plus the 18,306-token tool prefix. + +So the honest framing is that batching multiplies with the discovery lane +rather than beating it — search alone is 18.29x on the same task, and the two +together are 106.99x. Nothing here should claim batching is the biggest lever. + +What these tests actually guard is the *dangerous* half of the idea, because +the happy path is the easy half: + + * **Privilege.** A single ``cad_batch`` grant must not reach the raw + command/LISP escape hatches. The deny set is derived from the live + ``@cad_tool(cost="escape")`` cards rather than hand-listed, so a future + escape hatch is denied the day it is registered, and a denied step refuses + the *whole* batch before anything executes — otherwise ``on_error="continue"`` + would turn the denylist into a no-op. + * **Error typing.** The re-entrant call path reformats every non-FastMCP + exception into ``ToolError(f"Error calling tool {name!r}: {e}")``. The + classifier must recover the real cause from the ``__cause__`` chain and + never from the message text. + * **Atomicity honesty.** ezdxf rolls back by restoring a full DXF snapshot; + COM ends an undo mark and fires ``_UNDO B`` at AutoCAD without ever + confirming it landed. The payload must say which one it got, and must not + claim a rollback it did not verify. + * **dry_run.** Validates against each tool's real JSON Schema without + executing a single step. +""" + +from __future__ import annotations + +import json + +import pytest +import pytest_asyncio +from fastmcp import Client +from fastmcp.exceptions import ToolError + +import server + +pytestmark = pytest.mark.asyncio + + +@pytest_asyncio.fixture +async def client(monkeypatch): + """In-memory client on a fresh headless document. + + The env var is what ``server._make_backend`` reads; setting + ``config.settings.backend`` would leave a Windows box with AutoCAD open + talking to the operator's live drawing. + """ + monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf") + async with Client(server.mcp) as connected: + status = (await connected.call_tool("system_status", {})).structured_content or {} + assert status.get("backend") == "ezdxf", "these tests must never touch live AutoCAD" + await connected.call_tool("drawing_new", {}) + yield connected + + +async def _entity_count(client) -> int: + info = (await client.call_tool("drawing_info", {})).structured_content or {} + return int(info["entity_count"]) + + +async def _batch(client, steps, **kwargs): + payload = {"steps": steps, **kwargs} + return (await client.call_tool("cad_batch", payload)).structured_content + + +def dumps(obj) -> str: + """Compact JSON — how the payload is actually charged for.""" + return json.dumps(obj, separators=(",", ":"), ensure_ascii=False) + + +LINE = "entity_create_line" + + +def _line(x1=0.0, y1=0.0, x2=10.0, y2=0.0, **extra): + return {"tool": LINE, "args": {"x1": x1, "y1": y1, "x2": x2, "y2": y2}, **extra} + + +# --------------------------------------------------------------------------- +# Registration and metadata +# --------------------------------------------------------------------------- + + +async def test_cad_batch_is_registered_with_a_discovery_card(): + tools = {t.name: t for t in await server._registered_tools()} + assert "cad_batch" in tools, "cad_batch must be a registered tool" + card = (tools["cad_batch"].meta or {})["cad"] + assert card["cost"] == "destructive", "it can run any mutating tool, so it costs the worst case" + assert card["summary"] and "\n" not in card["summary"] + assert card["synonyms"], "the alias corpus must cover it" + + +async def test_cad_batch_is_filed_under_the_batch_group(): + groups = await server._tool_groups() + assert "cad_batch" in groups["batch"] + assert {"entity_batch_create", "entity_batch_modify"} <= set(groups["batch"]), ( + "the pre-existing batch tools stay: cad_batch supersedes them in scope, not in the registry" + ) + + +# --------------------------------------------------------------------------- +# The happy path +# --------------------------------------------------------------------------- + + +async def test_a_three_step_batch_runs_in_one_round_trip(client): + result = await _batch( + client, + [ + _line(), + _line(y1=10.0, y2=10.0), + {"tool": "entity_create_circle", "args": {"cx": 5, "cy": 5, "radius": 2}}, + ], + ) + assert result["ok"] is True + assert (result["steps"], result["executed"], result["succeeded"], result["failed"]) == ( + 3, + 3, + 3, + 0, + ) + assert [row["status"] for row in result["results"]] == ["ok", "ok", "ok"] + assert await _entity_count(client) == 3 + + +async def test_a_successful_step_reports_only_its_handle_by_default(client): + result = await _batch(client, [_line()]) + payload = result["results"][0]["result"] + assert set(payload) == {"handle"}, ( + "an EntityInfo is fully recoverable via entity_get(handle); re-emitting it " + "in every batch row is exactly the cost cad_batch exists to remove" + ) + assert payload["handle"] + + +async def test_verbose_restores_the_full_step_result(client): + result = await _batch(client, [_line()], verbose=True) + payload = result["results"][0]["result"] + assert payload["handle"] and payload["type"] == "LINE" + assert "properties" in payload + + +async def test_a_result_without_a_handle_is_never_trimmed(client): + """Compaction only drops what ``entity_get(handle)`` can give back.""" + result = await _batch(client, [{"tool": "drawing_info", "args": {}}]) + payload = result["results"][0]["result"] + assert payload["entity_count"] == 0 + assert payload["name"] + + +async def test_steps_run_in_the_order_given(client): + """Reads interleaved with writes see the document as of their own position.""" + result = await _batch( + client, + [ + _line(), + {"tool": "drawing_info", "args": {}}, + {"tool": "entity_create_circle", "args": {"cx": 5, "cy": 5, "radius": 2}}, + {"tool": "drawing_info", "args": {}}, + ], + ) + assert result["ok"] is True + assert result["results"][1]["result"]["entity_count"] == 1 + assert result["results"][3]["result"]["entity_count"] == 2 + + +# --------------------------------------------------------------------------- +# Binding — the half that removes the handle echo +# --------------------------------------------------------------------------- + + +async def test_a_bound_handle_feeds_a_later_step(client): + result = await _batch( + client, + [ + _line(bind="edge"), + {"tool": "entity_move", "args": {"handle": "$edge", "dx": 5.0, "dy": 5.0}}, + ], + ) + assert result["ok"] is True + assert result["bindings"] == ["edge"] + handle = result["results"][0]["result"]["handle"] + got = (await client.call_tool("entity_get", {"handle": handle})).structured_content + assert got["properties"]["start"][0] == pytest.approx(5.0) + + +async def test_a_dotted_reference_reaches_into_a_step_result(client): + """``point_from_snap`` returns {x, y}; without ``$p.x`` that is a round trip.""" + result = await _batch( + client, + [ + _line(x1=0.0, y1=0.0, x2=10.0, y2=0.0, bind="edge"), + { + "tool": "point_from_snap", + "args": {"handle": "$edge", "snap": "mid"}, + "bind": "mid", + }, + { + "tool": "entity_create_circle", + "args": {"cx": "$mid.x", "cy": "$mid.y", "radius": 1.0}, + }, + ], + ) + assert result["ok"] is True, result["results"] + handle = result["results"][2]["result"]["handle"] + circle = (await client.call_tool("entity_get", {"handle": handle})).structured_content + assert circle["properties"]["center"][0] == pytest.approx(5.0) + + +async def test_a_list_index_is_a_valid_reference_segment(): + resolved = server._resolve_batch_ref("$box.handles.1", {"box": {"handles": ["A", "B", "C"]}}) + assert resolved == "B" + + +async def test_a_bare_reference_resolves_to_the_handle(): + resolved = server._resolve_batch_ref("$edge", {"edge": {"handle": "2F1", "type": "LINE"}}) + assert resolved == "2F1" + + +async def test_a_bare_reference_to_a_handleless_result_is_a_typed_error(): + with pytest.raises(server.BatchReferenceError) as excinfo: + server._resolve_batch_ref("$info", {"info": {"entity_count": 3}}) + assert "handle" in str(excinfo.value) + + +async def test_a_doubled_dollar_escapes_a_literal_dollar(client): + """Text really does start with ``$`` sometimes; ``$$`` is the way out.""" + result = await _batch( + client, + [{"tool": "entity_create_text", "args": {"text": "$$4.50", "x": 0, "y": 0, "height": 2.5}}], + ) + handle = result["results"][0]["result"]["handle"] + got = (await client.call_tool("entity_get", {"handle": handle})).structured_content + assert got["properties"]["text"] == "$4.50" + + +async def test_only_a_whole_string_is_a_reference(): + """Partial interpolation is deliberately unsupported — it is ambiguous.""" + assert server._substitute_batch_refs("cost is $edge each", {"edge": {"handle": "1"}}) == ( + "cost is $edge each" + ) + + +# --------------------------------------------------------------------------- +# dry_run — the real JSON Schema, nothing executed +# --------------------------------------------------------------------------- + + +async def test_dry_run_validates_without_executing(client): + result = await _batch(client, [_line(), _line(y1=5.0, y2=5.0)], dry_run=True) + assert result["dry_run"] is True + assert result["ok"] is True + assert result["executed"] == 0 + assert [row["status"] for row in result["results"]] == ["valid", "valid"] + assert await _entity_count(client) == 0, "a dry run must not draw anything" + + +async def test_dry_run_rejects_a_missing_required_argument(client): + result = await _batch(client, [{"tool": LINE, "args": {"x1": 0, "y1": 0}}], dry_run=True) + assert result["ok"] is False + row = result["results"][0] + assert row["status"] == "invalid" + assert row["error"]["kind"] == "invalid_args" + assert "x2" in row["error"]["message"] + + +async def test_dry_run_rejects_an_argument_of_the_wrong_type(client): + result = await _batch( + client, + [{"tool": LINE, "args": {"x1": "over there", "y1": 0, "x2": 1, "y2": 1}}], + dry_run=True, + ) + assert result["results"][0]["error"]["kind"] == "invalid_args" + + +async def test_dry_run_rejects_an_unknown_argument(client): + result = await _batch( + client, + [{"tool": LINE, "args": {"x1": 0, "y1": 0, "x2": 1, "y2": 1, "colour": 3}}], + dry_run=True, + ) + assert result["results"][0]["error"]["kind"] == "invalid_args" + + +async def test_dry_run_names_an_unknown_tool(client): + result = await _batch(client, [{"tool": "entity_create_dragon", "args": {}}], dry_run=True) + row = result["results"][0] + assert row["error"]["kind"] == "unknown_tool" + assert "entity_create_dragon" in row["error"]["message"] + + +async def test_dry_run_catches_a_forward_reference(client): + """A step may only reference something an *earlier* step bound.""" + result = await _batch( + client, + [ + {"tool": "entity_move", "args": {"handle": "$edge", "dx": 1.0, "dy": 0.0}}, + _line(bind="edge"), + ], + dry_run=True, + ) + row = result["results"][0] + assert row["error"]["kind"] == "unresolved_ref" + assert "edge" in row["error"]["message"] + + +async def test_dry_run_catches_a_reference_nobody_binds(client): + result = await _batch( + client, + [{"tool": "entity_move", "args": {"handle": "$typo", "dx": 1.0, "dy": 0.0}}], + dry_run=True, + ) + assert result["results"][0]["error"]["kind"] == "unresolved_ref" + + +async def test_a_reference_stands_in_for_any_type_during_validation(client): + """``$mid.x`` is a float at run time and a string at validation time. + + The validator must not reject it for that — but it must still check + everything around it, which the following test proves. + """ + result = await _batch( + client, + [ + _line(bind="edge"), + {"tool": "point_from_snap", "args": {"handle": "$edge", "snap": "mid"}, "bind": "m"}, + {"tool": "entity_create_circle", "args": {"cx": "$m.x", "cy": "$m.y", "radius": 1.0}}, + ], + dry_run=True, + ) + assert result["ok"] is True, result["results"] + + +async def test_a_reference_does_not_excuse_the_rest_of_the_step(client): + result = await _batch( + client, + [ + _line(bind="edge"), + {"tool": "entity_move", "args": {"handle": "$edge", "dx": "sideways", "dy": 0.0}}, + ], + dry_run=True, + ) + assert result["results"][1]["error"]["kind"] == "invalid_args" + + +async def test_an_invalid_step_stops_a_real_batch_before_anything_runs(client): + with pytest.raises(ToolError) as excinfo: + await _batch(client, [_line(), {"tool": "entity_create_dragon", "args": {}}]) + assert "entity_create_dragon" in str(excinfo.value) + assert await _entity_count(client) == 0, "validation must fail closed, before step 1 draws" + + +async def test_a_malformed_step_is_reported_not_crashed(client): + result = await _batch(client, [{"args": {"x1": 0}}], dry_run=True) + assert result["results"][0]["error"]["kind"] == "malformed_step" + + +# --------------------------------------------------------------------------- +# Privilege — the deny set +# --------------------------------------------------------------------------- + + +async def test_the_deny_set_is_derived_from_the_escape_cost_cards(): + """Hand-listing would go stale the day a new escape hatch is registered.""" + escapes = { + tool.name + for tool in await server._registered_tools() + if ((tool.meta or {}).get("cad") or {}).get("cost") == "escape" + } + assert escapes, "the registry must still carry escape-cost tools for this to mean anything" + assert server._batch_denied_tools() == escapes | {"cad_batch"} + + +async def test_the_raw_command_and_lisp_hatches_are_denied(): + denied = server._batch_denied_tools() + assert {"system_run_command", "system_run_lisp"} <= denied + + +@pytest.mark.parametrize("tool", ["system_run_command", "system_run_lisp"]) +async def test_a_denied_tool_refuses_the_whole_batch(client, tool): + args = {"command": "_ZOOM E"} if tool.endswith("command") else {"expression": "(+ 1 1)"} + with pytest.raises(ToolError) as excinfo: + await _batch(client, [_line(), {"tool": tool, "args": args}]) + message = str(excinfo.value) + assert tool in message + assert "denied" in message.lower() or "not callable" in message.lower() + assert await _entity_count(client) == 0, "a denied step must abort before step 1 draws" + + +async def test_a_denied_step_is_not_survivable_with_on_error_continue(client): + """Otherwise the deny set would just be a step you skip past.""" + with pytest.raises(ToolError): + await _batch( + client, + [{"tool": "system_run_lisp", "args": {"expression": "(+ 1 1)"}}, _line()], + on_error="continue", + ) + assert await _entity_count(client) == 0 + + +async def test_cad_batch_cannot_nest_inside_itself(client): + """No recursion, and no laundering a denied step through a nested executor.""" + result = await _batch( + client, [{"tool": "cad_batch", "args": {"steps": [_line()]}}], dry_run=True + ) + assert result["results"][0]["error"]["kind"] == "denied" + with pytest.raises(ToolError, match="cad_batch"): + await _batch(client, [{"tool": "cad_batch", "args": {"steps": [_line()]}}]) + + +async def test_a_tool_with_no_discovery_card_is_not_run_blind(client): + """Fail closed on unknown provenance: the deny set is computed from the local + registry, but a mounted or transformed provider can supply a tool it never saw. + Every tool this server registers carries a card (there is a coverage gate), + so this costs the native surface nothing.""" + + @server.mcp.tool(name="_batch_uncarded_probe") + async def _uncarded(ctx=None) -> dict: + return {"ok": True} + + try: + result = await _batch(client, [{"tool": "_batch_uncarded_probe", "args": {}}], dry_run=True) + assert result["results"][0]["error"]["kind"] == "denied" + assert "card" in result["results"][0]["error"]["message"] + finally: + server.mcp.local_provider.remove_tool("_batch_uncarded_probe") + + +async def test_a_dry_run_reports_a_denied_step_rather_than_hiding_it(client): + result = await _batch( + client, + [{"tool": "system_run_command", "args": {"command": "_ZOOM E"}}], + dry_run=True, + ) + assert result["ok"] is False + assert result["results"][0]["error"]["kind"] == "denied" + + +async def test_the_refusal_points_at_the_direct_tool(client): + """The escape hatch stays reachable — separately, where its own gate applies.""" + with pytest.raises(ToolError) as excinfo: + await _batch(client, [{"tool": "system_run_command", "args": {"command": "_ZOOM E"}}]) + assert "directly" in str(excinfo.value) + + +# --------------------------------------------------------------------------- +# The production laundering vector: DISCOVERY_MODE=search +# --------------------------------------------------------------------------- +# +# In search mode the advertised surface is just ``search_tools`` + ``call_tool``, +# and ``call_tool`` invokes any tool by name. That is a general-purpose proxy +# sitting inside the same server as a denylist — i.e. exactly the shape of a way +# around it. The deny set is derived from ``cost="escape"`` cards, and the proxy +# is not a local component, so nothing in the *denylist* stops it; what stops it +# is the fail-closed "no @cad_tool card, no execution" rule. +# +# These pin that, because the coupling is invisible: giving ``call_tool`` a card +# — an entirely reasonable thing to want, so it is costed and discoverable — +# silently reopens the escalation path with no other test failing. + + +@pytest_asyncio.fixture +async def search_mode(monkeypatch): + """The real search surface, restored to ``off`` afterwards.""" + monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf") + server._apply_discovery_mode("search") + try: + async with Client(server.mcp) as connected: + yield connected + finally: + server._apply_discovery_mode("off") + + +async def test_search_mode_advertises_the_proxy_that_makes_this_a_risk(search_mode): + names = {tool.name for tool in await search_mode.list_tools()} + assert names == {"search_tools", "call_tool"}, ( + "if the search surface changed, the laundering test below is aiming at the wrong target" + ) + + +@pytest.mark.parametrize("hatch", ["system_run_command", "system_run_lisp"]) +async def test_the_search_proxy_cannot_launder_a_denied_hatch(search_mode, hatch): + """cad_batch -> call_tool -> system_run_* must not execute.""" + result = ( + await search_mode.call_tool( + "call_tool", + { + "name": "cad_batch", + "arguments": { + "steps": [{"tool": "call_tool", "args": {"name": hatch, "arguments": {}}}], + "dry_run": True, + }, + }, + ) + ).structured_content or {} + assert result["ok"] is False + assert result["results"][0]["error"]["kind"] == "denied" + + +async def test_the_laundered_hatch_is_refused_before_anything_executes(search_mode): + """Not merely reported — the whole batch is refused, so the proxy never runs.""" + with pytest.raises(ToolError) as excinfo: + await search_mode.call_tool( + "call_tool", + { + "name": "cad_batch", + "arguments": { + "steps": [ + { + "tool": "call_tool", + "args": { + "name": "system_run_command", + "arguments": {"command": "_ZOOM E"}, + }, + } + ] + }, + }, + ) + assert "refused all 1 steps - nothing was executed" in str(excinfo.value) + + +async def test_cad_batch_still_works_through_the_search_proxy(search_mode): + """The denial above must be a denial, not search mode breaking batching.""" + result = ( + await search_mode.call_tool( + "call_tool", + { + "name": "cad_batch", + "arguments": { + "steps": [{"tool": "drawing_new", "args": {}}, _line()], + }, + }, + ) + ).structured_content or {} + assert result["ok"] is True + assert result["succeeded"] == 2 + + +async def test_a_batch_step_may_still_call_the_older_batch_tools(client): + """The reconciliation: they compose. cad_batch orders, entity_batch_create bulks.""" + result = await _batch( + client, + [ + { + "tool": "entity_batch_create", + "args": { + "entities": [ + {"type": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 1}, + {"type": "circle", "cx": 0, "cy": 0, "radius": 1}, + ] + }, + }, + _line(), + ], + ) + assert result["ok"] is True + assert await _entity_count(client) == 3 + + +async def test_a_tool_hidden_by_the_profile_is_not_reachable_through_a_batch(client): + """cad_batch must not be a way around TOOL_PROFILE or the ENABLE_3D gate.""" + result = await _batch(client, [{"tool": "solid_box", "args": {}}], dry_run=True) + assert result["results"][0]["error"]["kind"] == "unknown_tool" + + +# --------------------------------------------------------------------------- +# Error taxonomy — typed, never string-sniffed +# --------------------------------------------------------------------------- + + +async def test_a_backend_capability_refusal_is_typed_as_unsupported(client, tmp_path): + result = await _batch( + client, + [{"tool": "drawing_save_as", "args": {"path": str(tmp_path / "part.dwg")}}], + on_error="continue", + ) + error = result["results"][0]["error"] + assert error["kind"] == "unsupported" + assert error["capability"] == "dwg", "the capability key must survive the re-entrant wrapper" + + +async def test_the_classifier_reads_the_cause_chain_not_the_message(): + """The proof that no message text is being sniffed: same words, different type.""" + from backends.ezdxf_backend import UnsupportedCapabilityError + + liar = RuntimeError("the headless ezdxf backend cannot write DWG (capability 'dwg')") + assert server._classify_batch_error(liar)["kind"] == "failed" + + honest = ToolError("Error calling tool 'drawing_save_as': nothing useful here") + honest.__cause__ = UnsupportedCapabilityError("dwg", "no DWG writer") + classified = server._classify_batch_error(honest) + assert classified["kind"] == "unsupported" + assert classified["capability"] == "dwg" + + +async def test_a_backend_failure_is_typed_as_failed(client): + result = await _batch( + client, [{"tool": "entity_get", "args": {"handle": "NOSUCH"}}], on_error="continue" + ) + assert result["results"][0]["error"]["kind"] == "failed" + + +async def test_a_deliberate_tool_refusal_is_typed_as_refused(client): + result = await _batch( + client, + [{"tool": "drawing_open", "args": {"path": "../../etc/passwd"}}], + on_error="continue", + ) + assert result["results"][0]["error"]["kind"] == "refused" + + +async def test_a_bound_value_of_the_wrong_type_is_typed_at_run_time(client): + """The one thing dry_run cannot check, reported precisely when it happens.""" + result = await _batch( + client, + [ + {"tool": "entity_create_text", "args": {"text": "T", "x": 0, "y": 0}, "bind": "t"}, + {"tool": "entity_move", "args": {"handle": "$t", "dx": "$t.type", "dy": 0.0}}, + ], + on_error="continue", + ) + assert result["results"][1]["error"]["kind"] == "invalid_args" + + +async def test_every_reported_kind_is_a_declared_kind(client, tmp_path): + result = await _batch( + client, + [ + {"tool": "entity_get", "args": {"handle": "NOSUCH"}}, + {"tool": "drawing_save_as", "args": {"path": str(tmp_path / "p.dwg")}}, + _line(), + ], + on_error="continue", + ) + kinds = {row["error"]["kind"] for row in result["results"] if row["status"] == "error"} + assert kinds <= set(server.BATCH_ERROR_KINDS) + assert kinds + + +# --------------------------------------------------------------------------- +# on_error +# --------------------------------------------------------------------------- + + +async def test_stop_is_the_default_because_both_backends_deliver_it(client): + result = await _batch(client, [_line()]) + assert result["on_error"] == "stop" + + +async def test_stop_leaves_earlier_steps_applied_and_says_so(client): + result = await _batch( + client, + [_line(), {"tool": "entity_get", "args": {"handle": "NOSUCH"}}, _line(y1=9.0, y2=9.0)], + on_error="stop", + ) + assert result["ok"] is False + statuses = [row["status"] for row in result["results"]] + assert statuses == ["ok", "error", "skipped"] + assert result["skipped"] == 1 + assert await _entity_count(client) == 1 + assert result["atomicity"]["rolled_back"] is False + assert result["atomicity"]["guarantee"] == "none" + + +async def test_continue_runs_every_step(client): + result = await _batch( + client, + [_line(), {"tool": "entity_get", "args": {"handle": "NOSUCH"}}, _line(y1=9.0, y2=9.0)], + on_error="continue", + ) + assert [row["status"] for row in result["results"]] == ["ok", "error", "ok"] + assert result["succeeded"] == 2 and result["failed"] == 1 + assert await _entity_count(client) == 2 + + +async def test_continue_does_not_execute_a_step_whose_binding_never_landed(client): + """A dependent step must not silently run on a stale or missing value.""" + result = await _batch( + client, + [ + {"tool": "entity_get", "args": {"handle": "NOSUCH"}, "bind": "ghost"}, + {"tool": "entity_move", "args": {"handle": "$ghost", "dx": 1.0, "dy": 0.0}}, + _line(), + ], + on_error="continue", + ) + assert result["results"][0]["status"] == "error" + assert result["results"][1]["status"] == "error" + assert result["results"][1]["error"]["kind"] == "unresolved_ref" + assert result["results"][2]["status"] == "ok" + + +async def test_an_unknown_on_error_mode_is_rejected(client): + with pytest.raises(ToolError): + await _batch(client, [_line()], on_error="pray") + + +# --------------------------------------------------------------------------- +# Atomicity — honest per backend +# --------------------------------------------------------------------------- + + +class _Caps: + def __init__(self, mode, supported=True): + from backends.base import CapabilityMap, FeatureCapability + + self.map = CapabilityMap( + backend="stub", features={"transactions": FeatureCapability(supported, mode)} + ) + + def capabilities(self): + return self.map + + +async def test_a_snapshot_backend_is_reported_as_a_snapshot_guarantee(): + guarantee, note = server._batch_rollback_guarantee(_Caps("snapshot")) + assert guarantee == "snapshot" + assert "snapshot" in note.lower() + + +async def test_an_undo_mark_backend_is_never_called_atomic(): + """COM ends the undo mark and fires ``_UNDO B`` without confirming it landed.""" + guarantee, note = server._batch_rollback_guarantee(_Caps("undo_mark")) + assert guarantee == "best_effort_undo" + lowered = note.lower() + assert "not atomic" in lowered, "it must say so, not merely omit the word" + assert "best-effort" in lowered + assert "does not confirm" in lowered + + +async def test_a_backend_without_transactions_gets_no_guarantee(): + guarantee, _ = server._batch_rollback_guarantee(_Caps(None, supported=False)) + assert guarantee == "none" + + +async def test_an_unrecognised_transaction_mode_is_not_assumed_good(): + guarantee, _ = server._batch_rollback_guarantee(_Caps("time_travel")) + assert guarantee == "unverified" + + +async def test_rollback_restores_the_document_on_ezdxf(client): + await client.call_tool("entity_create_circle", {"cx": 0, "cy": 0, "radius": 1}) + result = await _batch( + client, + [_line(), _line(y1=4.0, y2=4.0), {"tool": "entity_get", "args": {"handle": "NOSUCH"}}], + on_error="rollback", + ) + assert result["ok"] is False + assert await _entity_count(client) == 1, "the two batch lines must be gone" + atomicity = result["atomicity"] + assert atomicity["guarantee"] == "snapshot" + assert atomicity["rolled_back"] is True + assert atomicity["verified"] is True + assert atomicity["before"] == atomicity["after"] + + +async def test_a_successful_rollback_batch_commits_and_keeps_its_work(client): + result = await _batch(client, [_line(), _line(y1=4.0, y2=4.0)], on_error="rollback") + assert result["ok"] is True + assert result["atomicity"]["rolled_back"] is False + assert result["atomicity"]["committed"] is True + assert await _entity_count(client) == 2 + + +async def test_rollback_reports_the_guarantee_it_actually_got(client): + result = await _batch(client, [_line()], on_error="rollback") + atomicity = result["atomicity"] + assert atomicity["mode"] == "rollback" + assert atomicity["guarantee"] in server.BATCH_ROLLBACK_GUARANTEES + assert atomicity["note"] + + +async def test_rollback_refuses_when_the_backend_declines_the_checkpoint(client, monkeypatch): + """A checkpoint we did not open is not one we can roll back to. + + This is the live COM path: ``ComBackend.transaction_begin`` *returns* + ``{"ok": False, "error": "A transaction is already active"}`` rather than + raising, so a batch that took the return value on faith would run every + step and then report a rollback it never had. Simulated on the headless + backend, whose own transaction stack happily nests. + """ + from backends.ezdxf_backend import EzdxfBackend + + async def _decline(self): + return {"ok": False, "error": "A transaction is already active"} + + monkeypatch.setattr(EzdxfBackend, "transaction_begin", _decline) + result = await _batch(client, [_line()], on_error="rollback") + assert result["ok"] is False + assert result["executed"] == 0 + assert result["atomicity"]["transaction"] is False + assert "already active" in result["atomicity"]["note"] + assert await _entity_count(client) == 0, "nothing may run without the checkpoint" + + +async def test_a_rollback_batch_does_not_disturb_a_transaction_the_caller_opened(client): + """The headless backend's checkpoints are a stack; ours must pop only its own.""" + await client.call_tool("transaction_begin", {}) + await client.call_tool("entity_create_circle", {"cx": 0, "cy": 0, "radius": 1}) + result = await _batch( + client, + [_line(), {"tool": "entity_get", "args": {"handle": "NOSUCH"}}], + on_error="rollback", + ) + assert result["atomicity"]["rolled_back"] is True + assert await _entity_count(client) == 1, "the caller's circle survives our rollback" + await client.call_tool("transaction_rollback", {}) + assert await _entity_count(client) == 0, "the caller's own checkpoint still works" + + +async def test_non_rollback_modes_make_no_atomicity_claim(client): + """And say it in four keys, not in two paragraphs about a rollback that was + never on the table — that boilerplate measured 61% of a short batch's reply.""" + for mode in ("stop", "continue"): + result = await _batch(client, [_line()], on_error=mode) + atomicity = result["atomicity"] + assert atomicity["transaction"] is False + assert atomicity["rolled_back"] is False + assert atomicity["guarantee"] == "none" + assert set(atomicity) == {"mode", "guarantee", "transaction", "rolled_back", "note"} + assert "atomic" not in atomicity["note"].lower() + assert len(dumps(atomicity)) < 200 + + +async def test_a_dry_run_opens_no_transaction(client): + result = await _batch(client, [_line()], on_error="rollback", dry_run=True) + assert result["atomicity"]["transaction"] is False + status = (await client.call_tool("system_status", {})).structured_content or {} + assert status.get("transaction_depth", 0) == 0 + + +# --------------------------------------------------------------------------- +# Limits and progress +# --------------------------------------------------------------------------- + + +async def test_an_empty_batch_is_rejected(client): + with pytest.raises(ToolError): + await _batch(client, []) + + +async def test_the_step_count_is_capped(client): + with pytest.raises(ToolError) as excinfo: + await _batch(client, [_line()] * (server.MAX_BATCH_STEPS + 1)) + assert str(server.MAX_BATCH_STEPS) in str(excinfo.value) + + +async def test_progress_is_reported_for_every_step(monkeypatch): + monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf") + seen: list[tuple[float, float | None]] = [] + + async def handler(progress, total, message=None): + seen.append((progress, total)) + + async with Client(server.mcp, progress_handler=handler) as connected: + await connected.call_tool("drawing_new", {}) + await connected.call_tool( + "cad_batch", {"steps": [_line(), _line(y1=2.0, y2=2.0), _line(y1=4.0, y2=4.0)]} + ) + assert seen, "a long batch is the canonical case for ctx.report_progress" + assert (3.0, 3.0) in [(p, t) for p, t in seen], "the final tick must report completion" + + +# --------------------------------------------------------------------------- +# The rollback claim must come from the backend, not from control flow +# --------------------------------------------------------------------------- + + +async def test_rollback_is_not_claimed_when_the_backend_declines_it(client): + """A step that commits consumes the checkpoint out from under us. + + ``transaction_rollback()`` then returns ``{"ok": False, "error": "No active + transaction to rollback"}``. Reporting ``rolled_back: True`` off control + flow would be the same class of lie as writing DXF bytes into a .dwg. + """ + steps = [ + _line(), + {"tool": "transaction_commit", "args": {}}, + {"tool": "entity_get", "args": {"handle": "NOSUCH"}}, + ] + result = await _batch(client, steps, on_error="rollback") + + assert result["ok"] is False + assert result["atomicity"]["rolled_back"] is False, ( + "the backend refused the rollback; the payload must not claim it happened" + ) + assert await _entity_count(client) == 1, "the line really did survive" + + +async def test_commit_is_not_claimed_when_the_backend_declines_it(client): + """Same rule on the success path: a step that already committed leaves + nothing for the batch's own commit to do.""" + steps = [_line(), {"tool": "transaction_commit", "args": {}}] + result = await _batch(client, steps, on_error="rollback") + + assert result["ok"] is True + assert result["atomicity"]["committed"] is False, ( + "the checkpoint was already gone; do not report a commit that did not happen" + ) + + +async def test_a_read_only_finding_is_not_a_failed_step(client): + """A checker reporting findings must not be mistaken for a step that failed. + + ``validation_check`` is ``readOnlyHint=True`` and answers + ``{"ok": len(issues) == 0}`` — ``ok: False`` there means *it found something*, + not *it could not run*. Treating that as a refusal made a read-only check + trigger a verified rollback that destroyed the geometry the batch had just + drawn. A mutator's ``ok: False`` still means it did not mutate. + """ + steps = [_line(), {"tool": "validation_check", "args": {}}] + result = await _batch(client, steps, on_error="rollback") + + assert result["succeeded"] == 2 + assert result["failed"] == 0 + assert result["ok"] is True + assert result["atomicity"]["rolled_back"] is False + assert await _entity_count(client) == 1, "the read-only check must not undo the line" diff --git a/tests/test_cad_progid.py b/tests/test_cad_progid.py new file mode 100644 index 0000000..bc56efb --- /dev/null +++ b/tests/test_cad_progid.py @@ -0,0 +1,153 @@ +"""``CAD_PROGID`` — which COM application the live backend attaches to. + +The ProgID was hardcoded twice inside ``_acad_app``, so the only CAD this server +could ever drive was AutoCAD itself. GstarCAD, ZWCAD and BricsCAD all expose the +same ActiveX object model under a different ProgID, and the connection is the +only thing standing between them and the existing 100+ COM methods. + +Two things make this more delicate than a string swap, and both are tested here: + +* **Nothing may connect.** These tests run on a machine with AutoCAD installed. + The ``Dispatch`` branch is not a passive probe — it COM-launches the + application and sets ``Visible = True``. So the fake below records the ProgID + it was *asked* for and returns a stub; every assertion is on the recorded + calls, never on a returned object. ``raising=False`` on the ``win32com`` patch + is load-bearing: that name only exists in the module when the pywin32 imports + succeeded, so without it these tests would pass on a Windows dev box and error + on Linux CI. +* **The cache would hide the bug.** ``_acad_app`` short-circuits on + ``_COM_STATE["app"]``, so a warm cache makes it perform zero lookups and the + test asserts nothing at all. The fixture rebinds ``_COM_STATE`` to a fresh + dict rather than mutating it — that also guarantees a test can never leave a + fake application object in the real process-wide cache. + +An unknown ProgID fails loudly rather than falling back to AutoCAD. Unlike +``TOOL_PROFILE`` and ``DISCOVERY_MODE``, which fall back with a warning because +they have closed enums, this is an open string: an unrecognised value is not +"invalid", it is "unverifiable by us", and it has no principled substitute. The +blast radius decides it — refusing costs a readable startup error, while falling +back launches and attaches to a live AutoCAD the user explicitly said they were +not using. +""" + +from __future__ import annotations + +import types + +import pytest + +import config +from backends import com_backend as cb + +DEFAULT = "AutoCAD.Application" +OTHER = "BricscadApp.AcadApplication" + + +@pytest.fixture +def com_calls(monkeypatch): + """Record every ProgID ``_acad_app`` asks for, and connect to nothing.""" + calls: list[tuple[str, str]] = [] + + def _get_active_object(progid): + calls.append(("GetActiveObject", progid)) + raise OSError("no running instance (fake)") + + def _dispatch(progid): + calls.append(("Dispatch", progid)) + return types.SimpleNamespace(Visible=False) + + monkeypatch.setattr( + cb, + "win32com", + types.SimpleNamespace( + client=types.SimpleNamespace( + GetActiveObject=_get_active_object, + Dispatch=_dispatch, + ) + ), + raising=False, + ) + monkeypatch.setattr(cb, "_COM_STATE", {}) + return calls + + +# -- the setting ------------------------------------------------------------ + + +def test_the_default_progid_is_autocad(monkeypatch): + monkeypatch.delenv("CAD_PROGID", raising=False) + assert config.settings.cad_progid == DEFAULT + + +def test_the_setting_is_read_live_and_stripped(monkeypatch): + monkeypatch.setenv("CAD_PROGID", f" {OTHER} ") + assert config.settings.cad_progid == OTHER + + +def test_case_is_preserved_because_the_value_is_shown_to_humans(monkeypatch): + """``gstarcad.application`` in an error message reads like a typo we made.""" + monkeypatch.setenv("CAD_PROGID", "GstarCAD.Application") + assert config.settings.cad_progid == "GstarCAD.Application" + + +def test_an_empty_value_falls_back_to_the_default(monkeypatch): + monkeypatch.setenv("CAD_PROGID", " ") + assert config.settings.cad_progid == DEFAULT + + +def test_assigning_to_the_setting_fails_loudly(monkeypatch): + """Same reasoning as ``backend``: a silent no-op assignment reads as control.""" + monkeypatch.setenv("CAD_PROGID", OTHER) + with pytest.raises(AttributeError): + config.settings.cad_progid = DEFAULT + assert config.settings.cad_progid == OTHER + + +# -- what the connection actually asks for ---------------------------------- + + +def test_unset_attaches_to_autocad_on_both_paths(monkeypatch, com_calls): + monkeypatch.delenv("CAD_PROGID", raising=False) + cb._acad_app() + assert com_calls == [("GetActiveObject", DEFAULT), ("Dispatch", DEFAULT)] + + +def test_the_configured_progid_reaches_both_call_sites(monkeypatch, com_calls): + """The Dispatch fallback is the one that launches an application. + + Honouring the setting on ``GetActiveObject`` and then quietly launching + AutoCAD on the fallback would be worse than ignoring the setting entirely. + """ + monkeypatch.setenv("CAD_PROGID", OTHER) + cb._acad_app() + assert [progid for _, progid in com_calls] == [OTHER, OTHER] + assert DEFAULT not in [progid for _, progid in com_calls] + + +def test_a_failed_connection_names_the_progid_it_tried(monkeypatch, com_calls): + def _boom(progid): + com_calls.append(("Dispatch", progid)) + raise OSError("class not registered (fake)") + + monkeypatch.setattr(cb.win32com.client, "Dispatch", _boom) + monkeypatch.setenv("CAD_PROGID", "Nope.NotRegistered") + + with pytest.raises(RuntimeError) as excinfo: + cb._acad_app() + + message = str(excinfo.value) + assert "Nope.NotRegistered" in message, "a bare 'cannot connect to AutoCAD' misdirects" + assert "CAD_PROGID" in message, "the message must say which knob to turn" + assert DEFAULT not in [progid for _, progid in com_calls], "must never fall back to AutoCAD" + + +def test_a_failed_connection_does_not_poison_the_cache(monkeypatch, com_calls): + def _boom(progid): + raise OSError("class not registered (fake)") + + monkeypatch.setattr(cb.win32com.client, "Dispatch", _boom) + monkeypatch.setenv("CAD_PROGID", "Nope.NotRegistered") + + with pytest.raises(RuntimeError): + cb._acad_app() + assert "app" not in cb._COM_STATE diff --git a/tests/test_capabilities.py b/tests/test_capabilities.py index ca0b3fd..b8b2193 100644 --- a/tests/test_capabilities.py +++ b/tests/test_capabilities.py @@ -33,11 +33,12 @@ async def test_ezdxf_capabilities_are_machine_readable(backend): "reason": None, } assert caps["features"]["mleader"]["mode"] == "composite" - # v1.4: paper-space layouts/viewports are native on both backends; - # projecting model content through viewports stays COM-only, and 3D - # ACIS solids remain impossible headlessly. + # Paper-space layouts/viewports are native on both backends. Viewport + # model projection was declared COM-only in v1.4 and is not — measured in + # tests/test_viewport_ops.py, which renders a sheet holding nothing but a + # viewport. 3D ACIS solids do remain impossible headlessly. assert caps["features"]["paper_space"]["supported"] is True - assert caps["features"]["viewport_render"]["supported"] is False + assert caps["features"]["viewport_render"]["supported"] is True assert caps["features"]["solid_3d"]["supported"] is False diff --git a/tests/test_capability_contract.py b/tests/test_capability_contract.py new file mode 100644 index 0000000..8d9b584 --- /dev/null +++ b/tests/test_capability_contract.py @@ -0,0 +1,118 @@ +"""``@capability``: the escape hatch that is not allowed to become a hole. + +M7 split a 1854-line ABC with 112 abstract methods into per-domain contracts. +The split makes contract changes navigable; it does not fix the reason contract +changes hurt, which is that every method is an `@abstractmethod`, so adding one +makes *both* backends fail at instantiation until both are written — and two +test modules construct `ComBackend` at import time, so a half-finished edit +takes the whole suite red. + +`@capability` gives such a method a default. The default is the dangerous part, +so it is pinned here: + +* It **raises** `UnsupportedCapabilityError`, never returns a quiet + `{"ok": False}`. A method nobody implemented must be indistinguishable from a + deliberate refusal, not from a soft failure — the v1.5.0 honesty pass exists + because things that quietly did nothing looked like things that worked. +* Its capability key is **declared in both backends' capability maps**. Without + this test the decorator would be a way to ship refusals that + `system_capabilities` never mentions, which is worse than the build break it + replaces: a client that pre-checks the feature map would be told the call is + fine and then be refused. +* Overriding it is ordinary subclassing, so an implementing backend pays + nothing. +""" + +from __future__ import annotations + +import inspect + +import pytest + +from backends.base import AutoCADBackend, UnsupportedCapabilityError +from backends.capability import capability_keys, is_capability_default +from backends.com_backend import ComBackend +from backends.ezdxf_backend import EzdxfBackend + +# Marked per-test rather than module-wide: half of these are plain registry +# checks, and pytest-asyncio warns on a sync test carrying the mark. +asyncio_test = pytest.mark.asyncio + + +def _feature_keys(backend) -> set[str]: + return set(backend.capabilities().to_dict()["features"]) + + +# ── the registry is real ──────────────────────────────────────────────────── + + +def test_at_least_one_method_uses_the_decorator(): + """An unused abstraction is dead weight; this keeps the gate meaningful.""" + assert capability_keys(), "no @capability methods — delete the decorator or use it" + + +def test_every_declared_key_exists_in_both_capability_maps(): + """The gate. A refusal the feature map never mentions is a lie by omission.""" + ezdxf_keys = _feature_keys(EzdxfBackend()) + com_keys = _feature_keys(ComBackend()) + for key in capability_keys(): + assert key in ezdxf_keys, f"@capability({key!r}) is undeclared on the ezdxf backend" + assert key in com_keys, f"@capability({key!r}) is undeclared on the COM backend" + + +def test_the_contract_still_exposes_every_method_it_used_to(): + """The mixin split moved code; it must not have dropped any.""" + for names in capability_keys().values(): + for name in names: + assert callable(getattr(AutoCADBackend, name, None)), f"{name} vanished in the split" + + +# ── the default behaves like a deliberate refusal ─────────────────────────── + + +@asyncio_test +async def test_a_backend_that_does_not_implement_it_refuses_with_the_key(): + backend = ComBackend() + assert is_capability_default(ComBackend, "entity_change_space"), ( + "fixture assumption: the COM backend inherits the default here" + ) + + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await backend.entity_change_space(["1A"], "2B") + + assert excinfo.value.capability == "chspace" + payload = excinfo.value.to_dict() + assert payload["ok"] is False + assert payload["capability"] == "chspace" + + +@asyncio_test +async def test_the_refusal_names_the_backend_and_says_what_to_do_instead(): + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await ComBackend().entity_change_space(["1A"], "2B") + message = str(excinfo.value) + assert "com" in message + assert "ezdxf" in message, "a refusal that does not name the way out is half a refusal" + + +@asyncio_test +async def test_the_default_never_returns_a_soft_failure(): + """The design decision this file exists to defend. + + Returning ``{"ok": False, "capability": ...}`` would satisfy every caller's + type expectations and silently mean "nothing happened". + """ + try: + result = await ComBackend().entity_change_space(["1A"], "2B") + except UnsupportedCapabilityError: + return + pytest.fail(f"the default returned {result!r} instead of raising") + + +@asyncio_test +async def test_an_implementing_backend_is_unaffected(backend): + assert not is_capability_default(EzdxfBackend, "entity_change_space") + assert inspect.iscoroutinefunction(EzdxfBackend.entity_change_space) + result = await backend.entity_change_space([], "nope") + assert result["ok"] is False + assert "error" in result, "the real implementation still does its own validation" diff --git a/tests/test_capability_refusal.py b/tests/test_capability_refusal.py new file mode 100644 index 0000000..f5d5086 --- /dev/null +++ b/tests/test_capability_refusal.py @@ -0,0 +1,158 @@ +"""T0.7 — a capability refusal has to survive the trip to the client. + +The backends know precisely why they cannot do a thing: they raise +``UnsupportedCapabilityError(capability, message)``, where ``capability`` is a +key of ``capabilities().features`` so a client can pre-check the same boundary +through ``system_capabilities``. That type does not reach anybody. +``FastMCP.call_tool`` rewraps any non-``FastMCPError`` as +``ToolError(f"Error calling tool {name!r}: {e}") from e``; the ``from e`` keeps +the cause alive **in process** — which is the only reason ``cad_batch`` can +classify it — but ``__cause__`` does not serialise. Over the wire the client got +an English sentence and nothing else, so "this backend cannot do that" was +indistinguishable from "your arguments were wrong" except by substring matching. + +Subclassing ``FastMCPError`` does not fix this; it changes nothing the client can +observe. The one channel that carries a machine-readable payload *and* the error +flag is ``ToolResult(structured_content=..., is_error=True)``, so a single +middleware — the last place the exception type is still knowable — converts the +refusal, and no tool function changes at all. + +The other half is ``cad_batch``: a tool may decline *by value* rather than by +raising, and counting that as a succeeded step is the same class of lie as +writing DXF bytes into a ``.dwg`` or reporting a rollback the backend refused. +""" + +from __future__ import annotations + +import pytest +import pytest_asyncio +from fastmcp import Client + +import server +from backends.base import UnsupportedCapabilityError +from backends.ezdxf_backend import EzdxfBackend + +pytestmark = pytest.mark.asyncio + + +@pytest_asyncio.fixture +async def client(monkeypatch): + """In-memory client on a fresh headless document. + + The env var is what ``server._make_backend`` reads; setting + ``config.settings.backend`` would leave a Windows box with AutoCAD open + talking to the operator's live drawing. + """ + monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf") + async with Client(server.mcp) as connected: + status = (await connected.call_tool("system_status", {})).structured_content or {} + assert status.get("backend") == "ezdxf", "these tests must never touch live AutoCAD" + await connected.call_tool("drawing_new", {}) + yield connected + + +# ── the wire contract ─────────────────────────────────────────────────────── + + +async def test_refusal_reaches_the_client_as_a_machine_readable_payload(client, tmp_path): + target = tmp_path / "part.dwg" + result = await client.call_tool("drawing_save_as", {"path": str(target)}, raise_on_error=False) + + assert result.is_error is True + payload = result.structured_content + assert isinstance(payload, dict), "a refusal must not arrive as prose only" + assert payload["ok"] is False + assert payload["kind"] == "unsupported" + assert payload["capability"] == "dwg" + assert payload["tool"] == "drawing_save_as" + assert payload["backend"] == "ezdxf" + + # The prose channel still works for clients that only read text, and it is + # the FULL sentence — a refusal that names no way forward is not help. + assert payload["error"] in result.content[0].text + assert ".dxf" in payload["error"] + assert not target.exists(), "a refusal must not leave a mislabelled file behind" + + +async def test_the_capability_is_a_key_a_client_can_pre_check(client, tmp_path): + """The whole point of the key: ask before calling, get the same answer.""" + caps = (await client.call_tool("system_capabilities", {})).structured_content or {} + features = caps["features"] + + result = await client.call_tool( + "drawing_save_as", {"path": str(tmp_path / "x.dwg")}, raise_on_error=False + ) + capability = result.structured_content["capability"] + assert features[capability]["supported"] is False + + +async def test_ordinary_errors_are_left_exactly_as_they_were(client): + """The middleware must not turn every failure into a capability refusal.""" + result = await client.call_tool("entity_get", {"handle": "NOSUCH"}, raise_on_error=False) + assert result.is_error is True + payload = result.structured_content + assert not (isinstance(payload, dict) and payload.get("kind") == "unsupported") + + +async def test_a_successful_call_is_untouched(client): + result = await client.call_tool("entity_create_line", {"x1": 0, "y1": 0, "x2": 5, "y2": 0}) + assert result.is_error is False + assert result.structured_content["handle"] + + +# ── one refusal mechanism, not two ────────────────────────────────────────── + + +async def test_solid_tools_raise_the_typed_refusal_instead_of_returning_a_dict(): + """Returning ``{"ok": False}`` puts a refusal outside the middleware's reach. + + The payload the client sees is unchanged; what flips is ``is_error``, from a + quiet False to an honest True. + """ + backend = EzdxfBackend() + await backend.connect() + await backend.drawing_new() + + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await backend.solid_box(0, 0, 0, 10, 10, 10) + + assert excinfo.value.capability == "solid_3d" + assert excinfo.value.to_dict() == { + "ok": False, + "error": str(excinfo.value), + "capability": "solid_3d", + } + await backend.disconnect() + + +async def test_the_refusal_type_lives_on_the_shared_base(): + """Both backends must be able to raise the same class, not two look-alikes.""" + from backends.ezdxf_backend import UnsupportedCapabilityError as from_ezdxf + + assert from_ezdxf is UnsupportedCapabilityError + + +# ── cad_batch: a declined step is not a succeeded step ────────────────────── + + +async def _batch(client, steps, **kwargs): + payload = {"steps": steps, **kwargs} + return (await client.call_tool("cad_batch", payload)).structured_content + + +async def test_batch_does_not_count_a_step_that_declined_by_value_as_succeeded(client): + """``drawing_redo`` with nothing to redo returns ``{"ok": False}`` and did nothing.""" + result = await _batch( + client, + [ + {"tool": "entity_create_line", "args": {"x1": 0, "y1": 0, "x2": 1, "y2": 0}}, + {"tool": "drawing_redo", "args": {}}, + ], + on_error="continue", + ) + + assert result["succeeded"] == 1, "the redo did nothing; it cannot be a success" + assert result["failed"] == 1 + redo_row = result["results"][1] + assert redo_row["status"] == "error" + assert redo_row["error"]["kind"] in {"refused", "unsupported"} diff --git a/tests/test_change_space.py b/tests/test_change_space.py new file mode 100644 index 0000000..c0d4748 --- /dev/null +++ b/tests/test_change_space.py @@ -0,0 +1,319 @@ +"""M6 — CHSPACE: moving geometry between model space and a sheet. + +AutoCAD's CHSPACE moves objects *through* a viewport, scaling and translating +them by that viewport's zoom so they look identical on screen before and after. +ezdxf's `move_to_layout` does the move and applies **no geometric change at +all** — so the obvious implementation reports `ok: true`, produces a 100 mm +feature as 100 mm of paper inside a 1:2 viewport, and writes a file that audits +perfectly clean. That is the exact class of silent lie this release exists to +remove, so the transform is the tool, not a nicety. + +The matrix is `viewport.get_transformation_matrix()`, not a hand-rolled +`translate(-view_center) @ scale @ translate(center)`. The hand-rolled version +ignores `view_target_point`, and with a target point set it misplaces geometry +by tens of millimetres while `is_top_view` is still True and the twist angle is +still zero — i.e. past every guard one would naturally write. The regression +test below pins the result against what ezdxf's own rendering pipeline projects. + +What this refuses, and why each refusal is not pedantry: + +* **Non-top views and twisted viewports** — ezdxf returns a meaningless + top-view matrix for the first with no complaint, and rotates about the paper + origin for the second. +* **Dimensions** — `transform` halves `get_measurement()` while the baked + dimension-block text still reads the old value. Whichever the consumer + trusts, the other is a lie, so a dimension moves only with + `freeze_dimensions=True`, which bakes the pre-transform measurement into the + text first. +* **ACIS bodies** — `3DSOLID`, `BODY`, `REGION` and `SURFACE` accept + `transform()` and move nothing; the matrix is parked in a pending + transformation the headless engine cannot apply. +* **Tables and proxies** — `ACAD_TABLE`, `ACAD_PROXY_ENTITY` and `OLE2FRAME` + raise `NotImplementedError` from `transform()`, and `hasattr(e, "transform")` + is `True` for all of them, so a `hasattr` dispatch does not filter them out. +* **Viewports** — ezdxf will happily move one into model space; AutoCAD's + CHSPACE never does. +* **Entities already in the target space** — `move_to_layout` to the same + layout silently succeeds, and a no-op must not read as a move. + +Clipping is *flagged*, not refused: AutoCAD also lets you move geometry that +falls outside the viewport, so each moved entity reports `inside_viewport` and +`on_sheet` rather than being rejected. +""" + +from __future__ import annotations + +import pytest + +pytestmark = pytest.mark.asyncio + +SHEET = "A3-Sheet" + + +async def _sheet_with_viewport(backend, *, scale=0.5, name=SHEET, **vp_dxf): + """An A3 sheet with one 1:2 viewport centred on the page.""" + await backend.layout_create(name) + layout = backend._doc.layouts.get(name) + layout.page_setup(size=(420, 297), margins=(0, 0, 0, 0), units="mm") + created = await backend.viewport_create(name, 210, 148.5, 200, 150, 50, 25, scale=scale) + viewport = backend._doc.entitydb.get(created["handle"]) + for key, value in vp_dxf.items(): + viewport.dxf.set(key, value) + return created["handle"], viewport + + +def _layout_of(backend, handle: str) -> str: + """The layout tab an entity lives on, by owning block record.""" + doc = backend._doc + owner = doc.entitydb.get(handle).dxf.owner + if owner == doc.modelspace().block_record.dxf.handle: + return "Model" + for name in doc.layouts.names(): + if name != "Model" and doc.layouts.get(name).block_record.dxf.handle == owner: + return name + return owner + + +# ── the transform is the point ────────────────────────────────────────────── + + +async def test_moving_to_paper_scales_the_geometry_by_the_viewport(backend): + vp, _ = await _sheet_with_viewport(backend, scale=0.5) + line = await backend.entity_create_line(50, 25, 150, 25) # 100 mm through the view centre + + result = await backend.entity_change_space([line.handle], vp) + + assert result["ok"] is True + assert result["moved"][0]["handle"] == line.handle + moved = backend._doc.entitydb.get(line.handle) + length = (moved.dxf.end - moved.dxf.start).magnitude + assert length == pytest.approx(50.0), "a 100 mm model line is 50 mm of paper at 1:2" + + +async def test_the_moved_geometry_lands_where_the_renderer_projects_it(backend): + """Regression test for the hand-rolled matrix that ignores view_target_point. + + With ``view_target_point`` set, the naive + ``translate(-view_center) @ scale @ translate(center)`` misplaces the view + centre by 25 mm while ``is_top_view`` is True and the twist is zero — so no + guard catches it and only this comparison does. + """ + from ezdxf.math import Vec3 + + vp, viewport = await _sheet_with_viewport(backend, scale=0.5, view_target_point=(50, 25, 0)) + expected = viewport.get_transformation_matrix().transform(Vec3(60, 30, 0)) + line = await backend.entity_create_line(60, 30, 70, 30) + + await backend.entity_change_space([line.handle], vp) + + moved = backend._doc.entitydb.get(line.handle) + assert moved.dxf.start.x == pytest.approx(expected.x) + assert moved.dxf.start.y == pytest.approx(expected.y) + + +async def test_the_move_preserves_the_handle(backend): + """Every tool in this server addresses entities by handle.""" + vp, _ = await _sheet_with_viewport(backend) + line = await backend.entity_create_line(50, 25, 100, 25) + + await backend.entity_change_space([line.handle], vp) + + info = await backend.entity_get(line.handle) + assert info.handle == line.handle + assert _layout_of(backend, line.handle) == SHEET + + +async def test_a_round_trip_returns_the_original_coordinates(backend): + vp, _ = await _sheet_with_viewport(backend, scale=0.5) + line = await backend.entity_create_line(50, 25, 150, 25) + + await backend.entity_change_space([line.handle], vp, direction="to_paper") + await backend.entity_change_space([line.handle], vp, direction="to_model") + + moved = backend._doc.entitydb.get(line.handle) + assert moved.dxf.start.x == pytest.approx(50.0) + assert moved.dxf.end.x == pytest.approx(150.0) + assert _layout_of(backend, line.handle) == "Model" + + +async def test_the_reported_scale_matches_the_viewport(backend): + vp, _ = await _sheet_with_viewport(backend, scale=0.25) + line = await backend.entity_create_line(50, 25, 90, 25) + + result = await backend.entity_change_space([line.handle], vp) + + assert result["scale"] == pytest.approx(0.25) + assert result["viewport"] == vp + + +# ── what it refuses ───────────────────────────────────────────────────────── + + +async def test_a_twisted_viewport_is_refused(backend): + """ezdxf rotates about the paper origin; the alternative is unvalidatable.""" + vp, _ = await _sheet_with_viewport(backend, view_twist_angle=30.0) + line = await backend.entity_create_line(50, 25, 100, 25) + + result = await backend.entity_change_space([line.handle], vp) + + assert result["ok"] is False + assert "twist" in result["error"].lower() + assert _layout_of(backend, line.handle) == "Model", "a refused move must not move anything" + + +async def test_a_non_top_view_viewport_is_refused(backend): + """ezdxf hands back a meaningless top-view matrix with no complaint.""" + vp, _ = await _sheet_with_viewport(backend, view_direction_vector=(1, 1, 1)) + line = await backend.entity_create_line(50, 25, 100, 25) + + result = await backend.entity_change_space([line.handle], vp) + + assert result["ok"] is False + assert _layout_of(backend, line.handle) == "Model" + + +async def test_a_viewport_handle_that_is_not_a_viewport_is_refused(backend): + await _sheet_with_viewport(backend) + line = await backend.entity_create_line(0, 0, 10, 0) + result = await backend.entity_change_space([line.handle], line.handle) + assert result["ok"] is False + + +async def test_an_unfrozen_dimension_is_refused(backend): + """transform() halves the measurement while the block text keeps the old one.""" + vp, _ = await _sheet_with_viewport(backend) + dim = await backend.dimension_linear(50, 25, 150, 25, 50, 40) + + result = await backend.entity_change_space([dim.handle], vp) + + assert result["ok"] is True + assert result["moved"] == [] + reason = result["refused"][0]["reason"] + assert "dimension" in reason.lower() and "freeze_dimensions" in reason + + +async def test_freezing_bakes_the_measurement_before_scaling(backend): + vp, _ = await _sheet_with_viewport(backend, scale=0.5) + dim = await backend.dimension_linear(50, 25, 150, 25, 50, 40) + + result = await backend.entity_change_space([dim.handle], vp, freeze_dimensions=True) + + assert result["ok"] is True + row = result["moved"][0] + assert row["frozen_text"] == "100" + assert backend._doc.entitydb.get(dim.handle).dxf.text == "100" + + +@pytest.mark.parametrize("maker", ["add_body", "add_3dsolid", "add_region"]) +async def test_acis_bodies_are_refused_because_transform_moves_nothing(backend, maker): + vp, _ = await _sheet_with_viewport(backend) + entity = getattr(backend._doc.modelspace(), maker)() + + result = await backend.entity_change_space([entity.dxf.handle], vp) + + assert result["moved"] == [] + assert entity.dxftype() in result["refused"][0]["reason"] + + +async def test_moving_a_viewport_itself_is_refused(backend): + vp, _ = await _sheet_with_viewport(backend) + other, _ = await _sheet_with_viewport(backend, name="Second") + + result = await backend.entity_change_space([other], vp) + + assert result["moved"] == [] + assert "VIEWPORT" in result["refused"][0]["reason"] + + +async def test_an_entity_already_in_the_target_space_is_refused(backend): + """`move_to_layout` to the same layout silently succeeds.""" + vp, _ = await _sheet_with_viewport(backend) + line = await backend.entity_create_line(50, 25, 100, 25) + await backend.entity_change_space([line.handle], vp) + + result = await backend.entity_change_space([line.handle], vp) + + assert result["moved"] == [] + assert "already" in result["refused"][0]["reason"].lower() + + +async def test_an_entity_on_a_different_sheet_is_refused(backend): + """`move_to_layout` raises a raw DXFValueError from deep inside ezdxf here. + + Worse, it raises *after* the transform has already been applied: the failed + call left a line that had been scaled and translated by the viewport and + then stayed exactly where it was, on the wrong sheet. + """ + vp, _ = await _sheet_with_viewport(backend, scale=0.5) + await backend.layout_create("Other-Sheet") + await backend.layout_set_current("Other-Sheet") + line = await backend.entity_create_line(0, 0, 10, 0) + await backend.layout_set_current("Model") + before = backend._doc.entitydb.get(line.handle).dxf.end + + result = await backend.entity_change_space([line.handle], vp, direction="to_model") + + assert result["moved"] == [] + reason = result["refused"][0]["reason"] + assert "Other-Sheet" in reason + assert backend._doc.entitydb.get(line.handle).dxf.end == before, ( + "a refused entity must not be left rescaled" + ) + assert _layout_of(backend, line.handle) == "Other-Sheet" + + +async def test_one_bad_handle_does_not_rescale_the_good_ones_and_abort(backend): + """A raw exception mid-loop would leave the batch half-applied.""" + vp, _ = await _sheet_with_viewport(backend, scale=0.5) + await backend.layout_create("Other-Sheet") + await backend.layout_set_current("Other-Sheet") + stray = await backend.entity_create_line(0, 0, 10, 0) + await backend.layout_set_current("Model") + good = await backend.entity_create_line(50, 25, 150, 25) + + result = await backend.entity_change_space([stray.handle, good.handle], vp) + + assert [row["handle"] for row in result["moved"]] == [good.handle] + assert result["refused"][0]["handle"] == stray.handle + assert _layout_of(backend, good.handle) == SHEET + + +async def test_an_unknown_handle_is_reported_without_stopping_the_others(backend): + vp, _ = await _sheet_with_viewport(backend) + line = await backend.entity_create_line(50, 25, 100, 25) + + result = await backend.entity_change_space([line.handle, "DEADBEEF"], vp) + + assert [row["handle"] for row in result["moved"]] == [line.handle] + assert result["refused"][0]["handle"] == "DEADBEEF" + + +async def test_an_unknown_direction_is_refused(backend): + vp, _ = await _sheet_with_viewport(backend) + line = await backend.entity_create_line(50, 25, 100, 25) + result = await backend.entity_change_space([line.handle], vp, direction="sideways") + assert result["ok"] is False + + +async def test_no_handles_is_refused_rather_than_reported_as_success(backend): + vp, _ = await _sheet_with_viewport(backend) + result = await backend.entity_change_space([], vp) + assert result["ok"] is False + + +# ── what it flags rather than refuses ─────────────────────────────────────── + + +async def test_geometry_outside_the_viewport_is_flagged_not_refused(backend): + """AutoCAD moves it too; the caller just has to be told it left the sheet.""" + vp, _ = await _sheet_with_viewport(backend, scale=0.5) + inside = await backend.entity_create_line(50, 25, 60, 25) + outside = await backend.entity_create_line(800, 25, 900, 25) + + result = await backend.entity_change_space([inside.handle, outside.handle], vp) + + rows = {row["handle"]: row for row in result["moved"]} + assert rows[inside.handle]["inside_viewport"] is True + assert rows[inside.handle]["on_sheet"] is True + assert rows[outside.handle]["inside_viewport"] is False + assert rows[outside.handle]["on_sheet"] is False diff --git a/tests/test_com_timeout.py b/tests/test_com_timeout.py new file mode 100644 index 0000000..446ecb5 --- /dev/null +++ b/tests/test_com_timeout.py @@ -0,0 +1,132 @@ +"""``ComBackend._run`` deadline honesty — the STA executor must only be rebuilt +when *our* deadline actually expired. + +On Python 3.11+ ``TimeoutError`` **is** ``asyncio.TimeoutError`` (and an +``OSError`` subclass), so a wrapped COM call that raises ``TimeoutError`` itself +— a socket read against a dead network share, a helper that re-raises one — +arrives at ``except TimeoutError`` looking exactly like a blown deadline. Taking +it at face value told the user AutoCAD "did not respond within 60s" for a call +that failed instantly, and (far worse than the ezdxf equivalent) threw away the +live single-thread STA executor and the cached AutoCAD connection with it. + +These tests exercise the real ``_run`` — a plain ``ThreadPoolExecutor`` stands in +for the COM apartment, no AutoCAD and no win32 calls involved. +""" + +from __future__ import annotations + +import asyncio +import threading +import time +from concurrent.futures import ThreadPoolExecutor + +import pytest + +import backends.com_backend as cb +import config +from backends.com_backend import ComBackend + +pytestmark = pytest.mark.asyncio + + +@pytest.fixture +def com_backend(): + """A ComBackend wired to a real (AutoCAD-free) single-thread executor.""" + b = ComBackend() + # No initializer: _com_init would CoInitialize a real STA apartment. + b._executor = ThreadPoolExecutor(max_workers=1) + b._connected = True + yield b + if b._executor is not None: + b._executor.shutdown(wait=False) + b._executor = None + + +def _dead_share(): + """Stand-in for a COM/file call that raises TimeoutError of its own accord.""" + raise TimeoutError("the network share went away") + + +async def test_call_raising_TimeoutError_is_not_blamed_on_the_deadline(com_backend, monkeypatch): + """A TimeoutError *from the call* is an ordinary failure, not our deadline.""" + monkeypatch.setattr(config.settings, "com_call_timeout", 30) + + started = time.monotonic() + with pytest.raises(TimeoutError, match="network share") as excinfo: + await com_backend._run(_dead_share) + + assert time.monotonic() - started < 5, "it failed instantly; nothing waited 30s" + assert "did not respond" not in str(excinfo.value) + assert not isinstance(excinfo.value, RuntimeError), "the original error must survive" + + +async def test_call_raising_TimeoutError_keeps_the_sta_executor(com_backend, monkeypatch): + """Rebuilding the apartment on an ordinary error drops a live AutoCAD session.""" + monkeypatch.setattr(config.settings, "com_call_timeout", 30) + monkeypatch.setitem(cb._COM_STATE, "app", object()) + live = com_backend._executor + + with pytest.raises(TimeoutError): + await com_backend._run(_dead_share) + + assert com_backend._executor is live, "the STA executor must not be rebuilt" + assert com_backend._connected is True, "the AutoCAD connection must not be dropped" + assert "app" in cb._COM_STATE, "the cached COM app must survive an ordinary error" + assert await asyncio.wait_for(com_backend._run(lambda: "next"), timeout=5) == "next" + + +async def test_disabled_timeout_still_surfaces_a_callable_TimeoutError(com_backend, monkeypatch): + """With COM_CALL_TIMEOUT=0 there is no deadline at all to blame.""" + monkeypatch.setattr(config.settings, "com_call_timeout", 0) + live = com_backend._executor + + with pytest.raises(TimeoutError, match="network share"): + await com_backend._run(_dead_share) + + assert com_backend._executor is live + assert com_backend._connected is True + + +async def test_genuine_deadline_still_rebuilds_the_executor(com_backend, monkeypatch): + """The real overrun path is deliberate and must keep working: the worker is + stuck inside an uncancellable COM call, so it is abandoned and replaced.""" + monkeypatch.setattr(config.settings, "com_call_timeout", 0.2) + monkeypatch.setitem(cb._COM_STATE, "app", object()) + stuck = com_backend._executor + entered = threading.Event() + + def _hang(): + entered.set() + time.sleep(2.0) + return "too late" + + started = time.monotonic() + with pytest.raises(RuntimeError, match="did not respond") as excinfo: + await com_backend._run(_hang) + + assert entered.is_set() + assert time.monotonic() - started < 1.5, "must abandon the call, not wait it out" + assert isinstance(excinfo.value.__cause__, TimeoutError) + assert com_backend._executor is not stuck, "the stuck apartment must be replaced" + assert com_backend._connected is False + assert "app" not in cb._COM_STATE, "the cached app belongs to the abandoned thread" + + # The anti-deadlock guarantee: the next call runs on the fresh executor + # instead of queueing behind the still-sleeping worker. + monkeypatch.setattr(config.settings, "com_call_timeout", 10) + assert await asyncio.wait_for(com_backend._run(lambda: "fresh"), timeout=3) == "fresh" + + +async def test_ordinary_errors_are_unchanged(com_backend, monkeypatch): + """Sanity: non-timeout failures never touched the executor and still don't.""" + monkeypatch.setattr(config.settings, "com_call_timeout", 5) + live = com_backend._executor + + def _boom(): + raise ValueError("nope") + + with pytest.raises(ValueError, match="nope"): + await com_backend._run(_boom) + + assert com_backend._executor is live + assert await com_backend._run(lambda x: x * 2, 21) == 42 diff --git a/tests/test_discovery_aliases.py b/tests/test_discovery_aliases.py new file mode 100644 index 0000000..d1b5578 --- /dev/null +++ b/tests/test_discovery_aliases.py @@ -0,0 +1,210 @@ +"""Gates for the AutoCAD command / synonym alias corpus (``discovery.aliases``). + +FastMCP's ``BM25SearchTransform`` indexes only tool name + description + +parameter names + parameter descriptions (see +``fastmcp/server/transforms/search/base.py::_extract_searchable_text``). +AutoCAD command names appear in none of those for this server, so a drafter +searching for ``BPOLY`` or ``QSELECT`` scores df=0 and gets nothing back. + +These tests both *prove* that gap is real against the live registry and lock the +corpus that closes it to the live tool list, so the corpus can never silently +drift away from the registered tools. +""" + +from __future__ import annotations + +import re + +import pytest + +import server +from discovery.aliases import ( + SHARED_ACAD_COMMANDS, + TOOL_ALIASES, + ToolAliases, + alias_text, + aliases_for, +) + +# AutoCAD command tokens: uppercase alphanumerics, no spaces, at least two +# characters (single-letter aliases like "L"/"C" are deliberately excluded -- +# they are pure BM25 noise and collide with ordinary prose). +_ACAD_RE = re.compile(r"^[A-Z][A-Z0-9]+$") + +# Commands proven below to be absent from every live tool's searchable text. +# +# Deliberately NOT in this list, and why: +# FILLET -- already present: the tool is literally named ``entity_fillet``. +# XCLIP -- the server has no xref/clipping support at all; any mapping +# would misroute the search, so the corpus honestly omits it. +# REVCLOUD -- no revision-cloud tool exists; a revcloud would have to be +# hand-built from arcs, so no single tool is the right answer. +#: AutoCAD commands that appear nowhere in the live tool text, so a +#: description-only ranker scores them df=0 and returns nothing. +#: +#: BPOLY and QSELECT used to be here and were removed in v1.5.0 (M8), when +#: `boundary_trace` and `selection_filter` landed and named those commands in +#: their own docstrings. The alias corpus is no longer the *only* way to reach +#: them, and saying otherwise would overstate what the corpus is worth. Two +#: commands moving out of this list is the honest cost of the drawing tools +#: growing to cover them — the corpus still carries the rest. +DF0_COMMANDS = ( + "PEDIT", + "WBLOCK", + "OVERKILL", + "LAYTRANS", + "MATCHPROP", +) + + +async def _registered_tools() -> list: + return [t for t in await server._registered_tools() if getattr(t, "name", None)] + + +async def _registered_names() -> set[str]: + return {t.name for t in await _registered_tools()} + + +def _searchable_text(tool) -> str: + """Mirror of fastmcp's ``_extract_searchable_text`` -- what BM25 indexes.""" + parts = [tool.name] + if tool.description: + parts.append(tool.description) + schema = getattr(tool, "parameters", None) or {} + for param_name, param_info in (schema.get("properties") or {}).items(): + parts.append(param_name) + if isinstance(param_info, dict) and param_info.get("description"): + parts.append(str(param_info["description"])) + return " ".join(parts).lower() + + +def _acad_owners() -> dict[str, list[str]]: + owners: dict[str, list[str]] = {} + for tool_name, record in TOOL_ALIASES.items(): + for command in record.acad: + owners.setdefault(command, []).append(tool_name) + for names in owners.values(): + names.sort() + return owners + + +# ── coverage ──────────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_every_registered_tool_has_an_alias_record(): + """Adding a tool without an alias record must fail this gate.""" + missing = sorted(await _registered_names() - set(TOOL_ALIASES)) + assert not missing, f"tools with no alias record: {missing}" + + +@pytest.mark.asyncio +async def test_no_alias_key_is_an_unknown_tool(): + """Catches typos and tools that were renamed or removed.""" + unknown = sorted(set(TOOL_ALIASES) - await _registered_names()) + assert not unknown, f"alias keys that are not registered tools: {unknown}" + + +def test_every_record_is_a_tool_aliases_instance(): + bad = sorted(k for k, v in TOOL_ALIASES.items() if not isinstance(v, ToolAliases)) + assert not bad, f"records that are not ToolAliases: {bad}" + + +# ── shape ─────────────────────────────────────────────────────────────────── + + +def test_acad_commands_are_uppercase_and_space_free(): + bad: list[str] = [] + for tool_name, record in TOOL_ALIASES.items(): + for command in record.acad: + if not _ACAD_RE.fullmatch(command): + bad.append(f"{tool_name}:{command!r}") + assert not bad, f"malformed AutoCAD command tokens: {bad}" + + +def test_acad_commands_are_unique_within_a_record(): + bad = sorted(k for k, v in TOOL_ALIASES.items() if len(set(v.acad)) != len(v.acad)) + assert not bad, f"records with a repeated AutoCAD command: {bad}" + + +def test_synonyms_are_lowercase_non_empty_and_trimmed(): + bad: list[str] = [] + for tool_name, record in TOOL_ALIASES.items(): + for phrase in record.synonyms: + if not phrase or phrase != phrase.lower() or phrase != phrase.strip(): + bad.append(f"{tool_name}:{phrase!r}") + assert not bad, f"malformed synonyms: {bad}" + + +def test_every_tool_has_at_least_one_synonym(): + """``acad`` may legitimately be empty; a record with nothing in it is dead weight.""" + empty = sorted(k for k, v in TOOL_ALIASES.items() if not v.synonyms) + assert not empty, f"records with no synonyms: {empty}" + + +def test_synonyms_are_unique_within_a_record(): + bad = sorted(k for k, v in TOOL_ALIASES.items() if len(set(v.synonyms)) != len(v.synonyms)) + assert not bad, f"records with a repeated synonym: {bad}" + + +# ── uniqueness ────────────────────────────────────────────────────────────── +# +# Rule: one AutoCAD command routes to exactly one tool. The only exceptions are +# the commands in ``SHARED_ACAD_COMMANDS`` -- option/dialog-driven umbrella +# commands (LAYER, ZOOM, ARRAY, LAYOUT, MEASUREGEOM, SETVAR) and the two places +# where this server genuinely exposes one operation through two tools (INSERT -> +# block_insert / entity_create_block_ref, ERASE -> entity_delete / +# entity_delete_many). Anything else is a copy-paste bug. + + +def test_each_acad_command_routes_to_a_single_tool(): + unexpected = { + command: names + for command, names in _acad_owners().items() + if len(names) > 1 and command not in SHARED_ACAD_COMMANDS + } + assert not unexpected, f"AutoCAD command claimed by several tools: {unexpected}" + + +def test_shared_command_allowlist_has_no_stale_entries(): + actually_shared = {c for c, names in _acad_owners().items() if len(names) > 1} + stale = sorted(SHARED_ACAD_COMMANDS - actually_shared) + assert not stale, f"SHARED_ACAD_COMMANDS lists commands that are not shared: {stale}" + + +# ── the df=0 regression guard ─────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_key_autocad_commands_are_absent_from_live_tool_text(): + """Proves the gap: these commands score df=0 against the live BM25 corpus.""" + tools = await _registered_tools() + hits = { + command: sorted(t.name for t in tools if command.lower() in _searchable_text(t)) + for command in DF0_COMMANDS + } + leaked = {c: names for c, names in hits.items() if names} + assert not leaked, f"command already indexed -- drop it from DF0_COMMANDS: {leaked}" + + +def test_key_autocad_commands_are_covered_by_the_corpus(): + """...and proves the corpus closes it.""" + owners = _acad_owners() + missing = sorted(c for c in DF0_COMMANDS if c not in owners) + assert not missing, f"df=0 commands still unreachable via the corpus: {missing}" + + +# ── helpers ───────────────────────────────────────────────────────────────── + + +def test_aliases_for_returns_the_record_or_none(): + assert aliases_for("entity_fillet") is TOOL_ALIASES["entity_fillet"] + assert aliases_for("no_such_tool") is None + + +def test_alias_text_contains_commands_and_synonyms(): + record = TOOL_ALIASES["entity_create_circle"] + text = alias_text("entity_create_circle") + for token in (*record.acad, *record.synonyms): + assert token in text + assert alias_text("no_such_tool") == "" diff --git a/tests/test_discovery_transform.py b/tests/test_discovery_transform.py new file mode 100644 index 0000000..114d352 --- /dev/null +++ b/tests/test_discovery_transform.py @@ -0,0 +1,668 @@ +"""The discovery search transform must find tools a drafter can actually name. + +Stock ``BM25SearchTransform`` indexes tool name + description + parameter names ++ parameter descriptions and nothing else. Measured against this server's live +catalog, that means ``PEDIT``, ``WBLOCK``, ``OVERKILL``, +``LAYTRANS`` and ``MATCHPROP`` all score df=0 and return *nothing*, +and "how many entities are on the GEOMETRY layer" returns ``entity_delete_many`` +as its top hit. Both are proved here against the real catalog before anything is +asserted about the replacement. +""" + +from __future__ import annotations + +import asyncio +import json + +import pytest +from fastmcp import Client +from fastmcp.server.transforms.search import BM25SearchTransform +from fastmcp.server.transforms.search.base import serialize_tools_for_output_json + +import config +import server +from discovery.serialize import serialize_search_results +from discovery.transform import CadSearchTransform, tool_risk +from tests.data.discovery_golden import ALL_CASES, HOLDOUT_CASES, TUNING_CASES + +pytestmark = pytest.mark.asyncio + + +@pytest.fixture(scope="module") +def catalog(): + """The real, untransformed 131-tool catalog. + + Synchronous on purpose: ``_registered_tools`` only reads the in-process + component registry, and a module-scoped *async* fixture would need its own + event-loop scope under ``asyncio_mode = strict``. + """ + return asyncio.run(server._registered_tools()) + + +@pytest.fixture +def transform(): + return CadSearchTransform() + + +@pytest.fixture +def stock(): + return BM25SearchTransform() + + +def _names(tools) -> list[str]: + return [t.name for t in tools] + + +# ── the df=0 vocabulary gap ───────────────────────────────────────────────── + +# Verified against the live catalog: stock BM25 returns *zero* hits for every +# one of these. FILLET is deliberately absent — it is already indexed (df=2, +# the tool is literally named entity_fillet), so citing it would overstate the +# gap. +# +# BPOLY and QSELECT left this list in v1.5.0 (M8): `boundary_trace` and +# `selection_filter` name those commands in their own docstrings, so stock BM25 +# now finds them and the gap is genuinely closed for those two. Keeping them +# here would have made the corpus look more load-bearing than it is. The +# remaining five are still unreachable without it. +DF_ZERO_COMMANDS = [ + ("PEDIT", "entity_edit_geometry"), + ("WBLOCK", "block_create_from_entities"), + ("OVERKILL", "drawing_refine"), + ("LAYTRANS", "template_apply_layers"), + ("MATCHPROP", "entity_set_properties"), +] + + +@pytest.mark.parametrize(("command", "expected"), DF_ZERO_COMMANDS) +async def test_stock_bm25_cannot_find_these_commands_at_all(stock, catalog, command, expected): + """Guard the guard: the gap is real, not an artefact of a weak assertion.""" + assert await stock._search(catalog, command) == [] + + +@pytest.mark.parametrize(("command", "expected"), DF_ZERO_COMMANDS) +async def test_autocad_command_names_resolve(transform, catalog, command, expected): + assert _names(transform.rank(catalog, command).hits)[0] == expected + + +@pytest.mark.parametrize( + ("command", "expected"), + [ + ("DLI", "dimension_linear"), + ("REC", "entity_create_rectangle"), + ("CHA", "entity_chamfer"), + ("TR", "entity_trim"), + ("MI", "entity_mirror"), + ("XL", "construction_xline"), + ], +) +async def test_short_command_aliases_resolve(transform, catalog, command, expected): + """Two- and three-letter aliases are what drafters actually type.""" + assert _names(transform.rank(catalog, command).hits)[0] == expected + + +async def test_searchable_text_extends_the_stock_document(transform, catalog): + """The hook adds alias vocabulary; it never drops the stock text.""" + from fastmcp.server.transforms.search.base import _extract_searchable_text + + tool = next(t for t in catalog if t.name == "entity_create_polyline") + text = transform._extract_searchable_text(tool) + assert _extract_searchable_text(tool) in text + assert "PLINE" in text + assert "closed profile" in text + + +async def test_a_tool_with_no_alias_record_is_still_indexed(transform, catalog): + """An un-aliased tool must stay findable, not vanish from the index.""" + tool = next(t for t in catalog if t.name == "entity_create_line") + stripped = tool.model_copy(update={"name": "zzz_unaliased_probe"}) + hits = transform.rank([*catalog, stripped], "zzz_unaliased_probe").hits + assert _names(hits)[0] == "zzz_unaliased_probe" + + +# ── the two boosts ────────────────────────────────────────────────────────── + + +async def test_an_exact_command_outranks_tools_that_share_a_description_word(transform, catalog): + """ "CIRCLE" is the command; a dozen tools merely mention circles.""" + hits = _names(transform.rank(catalog, "CIRCLE", limit=5).hits) + assert hits[0] == "entity_create_circle" + + +async def test_a_command_name_that_is_also_an_english_word_stays_english(transform, catalog): + """LAYER is a command *and* a noun. + + Boosting on a bare lowercase "layer" made layer_modify / layer_list / + layer_create the top three for this query and pushed layer_delete out of + the answer entirely — exactly backwards. Measured regression, not a + hypothetical. + """ + hits = _names(transform.rank(catalog, "delete the layer called GEOMETRY", limit=8).hits) + assert "layer_delete" in hits + assert not {"layer_create", "layer_modify", "layer_list"} & set(hits[:3]) + + +async def test_the_same_command_in_capitals_is_treated_as_a_command(transform, catalog): + """The other half of the rule: LAYER typed as a command routes to LAYER tools.""" + hits = set(_names(transform.rank(catalog, "LAYER", limit=5).hits)) + assert {"layer_create", "layer_list", "layer_modify"} <= hits + + +async def test_a_lone_lowercase_command_still_counts_as_a_command(transform, catalog): + """Nobody shouts when the command is the whole message.""" + assert _names(transform.rank(catalog, "bpoly").hits)[0] == "boundary_trace" + + +async def test_articles_do_not_break_synonym_phrase_matching(transform, catalog): + """The corpus says "delete a layer"; users say "delete the layer".""" + assert "layer_delete" in _names(transform.rank(catalog, "delete the layer", limit=5).hits) + + +# ── the risk filter ───────────────────────────────────────────────────────── + +COUNTING_QUERY = "how many entities are on the GEOMETRY layer" + + +async def test_stock_bm25_answers_a_counting_question_with_destructive_tools(stock, catalog): + """Guard the guard: the destructive-hit problem is measured, not assumed. + + This asserted the top hit was ``entity_delete_many`` until + ``analysis_measure_entity`` was added and displaced it to second. The + ranking order is incidental; what the risk ceiling exists to prevent is a + read-only question being answered with tools that destroy geometry, and that + is still exactly what stock BM25 does here. + """ + hits = await stock._search(catalog, COUNTING_QUERY) + risky = [t.name for t in hits if tool_risk(t) == "destructive"] + assert risky, f"expected destructive hits among {_names(hits)}" + assert "entity_delete_many" in risky + + +@pytest.mark.parametrize( + ("name", "expected"), + [ + ("drawing_info", "read"), + ("analysis_entity_stats", "read"), + ("entity_create_line", "write"), + ("entity_delete_many", "destructive"), + ("layer_delete", "destructive"), + ("transaction_rollback", "destructive"), + ], +) +async def test_risk_is_derived_from_the_mcp_annotations(catalog, name, expected): + tool = next(t for t in catalog if t.name == name) + assert tool_risk(tool) == expected + + +async def test_read_ceiling_keeps_destructive_tools_out_of_a_counting_answer(transform, catalog): + outcome = transform.rank(catalog, COUNTING_QUERY, risk="read") + assert [tool_risk(t) for t in outcome.hits] == ["read"] * len(outcome.hits) + assert "analysis_entity_stats" in _names(outcome.hits) + + +async def test_write_ceiling_allows_mutation_but_not_destruction(transform, catalog): + outcome = transform.rank(catalog, "delete the entities on the GEOMETRY layer", risk="write") + assert "destructive" not in {tool_risk(t) for t in outcome.hits} + + +async def test_the_filter_frees_slots_rather_than_shrinking_the_answer(transform, catalog): + """Filtering must promote lower-ranked safe tools, not just delete rows.""" + unfiltered = transform.rank(catalog, COUNTING_QUERY, limit=5) + filtered = transform.rank(catalog, COUNTING_QUERY, limit=5, risk="read") + assert len(unfiltered.hits) == 5 + assert len(filtered.hits) == 5 + assert set(_names(filtered.hits)) - set(_names(unfiltered.hits)) + + +async def test_the_filter_records_exactly_what_it_hid(transform, catalog): + outcome = transform.rank(catalog, COUNTING_QUERY, limit=5, risk="read") + assert "entity_delete_many" in _names(outcome.hidden) + # Only tools the caller would otherwise have seen — not the whole catalog. + assert len(outcome.hidden) <= 5 + + +async def test_nothing_is_recorded_as_hidden_when_the_filter_is_off(transform, catalog): + assert transform.rank(catalog, COUNTING_QUERY, limit=5).hidden == () + + +# ── the risk filter must be visible in the payload ────────────────────────── + + +class _FakeServer: + def __init__(self, tools): + self._tools = list(tools) + + async def list_tools(self, run_middleware: bool = True): + return list(self._tools) + + +class _FakeContext: + """Just enough Context for ``_get_visible_tools`` to reach the catalog.""" + + def __init__(self, tools): + self.fastmcp = _FakeServer(tools) + + +async def _search_payload(transform, catalog, query: str, **kwargs): + tool = transform._make_search_tool() + return await tool.fn(query=query, ctx=_FakeContext(catalog), **kwargs) + + +async def test_the_payload_says_when_the_risk_filter_removed_hits(transform, catalog): + payload = await _search_payload(transform, catalog, COUNTING_QUERY, risk="read") + notice = [line for line in payload.splitlines() if line.startswith("Risk filter:")] + assert len(notice) == 1 + assert "entity_delete_many" in notice[0] + assert 'risk="any"' in notice[0] + + +async def test_a_mixed_intent_is_never_silently_halved(transform, catalog): + """ "list the layers then delete GEOMETRY" is half a read and half a delete.""" + payload = await _search_payload( + transform, catalog, "list the layers then delete GEOMETRY", risk="read" + ) + assert "layer_list" in payload + notice = next(line for line in payload.splitlines() if line.startswith("Risk filter:")) + assert "entity_delete" in notice + + +async def test_no_notice_when_the_filter_removed_nothing(transform, catalog): + payload = await _search_payload(transform, catalog, COUNTING_QUERY, risk="any") + assert "Risk filter:" not in payload + + +async def test_a_structured_serializer_gets_the_notice_as_a_sibling_key(catalog): + """There is no line to append to when the serializer returns JSON.""" + structured = CadSearchTransform(search_result_serializer=serialize_tools_for_output_json) + payload = await _search_payload(structured, catalog, COUNTING_QUERY, risk="read") + assert isinstance(payload["results"], list) + assert "entity_delete_many" in payload["notice"] + + +async def test_a_structured_serializer_returns_bare_results_when_nothing_was_hidden(catalog): + structured = CadSearchTransform(search_result_serializer=serialize_tools_for_output_json) + payload = await _search_payload(structured, catalog, COUNTING_QUERY, risk="any") + assert isinstance(payload, list) + + +# ── the search tool's own schema ──────────────────────────────────────────── + + +async def test_search_tool_advertises_limit_and_risk(transform): + properties = transform._make_search_tool().parameters["properties"] + assert set(properties) == {"query", "limit", "risk"} + assert properties["risk"]["enum"] == ["any", "write", "read"] + assert properties["limit"]["default"] == transform._max_results + + +@pytest.mark.parametrize(("asked", "served"), [(999, 25), (0, 1), (-4, 1), (3, 3)]) +async def test_limit_is_clamped_to_a_usable_range(transform, catalog, asked, served): + outcome = transform.rank(catalog, "line", limit=asked) + assert len(outcome.hits) == served + + +async def test_an_unknown_risk_value_is_rejected_rather_than_ignored(transform, catalog): + with pytest.raises(ValueError, match="risk"): + transform.rank(catalog, "line", risk="mostly-harmless") + + +# ── the compact serializer ────────────────────────────────────────────────── + + +def _tools(catalog, *names): + by_name = {t.name: t for t in catalog} + return [by_name[n] for n in names] + + +async def test_one_card_per_tool_on_one_line_each(catalog): + tools = _tools(catalog, "entity_create_circle", "entity_delete", "drawing_info") + lines = serialize_search_results(tools).splitlines() + assert len(lines) == 3 + assert lines[0].startswith("entity_create_circle") + + +async def test_a_card_carries_name_summary_and_required_params(catalog): + (line,) = serialize_search_results(_tools(catalog, "entity_create_circle")).splitlines() + assert "entity_create_circle" in line + assert "req: cx, cy, radius" in line + + +async def test_a_card_marks_the_risk_level(catalog): + text = serialize_search_results( + _tools(catalog, "drawing_info", "entity_create_circle", "entity_delete") + ) + assert "drawing_info (read)" in text + assert "entity_create_circle (write)" in text + assert "entity_delete (destructive)" in text + + +async def test_a_card_carries_the_autocad_command(catalog): + (line,) = serialize_search_results(_tools(catalog, "entity_chamfer")).splitlines() + assert "acad: CHAMFER" in line + + +async def test_the_cad_meta_summary_wins_over_the_docstring(catalog): + """@cad_tool exists to give a hit a hand-written one-liner.""" + tool = next(t for t in catalog if t.name == "transaction_begin") + summary = (tool.meta or {})["cad"]["summary"] + assert summary in serialize_search_results([tool]) + + +async def test_the_first_docstring_line_is_the_fallback_summary(catalog): + """A card still reads well for a tool that carries no @cad_tool summary. + + The fallback is exercised by stripping the meta channel off a real tool + rather than by naming one that happens to lack it: the @cad_tool rollout + is filling the catalog in, so "a tool without cad meta" is not a stable + fixture, while the serializer's fallback branch has to keep working for + any tool a transform hands it (a mounted server's, say). + """ + tool = next(t for t in catalog if t.name == "entity_create_circle") + bare = tool.model_copy(update={"meta": None}) + first_line = bare.description.strip().splitlines()[0] + assert first_line in serialize_search_results([bare]) + + +async def test_a_long_summary_is_truncated_on_a_word_boundary(catalog): + tool = next(t for t in catalog if t.name == "entity_create_circle") + verbose = tool.model_copy(update={"description": "word " * 200, "meta": None}) + (line,) = serialize_search_results([verbose]).splitlines() + assert len(line) < 250 + assert line.count("...") == 1 + + +async def test_a_zero_argument_tool_says_so(catalog): + (line,) = serialize_search_results(_tools(catalog, "drawing_info")).splitlines() + assert "req: (none)" in line + + +async def test_an_empty_result_set_is_explicit(): + assert serialize_search_results([]) == "No tools matched the query." + + +# Budgets read off the measured output, not chosen first and enforced onto the +# renderer. Over the 15 REALISTIC_QUERIES below at limit=5 the worst rendered +# payload measured 798 characters and the worst single card in the whole +# 131-tool catalog measured 191 (p50 123, p90 160). The thresholds sit ~30% +# above those so an added tool with a long docstring does not turn into a +# spurious CI failure — a real regression would be a multiple, not a nudge. +MAX_CARD_CHARS = 256 +MAX_PAYLOAD_CHARS = 1024 + +REALISTIC_QUERIES = [ + "FILLET", + "BPOLY", + "round the corner", + "bill of materials", + "how many entities are on the GEOMETRY layer", + "draw a hole in the plate", + "put a leader note on the chamfer", + "dimension everything with a chain", + "make a title block", + "save the drawing as a pdf", + "select every circle on the GEOMETRY layer", + "gear", + "keyway in the hub bore", + "screenshot", + "check the drawing before finalising", +] + + +async def test_no_single_card_exceeds_the_measured_budget(catalog): + """Checked over every registered tool, not just the ones a query happens to hit.""" + worst = max(catalog, key=lambda tool: len(serialize_search_results([tool]))) + assert len(serialize_search_results([worst])) <= MAX_CARD_CHARS, worst.name + + +@pytest.mark.parametrize("query", REALISTIC_QUERIES) +async def test_a_five_hit_payload_stays_inside_the_measured_budget(transform, catalog, query): + hits = transform.rank(catalog, query, limit=5).hits + assert len(serialize_search_results(hits)) <= MAX_PAYLOAD_CHARS + + +async def test_the_serializer_is_far_smaller_than_stock_json(transform, catalog): + """Payload size is a real gain, but it is *not* the differentiator. + + Stock BM25 with this same serializer would close most of the gap; what it + cannot do is find BPOLY at all. Asserted as a ratio so it stays honest. + """ + hits = transform.rank(catalog, "round the corner", limit=5).hits + compact = len(serialize_search_results(hits)) + stock_json = len(json.dumps(serialize_tools_for_output_json(hits))) + assert compact < stock_json / 5 + + +# ── the golden set ────────────────────────────────────────────────────────── + +TOP_K = 3 + +# Measured on the tuning set with the scorer frozen: top-1 47/51 (92.2%), +# top-3 50/51 (98.0%). The bars sit below those, not on them. +TUNING_TOP1_BAR = 0.85 +TUNING_TOP3_BAR = 0.94 + +# Declared *before* the holdout set was measured for the first time, and not +# revised afterwards. Lower than the tuning bar on purpose: a ranker that +# scores the same on data it never saw is the exception, not the expectation. +HOLDOUT_TOP3_BAR = 0.80 + +# A tuning case that fails for a reason this module cannot fix. "smaller" and +# "by half" appear nowhere in discovery/aliases.py (which has "shrink" but not +# "smaller"), so no scoring change reaches entity_scale — only a corpus edit +# does, and the corpus is a separate deliverable. Left in the set rather than +# quietly deleted: it is the honest ceiling of an alias-driven approach. +KNOWN_CORPUS_GAPS = {"make the whole shape smaller by half"} + + +def _tuning_params(): + return [ + pytest.param( + case, + marks=( + [pytest.mark.xfail(reason="vocabulary missing from discovery/aliases.py")] + if case.query in KNOWN_CORPUS_GAPS + else [] + ), + id=str(case), + ) + for case in TUNING_CASES + ] + + +async def _accuracy(transform, stock, catalog, cases): + """(mine_top1, mine_topk, stock_top1, stock_topk, misses) over ``cases``.""" + mine1 = mine_k = stock1 = stock_k = 0 + misses = [] + for case in cases: + # risk="any" for both, so the A/B never credits the risk filter. + ours = _names(transform.rank(catalog, case.query, limit=5).hits) + theirs = _names(await stock._search(catalog, case.query)) + mine1 += ours[:1] == [case.expect] + stock1 += theirs[:1] == [case.expect] + if case.expect in ours[:TOP_K]: + mine_k += 1 + else: + misses.append(f"{case.query!r} want={case.expect} got={ours[:TOP_K]}") + stock_k += case.expect in theirs[:TOP_K] + return mine1, mine_k, stock1, stock_k, misses + + +@pytest.mark.parametrize("case", _tuning_params()) +async def test_tuning_case_finds_its_tool(transform, catalog, case): + hits = _names(transform.rank(catalog, case.query, limit=5).hits) + assert case.expect in hits[:TOP_K], hits + + +async def test_tuning_accuracy_holds_its_measured_bar(transform, stock, catalog): + mine1, mine_k, _, _, misses = await _accuracy(transform, stock, catalog, TUNING_CASES) + n = len(TUNING_CASES) + assert mine1 / n >= TUNING_TOP1_BAR, f"top-1 {mine1}/{n}" + assert mine_k / n >= TUNING_TOP3_BAR, f"top-{TOP_K} {mine_k}/{n}; missed {misses}" + + +async def test_holdout_accuracy_clears_the_bar_declared_before_it_was_measured( + transform, stock, catalog +): + _, mine_k, _, _, misses = await _accuracy(transform, stock, catalog, HOLDOUT_CASES) + n = len(HOLDOUT_CASES) + assert mine_k / n >= HOLDOUT_TOP3_BAR, f"top-{TOP_K} {mine_k}/{n}; missed {misses}" + + +async def test_it_beats_stock_bm25_on_the_whole_golden_set(transform, stock, catalog): + """The A/B. Both sides run unfiltered, so this measures vocabulary and + ranking only — none of the credit goes to the risk filter. + + Scored on top-1, not a top-`TOP_K` ratio. A ratio between two scores over a + shared corpus is not a property of this transform: improving an unrelated + tool's docstring adds vocabulary to *both* indexes, so a `mine_k >= 2 * + stock_k` assertion moves when stock gets **better**, which is a good outcome + being reported as a regression. It fired exactly that way when the undo/redo + docstrings were written. Top-1 is also the number that matters — a search + tool returns one answer to act on. + """ + mine1, mine_k, stock1, stock_k, _ = await _accuracy(transform, stock, catalog, ALL_CASES) + n = len(ALL_CASES) + report = ( + f"top-1 mine {mine1}/{n} vs stock {stock1}/{n}; " + f"top-{TOP_K} mine {mine_k}/{n} vs stock {stock_k}/{n}" + ) + assert mine1 >= 2 * stock1, report + assert mine_k > stock_k, report + + +async def test_stock_bm25_cannot_answer_a_fifth_of_the_set_at_all(transform, stock, catalog): + """The thesis, stated as the thing no amount of tuning can change. + + The corpus gap is *data*, not parameters: AutoCAD command names appear in + none of the tool descriptions, so those queries score df=0 and stock returns + an empty list. No serializer and no BM25 constant invents vocabulary that is + not in the index — which is why the alias corpus, not the ranking, is what + this transform actually contributes. + """ + empty_stock = [c.query for c in ALL_CASES if not await stock._search(catalog, c.query)] + empty_mine = [c.query for c in ALL_CASES if not transform.rank(catalog, c.query, limit=5).hits] + + assert len(empty_stock) >= 15, f"only {len(empty_stock)} empty — has the corpus changed?" + assert empty_mine == [], f"the alias corpus must cover every case; empty: {empty_mine}" + + +COUNTING_CASES = [case for case in ALL_CASES if case.kind == "counting"] + + +@pytest.mark.parametrize("case", COUNTING_CASES, ids=str) +async def test_a_counting_question_never_answers_with_a_destructive_tool(transform, catalog, case): + outcome = transform.rank(catalog, case.query, limit=5, risk=case.risk) + assert case.risk == "read" + assert "destructive" not in {tool_risk(t) for t in outcome.hits} + assert case.expect in _names(outcome.hits)[:TOP_K] + + +async def test_the_risk_ceiling_is_what_removes_the_delete_tool(transform, catalog): + """Not the ranking — the ranking still surfaces it, and says that it did.""" + query = "how many entities are on the GEOMETRY layer" + assert "entity_delete_many" in _names(transform.rank(catalog, query, limit=5).hits) + filtered = transform.rank(catalog, query, limit=5, risk="read") + assert "entity_delete_many" not in _names(filtered.hits) + assert "entity_delete_many" in filtered.notice() + + +# ── DISCOVERY_MODE ────────────────────────────────────────────────────────── + + +@pytest.fixture(autouse=True) +def _restore_discovery_mode(): + """Attaching a transform mutates the shared server; always put it back.""" + yield + server._apply_discovery_mode("off") + + +async def test_discovery_is_off_by_default(monkeypatch): + """v1.5.0 ships the flag, not the switch: turning it on is a wire-level + breaking change for every connected client and needs its own migration.""" + monkeypatch.delenv("DISCOVERY_MODE", raising=False) + assert config.Settings().discovery_mode == "off" + + +async def test_the_env_var_selects_search_mode(monkeypatch): + monkeypatch.setenv("DISCOVERY_MODE", "SEARCH") + assert config.Settings().discovery_mode == "search" + + +async def test_off_leaves_the_whole_catalog_on_the_wire(): + server._apply_discovery_mode("off") + async with Client(server.mcp) as client: + names = {tool.name for tool in await client.list_tools()} + assert len(names) > 100 + assert "search_tools" not in names + + +async def test_search_replaces_the_catalog_with_the_two_synthetic_tools(): + server._apply_discovery_mode("search") + async with Client(server.mcp) as client: + names = {tool.name for tool in await client.list_tools()} + assert names == {"search_tools", "call_tool"} + + +async def test_search_mode_is_reversible_and_attaches_only_once(): + server._apply_discovery_mode("search") + server._apply_discovery_mode("search") + server._apply_discovery_mode("off") + async with Client(server.mcp) as client: + names = {tool.name for tool in await client.list_tools()} + assert len(names) > 100 + + +async def test_an_unknown_discovery_mode_falls_back_to_off(): + assert server._apply_discovery_mode("semantic-vector-magic") == "off" + async with Client(server.mcp) as client: + names = {tool.name for tool in await client.list_tools()} + assert len(names) > 100 + + +async def test_the_search_tool_answers_on_the_wire(): + """End to end: a real client, a real AutoCAD command, a real payload.""" + server._apply_discovery_mode("search") + async with Client(server.mcp) as client: + result = await client.call_tool("search_tools", {"query": "BPOLY"}) + assert "boundary_trace" in result.content[0].text + + +async def test_the_risk_notice_survives_to_the_wire(): + server._apply_discovery_mode("search") + async with Client(server.mcp) as client: + result = await client.call_tool( + "search_tools", + {"query": "how many entities are on the GEOMETRY layer", "risk": "read"}, + ) + text = result.content[0].text + assert "analysis_entity_stats" in text + assert "Risk filter:" in text + assert "entity_delete_many" in text + + +async def test_search_cannot_surface_a_tool_the_profile_disabled(): + """Capability-aware discovery has to survive the transform. + + The 3D solid tools are registered but disabled unless ENABLE_3D=true, so + searching for one must come back empty rather than advertising a call that + would only be rejected. This works because the search reads the *filtered* + catalog through _get_visible_tools, not the raw registry. + """ + server._apply_discovery_mode("search") + async with Client(server.mcp) as client: + result = await client.call_tool("search_tools", {"query": "BOX", "limit": 10}) + assert "solid_box" not in result.content[0].text + + +async def test_limit_reaches_the_wire(): + server._apply_discovery_mode("search") + async with Client(server.mcp) as client: + result = await client.call_tool("search_tools", {"query": "layer", "limit": 2}) + assert len(result.content[0].text.splitlines()) == 2 + + +async def test_search_mode_does_not_shrink_what_the_server_reports_about_itself(): + """system_about reads the untransformed registry; prove it under the transform.""" + server._apply_discovery_mode("search") + assert await server._registered_tool_count() > 100 + assert len(await server._tool_groups()) > 5 diff --git a/tests/test_drawing.py b/tests/test_drawing.py index f447957..415facb 100644 --- a/tests/test_drawing.py +++ b/tests/test_drawing.py @@ -4,6 +4,8 @@ from __future__ import annotations import pytest +import config + pytestmark = pytest.mark.asyncio @@ -46,14 +48,33 @@ async def test_drawing_purge(backend): async def test_drawing_audit(backend): result = await backend.drawing_audit() assert result.get("ok") is True + # Asserting only `ok` is why the audit debt survived to 1.5.0: the tool + # repaired documents and reported nothing about it, and this test was green + # throughout. The contract is the whole key set (see backends/base.py). + assert {"ok", "repaired", "fixes", "fix_count", "errors", "error_count"} <= result.keys() -async def test_drawing_undo(backend): +async def test_drawing_undo(backend, monkeypatch): + # `assert isinstance(result, dict)` used to be the whole test, and it passed + # for years against an undo that could only ever fail. See + # tests/test_undo_redo.py for the full contract. + monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 4) + await backend.drawing_new() await backend.entity_create_line(0, 0, 100, 0) + result = await backend.drawing_undo() - assert isinstance(result, dict) + + assert result["ok"] is True + assert len(list(backend._doc.modelspace())) == 0 -async def test_drawing_redo(backend): +async def test_drawing_redo(backend, monkeypatch): + monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 4) + await backend.drawing_new() + await backend.entity_create_line(0, 0, 100, 0) + await backend.drawing_undo() + result = await backend.drawing_redo() - assert isinstance(result, dict) + + assert result["ok"] is True + assert len(list(backend._doc.modelspace())) == 1 diff --git a/tests/test_entity_measure.py b/tests/test_entity_measure.py new file mode 100644 index 0000000..01ad0fe --- /dev/null +++ b/tests/test_entity_measure.py @@ -0,0 +1,267 @@ +"""T0.5 — ``entity_measure``: ask the drawing, not the model's memory. + +Companion to ``tests/test_measure.py``, which covers the arithmetic. This file +covers the part that was actually missing: addressing an entity **by handle**. + +Before this, neither backend could answer "what is the area of the polyline I +just drew". ``analysis_measure_area`` shoelaced points the caller supplied, and +the live COM backend did the same — despite having a document, a +``HandleToObject`` lookup it already used fourteen times, and an ActiveX ``Area`` +property sitting right there. + +Two distinctions here are load-bearing and are asserted, not assumed: + +* A LINE has no area on *any* engine, so that is a plain error — tagging it with + a ``capability`` would falsely imply "switch backends and this works". +* A REGION has no area on the *headless* engine only, so that is a capability + refusal naming the live backend as the way forward. +""" + +from __future__ import annotations + +import math + +import pytest + +from backends.base import UnsupportedCapabilityError +from backends.com_backend import ComBackend +from backends.ezdxf_backend import EzdxfBackend + +pytestmark = pytest.mark.asyncio + +#: 100x100 square, bottom edge bulged outward into a semicircle of radius 50. +BULGED_SQUARE = [(0, 0, 0, 0, 0.0), (100, 0, 0, 0, 1.0), (100, 100, 0, 0, 0.0), (0, 100, 0, 0, 0.0)] +TRUE_AREA = 10000.0 + math.pi * 50.0**2 / 2.0 +TRUE_PERIMETER = 300.0 + math.pi * 50.0 + + +def _add_bulged_square(backend, close=True): + pline = backend._doc.modelspace().add_lwpolyline(BULGED_SQUARE, format="xyseb", close=close) + return pline.dxf.handle + + +# ── the capability that did not exist ─────────────────────────────────────── + + +async def test_measures_a_bulged_polyline_exactly(backend): + """The 28.2% the reported-points workflow lost.""" + handle = _add_bulged_square(backend) + + result = await backend.entity_measure(handle) + + assert result["handle"] == handle + assert result["type"] == "LWPOLYLINE" + assert result["area"] == pytest.approx(TRUE_AREA, rel=1e-9) + assert result["perimeter"] == pytest.approx(TRUE_PERIMETER, rel=1e-9) + assert result["method"] == "analytic_bulge" + assert result["exact"] is True + assert result["flatten_tolerance"] is None + assert result["closed"] is True + assert result["assumed_closed"] is False + assert result["self_intersecting"] is False + assert result["backend"] == "ezdxf" + + +async def test_a_straight_polyline_reports_the_plain_analytic_method(backend): + pline = backend._doc.modelspace().add_lwpolyline( + [(0, 0), (10, 0), (10, 10), (0, 10)], close=True + ) + result = await backend.entity_measure(pline.dxf.handle) + + assert result["area"] == pytest.approx(100.0) + assert result["perimeter"] == pytest.approx(40.0) + assert result["method"] == "analytic" + assert result["exact"] is True + + +async def test_measures_a_circle(backend): + circle = await backend.entity_create_circle(5, 5, 4) + result = await backend.entity_measure(circle.handle) + + assert result["area"] == pytest.approx(math.pi * 16.0) + assert result["perimeter"] == pytest.approx(2 * math.pi * 4.0) + assert result["method"] == "analytic" + assert result["exact"] is True + assert result["closed"] is True + + +async def test_an_open_boundary_says_it_was_closed(backend): + """AutoCAD's AREA closes an open boundary; the payload must admit it.""" + handle = _add_bulged_square(backend, close=False) + + result = await backend.entity_measure(handle) + + assert result["closed"] is False + assert result["assumed_closed"] is True + assert result["area"] == pytest.approx(TRUE_AREA, rel=1e-9) + + +async def test_a_self_intersecting_polyline_is_flagged(backend): + """Shoelace cancels crossed lobes, so the 0.0 has to come with a warning.""" + pline = backend._doc.modelspace().add_lwpolyline( + [(0, 0), (100, 100), (100, 0), (0, 100)], close=True + ) + result = await backend.entity_measure(pline.dxf.handle) + + assert result["self_intersecting"] is True + + +# ── the two kinds of "no" ─────────────────────────────────────────────────── + + +async def test_a_line_has_no_area_on_any_engine_so_it_is_not_a_capability_gap(backend): + line = await backend.entity_create_line(0, 0, 10, 0) + + with pytest.raises(RuntimeError) as excinfo: + await backend.entity_measure(line.handle) + + assert not isinstance(excinfo.value, UnsupportedCapabilityError), ( + "tagging this `capability` would tell the user to switch backends, " + "where no backend can give a LINE an area" + ) + assert "LWPOLYLINE" in str(excinfo.value), "the refusal must list what IS measurable" + + +async def test_a_region_is_a_real_capability_boundary(backend): + """ezdxf stores ACIS opaquely and cannot evaluate it; live AutoCAD can.""" + region = backend._doc.modelspace().new_entity("REGION", {}) + + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await backend.entity_measure(region.dxf.handle) + + assert excinfo.value.capability == "measure_area_acis" + assert "com" in str(excinfo.value).lower(), "a refusal must name the way forward" + + +async def test_measure_capability_is_declared_on_both_backends(): + assert EzdxfBackend().capabilities().features["measure_area_acis"].supported is False + assert ComBackend().capabilities().features["measure_area_acis"].supported is True + + +# ── HATCH: the type the refusal advertised and then refused ───────────────── +# +# `_MEASURABLE_TYPES` named HATCH, so a caller who asked for a hatch's area got +# a RuntimeError whose own text listed HATCH as measurable. Whichever half was +# right, the pair could not both be — and a hatch is the one entity whose area +# a drafter actually asks for, because it is the section they have to fill. + + +def _hatch_with_island(backend, *, style: int = 0): + """20x20 outer square, 10x10 island. Filled area is 400 - 100 = 300.""" + hatch = backend._doc.modelspace().add_hatch(color=1) + hatch.dxf.hatch_style = style + hatch.paths.add_polyline_path( + [(0, 0), (20, 0), (20, 20), (0, 20)], is_closed=True, flags=1 | 16 + ) + island = hatch.paths.add_edge_path(flags=0) + island.add_line((5, 5), (15, 5)) + island.add_line((15, 5), (15, 15)) + island.add_line((15, 15), (5, 15)) + island.add_line((5, 15), (5, 5)) + return hatch.dxf.handle + + +async def test_a_hatch_reports_the_area_it_actually_fills(backend): + handle = _hatch_with_island(backend) + + result = await backend.entity_measure(handle) + + assert result["type"] == "HATCH" + assert result["area"] == pytest.approx(300.0, abs=1e-6), ( + "the island is a hole in the fill; counting it is the 33% error" + ) + assert result["loop_count"] == 2 + assert result["hatch_style"] == "normal" + assert result["exact"] is True, "every edge here is a straight line — nothing was flattened" + + +async def test_hatch_style_ignore_fills_straight_over_the_island(backend): + """AutoCAD's HPISLAND=2 fills the island too. The number must follow.""" + handle = _hatch_with_island(backend, style=2) + + result = await backend.entity_measure(handle) + + assert result["area"] == pytest.approx(400.0, abs=1e-6) + assert result["hatch_style"] == "ignore" + + +async def test_a_nested_island_alternates_under_the_normal_style(backend): + """Depth 2 is where `normal` and `outer` stop agreeing, so it is the only + case that proves the depths were computed rather than assumed.""" + hatch = backend._doc.modelspace().add_hatch(color=1) + hatch.dxf.hatch_style = 0 # normal + hatch.paths.add_polyline_path( + [(0, 0), (20, 0), (20, 20), (0, 20)], is_closed=True, flags=1 | 16 + ) + hatch.paths.add_polyline_path([(4, 4), (16, 4), (16, 16), (4, 16)], is_closed=True, flags=0) + hatch.paths.add_polyline_path([(8, 8), (12, 8), (12, 12), (8, 12)], is_closed=True, flags=0) + + result = await backend.entity_measure(hatch.dxf.handle) + + # 400 - 144 + 16: the innermost loop is filled again. + assert result["area"] == pytest.approx(272.0, abs=1e-6) + assert result["loop_count"] == 3 + + +async def test_a_curved_hatch_edge_says_it_was_flattened(backend): + hatch = backend._doc.modelspace().add_hatch(color=1) + hatch.paths.add_edge_path(flags=1 | 16).add_arc((0, 0), 10, 0, 360) + + result = await backend.entity_measure(hatch.dxf.handle, flatten_tolerance=1e-5) + + assert result["area"] == pytest.approx(math.pi * 100.0, rel=1e-3) + assert result["exact"] is False, "a flattened arc is not an exact area" + assert result["flatten_tolerance"] == 1e-5 + + +async def test_tightening_the_tolerance_does_not_make_a_curved_hatch_exact(backend): + """The floor is the arc-to-Bezier conversion, not the flattening. + + A caller who sees `flatten_tolerance` naturally assumes a smaller number + buys a better area, all the way to the truth. For hatch edges it does not: + ezdxf hands the boundary over as cubic Beziers, and tightening the + tolerance converges on the *Bezier's* area, which sits ~0.028% above the + circle it stands in for. The residual does not shrink with the tolerance, + which is why `exact` is False rather than the tolerance being presented as + the accuracy knob. + """ + hatch = backend._doc.modelspace().add_hatch(color=1) + hatch.paths.add_edge_path(flags=1 | 16).add_arc((0, 0), 10, 0, 360) + + true_area = math.pi * 100.0 + coarse = await backend.entity_measure(hatch.dxf.handle, flatten_tolerance=1e-3) + fine = await backend.entity_measure(hatch.dxf.handle, flatten_tolerance=1e-7) + finest = await backend.entity_measure(hatch.dxf.handle, flatten_tolerance=1e-9) + + # Four orders of magnitude of extra tolerance buy nothing after the first. + assert finest["area"] == pytest.approx(fine["area"], rel=1e-7) + residual = abs(finest["area"] - true_area) / true_area + assert residual > 2e-4, "the Bezier error survives any tolerance" + assert abs(coarse["area"] - true_area) / true_area < residual, ( + "the coarse run is only closer by accident — an inscribed chord error " + "that happens to cancel part of the Bezier overshoot" + ) + assert finest["exact"] is False + + +# ── entity_get stops dropping the curve ───────────────────────────────────── + + +async def test_entity_get_reports_area_and_length_for_a_closed_polyline(backend): + """``props["length"] = ent.length()`` was dead code — LWPolyline has no + ``length()``, so the AttributeError went to a debug log and the key silently + never appeared.""" + handle = _add_bulged_square(backend) + + info = await backend.entity_get(handle) + + assert info.properties["area"] == pytest.approx(TRUE_AREA, rel=1e-9) + assert info.properties["length"] == pytest.approx(TRUE_PERIMETER, rel=1e-9) + + +async def test_entity_get_still_reports_only_xy_points(backend): + """The bulge feeds the measurement; it must not change the points contract.""" + handle = _add_bulged_square(backend) + info = await backend.entity_get(handle) + + assert all(len(point) == 2 for point in info.properties["points"]) diff --git a/tests/test_entity_properties.py b/tests/test_entity_properties.py new file mode 100644 index 0000000..dc48c31 --- /dev/null +++ b/tests/test_entity_properties.py @@ -0,0 +1,325 @@ +"""F2 — LIST: the raw DXF attribute set for one handle. + +`entity_get` reports a *curated* view: the handful of derived fields the drafting +tools need, already flattened to WCS xy. `entity_measure` reports lengths and +areas. Neither answers the question AutoCAD's LIST answers — "what is actually +stored on this object?" — and that is the only reason this tool exists. It must +not restate the other two. + +Four measured facts shape every test below. + +* **The dump is not JSON-serialisable as it comes out of ezdxf.** + `entity.dxfattribs()` returns `Vec3` for `start` / `end` / `insert` / `center`. + Measured: `json.dumps` raises ``Object of type Vec3 is not JSON serializable`` + for LINE, CIRCLE, TEXT and INSERT. A tool that hands that dict back either dies + at the wire boundary or gets silently coerced into `"Vec3(0.0, 0.0, 0.0)"` — + a string where the caller expected a point. So `json.dumps(result)` is asserted + outright, for all five point-carrying types, and the values are asserted to + still be *numbers* afterwards: stringifying the Vec3 would satisfy `json.dumps` + and destroy the payload. + +* **The dump is WCS, like everything else crossing a tool boundary.** + A mirrored CIRCLE stores `center = (30, 20)` with `extrusion = (0, 0, -1)`; + it is drawn at (-30, 20), which is what `entity_get` reports. A raw + `dxfattribs()` echo therefore contradicts `entity_get` in the same payload by + 60 mm — the exact defect T0.4 was opened to remove. The dump still reports the + `extrusion` it translated *from*, because a caller who cannot see the frame + cannot tell a translated point from an untranslated one. + +* **`dxfattribs()` on an LWPOLYLINE contains no geometry at all.** + Measured: the keys are `flags`, `handle`, `layer`, `owner` — the vertices live + in `get_points()`, not in the attribute dict. A tool that dumps `dxfattribs()` + and stops reports a polyline with no points and calls it a full property dump. + The vertices must carry their **bulge** too: `entity_get` gives bare `[x, y]` + pairs, and reconstructing a bulged outline from those loses 28.2% of the area. + +* **Only stored attributes are reported.** `thickness` is absent from a fresh + LINE and appears once set. Padding the dump with schema defaults would make an + explicitly-set 0.0 indistinguishable from "never touched", which is the one + question a raw attribute dump exists to answer. + +The refusal: an unknown or deleted handle raises `RuntimeError`, matching +`entity_get`. Returning `{}` for a handle that is not there would report "this +entity has no properties" for an entity that has no existence. +""" + +from __future__ import annotations + +import json + +import pytest + +pytestmark = pytest.mark.asyncio + +#: Mirror line = the Y axis (x -> -x). This is what produces extrusion (0, 0, -1). +Y_AXIS = (0.0, 0.0, 0.0, 1.0) + + +def _numbers(value): + """Assert `value` is a real numeric sequence and return its x, y. + + Accepts a 2- or 3-component point: the arity of the dump is the tool + author's call, but "it is a sequence of numbers" is not. `bool` is excluded + because `isinstance(True, int)` would otherwise wave a coerced flag through. + """ + assert isinstance(value, (list, tuple)), f"expected a point, got {value!r}" + assert len(value) in (2, 3), f"expected a 2D or 3D point, got {value!r}" + for component in value: + assert isinstance(component, (int, float)) and not isinstance(component, bool), ( + f"point component {component!r} is not a number" + ) + return float(value[0]), float(value[1]) + + +async def _one_of_each(backend): + """A LINE, LWPOLYLINE, INSERT, TEXT and CIRCLE — every Vec3-carrying type.""" + backend._doc.blocks.new("BOLT").add_circle((0.0, 0.0), 2.0) + return { + "LINE": (await backend.entity_create_line(0, 0, 100, 50)).handle, + "LWPOLYLINE": ( + await backend.entity_create_polyline([[0, 0], [10, 0], [10, 10]], closed=True) + ).handle, + "INSERT": (await backend.block_insert("BOLT", 10, 20, scale_x=2.0, scale_y=3.0)).handle, + "TEXT": (await backend.entity_create_text("PART A", 40, 15, 3.0)).handle, + "CIRCLE": (await backend.entity_create_circle(30, 20, 5)).handle, + } + + +# ── the wire boundary ─────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("dxftype", ["LINE", "LWPOLYLINE", "INSERT", "TEXT", "CIRCLE"]) +async def test_the_dump_survives_json_encoding(backend, dxftype): + """Measured: raw `dxfattribs()` raises `Object of type Vec3 is not JSON serializable`.""" + handles = await _one_of_each(backend) + + result = await backend.analysis_list_properties(handles[dxftype]) + + encoded = json.dumps(result) + assert json.loads(encoded) == result, "the payload changed shape on the way through JSON" + + +async def test_a_lines_endpoints_are_numbers_not_stringified_vectors(backend): + """`str(Vec3(...))` would satisfy json.dumps and hand the caller a string.""" + line = await backend.entity_create_line(0, 0, 100, 50) + + dump = (await backend.analysis_list_properties(line.handle))["dxf_attributes"] + + assert _numbers(dump["start"]) == pytest.approx((0.0, 0.0)) + assert _numbers(dump["end"]) == pytest.approx((100.0, 50.0)) + + +async def test_a_texts_insertion_point_is_numbers_not_a_stringified_vector(backend): + text = await backend.entity_create_text("PART A", 40, 15, 3.0) + + dump = (await backend.analysis_list_properties(text.handle))["dxf_attributes"] + + assert _numbers(dump["insert"]) == pytest.approx((40.0, 15.0)) + assert dump["text"] == "PART A" + assert dump["height"] == pytest.approx(3.0) + + +async def test_an_inserts_insertion_point_is_numbers_not_a_stringified_vector(backend): + backend._doc.blocks.new("BOLT").add_circle((0.0, 0.0), 2.0) + insert = await backend.block_insert("BOLT", 10, 20, scale_x=2.0, scale_y=3.0) + + dump = (await backend.analysis_list_properties(insert.handle))["dxf_attributes"] + + assert _numbers(dump["insert"]) == pytest.approx((10.0, 20.0)) + assert dump["name"] == "BOLT" + assert dump["xscale"] == pytest.approx(2.0) + assert dump["yscale"] == pytest.approx(3.0) + + +# ── coordinates are WCS ───────────────────────────────────────────────────── + + +async def test_a_mirrored_circles_centre_is_reported_in_wcs(backend): + """Stored (30, 20) with extrusion -Z; drawn at (-30, 20). The dump says drawn. + + Echoing `dxfattribs()` puts a 60 mm contradiction inside one payload, because + `entity_get` on the same handle reports (-30, 20). + """ + circle = await backend.entity_create_circle(30, 20, 5) + mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS) + assert backend._doc.entitydb.get(mirrored.handle).dxf.center.x == pytest.approx(30.0), ( + "precondition: the DXF really does store the un-negated x" + ) + + dump = (await backend.analysis_list_properties(mirrored.handle))["dxf_attributes"] + + assert _numbers(dump["center"]) == pytest.approx((-30.0, 20.0)) + info = await backend.entity_get(mirrored.handle) + assert _numbers(dump["center"]) == pytest.approx(tuple(info.properties["center"])) + + +async def test_the_dump_still_names_the_frame_it_translated_from(backend): + """Drop the extrusion and a caller cannot tell a translated point from a raw one.""" + circle = await backend.entity_create_circle(30, 20, 5) + mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS) + + dump = (await backend.analysis_list_properties(mirrored.handle))["dxf_attributes"] + + assert list(dump["extrusion"]) == pytest.approx([0.0, 0.0, -1.0]) + assert dump["radius"] == pytest.approx(5.0), "radius is frame-invariant in a flat frame" + + +# ── what entity_get deliberately does not carry ───────────────────────────── + + +async def test_a_polylines_vertices_are_in_the_dump(backend): + """Measured: `dxfattribs()` for an LWPOLYLINE is flags/handle/layer/owner only. + + Dumping it verbatim describes a polyline without saying where it is. + """ + poly = await backend.entity_create_polyline([[0, 0], [10, 0], [10, 10]], closed=True) + assert "points" not in backend._doc.entitydb.get(poly.handle).dxfattribs(), ( + "precondition: ezdxf keeps the vertices out of the attribute dict" + ) + + dump = (await backend.analysis_list_properties(poly.handle))["dxf_attributes"] + + assert [_numbers(pt) for pt in dump["points"]] == pytest.approx( + [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)] + ) + + +async def test_a_polyline_vertex_carries_its_bulge(backend): + """`entity_get` gives bare [x, y]; rebuilding a bulged outline from those + loses 28.2% of the area. The full dump is where the bulge has to live.""" + poly = await backend.entity_create_polyline([[0, 0], [10, 0], [10, 10]], closed=True) + backend._doc.entitydb.get(poly.handle).set_points( + [(0, 0, 0.0), (10, 0, 0.5), (10, 10, 0.0)], format="xyb" + ) + + dump = (await backend.analysis_list_properties(poly.handle))["dxf_attributes"] + + bulges = [pt[2] for pt in dump["points"]] + assert len(bulges) == 3, "every vertex reports a bulge, including the straight ones" + assert bulges == pytest.approx([0.0, 0.5, 0.0]) + + +async def test_text_style_and_obliquity_reach_the_caller(backend): + """Neither appears anywhere in `entity_get`, and both change what is plotted.""" + backend._doc.styles.new("ISO", dxfattribs={"font": "isocp.shx"}) + text = await backend.entity_create_text("PART A", 40, 15, 3.0) + entity = backend._doc.entitydb.get(text.handle) + entity.dxf.style = "ISO" + entity.dxf.oblique = 15.0 + + dump = (await backend.analysis_list_properties(text.handle))["dxf_attributes"] + + assert dump["style"] == "ISO" + assert dump["oblique"] == pytest.approx(15.0) + info = await backend.entity_get(text.handle) + assert "style" not in info.properties, "precondition: entity_get is the curated view" + + +async def test_the_dump_reports_what_is_stored_not_schema_defaults(backend): + """A dump padded with defaults cannot answer "did the drafter set this?". + + That is the only question a raw attribute dump exists to answer, so an + unset `thickness` must be absent rather than reported as 0.0. + """ + line = await backend.entity_create_line(0, 0, 100, 50) + + before = (await backend.analysis_list_properties(line.handle))["dxf_attributes"] + assert "thickness" not in before + assert "ltscale" not in before + + entity = backend._doc.entitydb.get(line.handle) + entity.dxf.thickness = 2.0 + entity.dxf.ltscale = 0.5 + after = (await backend.analysis_list_properties(line.handle))["dxf_attributes"] + + assert after["thickness"] == pytest.approx(2.0) + assert after["ltscale"] == pytest.approx(0.5) + + +# ── the top level ─────────────────────────────────────────────────────────── + + +async def test_the_type_and_layer_sit_at_the_top_level(backend): + """A caller asking "what is this?" should not have to dig into the dump.""" + circle = await backend.entity_create_circle(30, 20, 5, layer="GEOMETRY") + + result = await backend.analysis_list_properties(circle.handle) + + assert result["ok"] is True + assert result["handle"] == circle.handle + assert result["type"] == "CIRCLE" + assert result["layer"] == "GEOMETRY" + assert result["dxf_attributes"]["layer"] == "GEOMETRY", "the two must not disagree" + + +async def test_the_summary_does_not_contradict_entity_get(backend): + """`properties` restates the curated view, so it must at least agree with it. + + This is the test that argues for deleting the key: a second copy of + `entity_get`'s output can only ever be identical or wrong. + """ + circle = await backend.entity_create_circle(30, 20, 5) + mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS) + + result = await backend.analysis_list_properties(mirrored.handle) + + info = await backend.entity_get(mirrored.handle) + assert result["properties"]["center"] == pytest.approx(info.properties["center"]) + + +# ── the refusal ───────────────────────────────────────────────────────────── + + +async def test_an_unknown_handle_is_refused_not_answered_with_an_empty_dump(backend): + """`{}` would read as "this entity has no properties" for a nonexistent entity.""" + with pytest.raises(RuntimeError, match="FFFFFF"): + await backend.analysis_list_properties("FFFFFF") + + +async def test_a_deleted_handle_is_refused_too(backend): + """`entitydb.get` does not filter destroyed entities, so the guard must.""" + line = await backend.entity_create_line(0, 0, 100, 50) + await backend.entity_delete(line.handle) + + with pytest.raises(RuntimeError, match=line.handle): + await backend.analysis_list_properties(line.handle) + + +# ── round trip ────────────────────────────────────────────────────────────── + + +async def test_the_dump_describes_what_actually_lands_in_the_file(backend, tmp_path): + """The dump is a translation of the file, not a snapshot of in-memory state. + + The reloaded DXF still stores the OCS centre; the dump reported the WCS one. + Both readings of the same circle must remain consistent after a save. + """ + import ezdxf + + circle = await backend.entity_create_circle(30, 20, 5) + mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS) + dump = (await backend.analysis_list_properties(mirrored.handle))["dxf_attributes"] + + path = tmp_path / "listed.dxf" + await backend.drawing_save_as(str(path)) + reloaded = ezdxf.readfile(str(path)).entitydb.get(mirrored.handle) + + assert reloaded.dxf.center.x == pytest.approx(30.0), "the file keeps the OCS x" + assert list(reloaded.dxf.extrusion) == pytest.approx(list(dump["extrusion"])) + assert reloaded.dxf.radius == pytest.approx(dump["radius"]) + assert _numbers(dump["center"]) == pytest.approx((-reloaded.dxf.center.x, 20.0)) + + +async def test_a_texts_dump_round_trips_through_a_saved_file(backend, tmp_path): + import ezdxf + + text = await backend.entity_create_text("PART A", 40, 15, 3.0) + dump = (await backend.analysis_list_properties(text.handle))["dxf_attributes"] + + path = tmp_path / "listed_text.dxf" + await backend.drawing_save_as(str(path)) + reloaded = ezdxf.readfile(str(path)).entitydb.get(text.handle) + + assert reloaded.dxf.text == dump["text"] + assert reloaded.dxf.height == pytest.approx(dump["height"]) + assert _numbers(dump["insert"]) == pytest.approx((reloaded.dxf.insert.x, reloaded.dxf.insert.y)) diff --git a/tests/test_ezdxf_honesty.py b/tests/test_ezdxf_honesty.py new file mode 100644 index 0000000..3679fb7 --- /dev/null +++ b/tests/test_ezdxf_honesty.py @@ -0,0 +1,371 @@ +"""Honesty guarantees for the headless ezdxf backend. + +T0.1 — the DWG lie. ezdxf has no DWG writer, so any save that would put DXF + bytes behind a ``.dwg`` name must refuse with the machine-readable + ``{"ok": False, "error": ..., "capability": ...}`` shape instead, and the + boundary must be discoverable up-front through the capability map. + +T0.3 — the audit lie, which ran the *other* way. ``Drawing.audit()`` repairs the + document in place, so ``auditor.fixes`` is a log of mutations already + applied. ``drawing_audit`` threw that list away, never marked the document + dirty (so ``drawing_close(save=True)`` discarded the repairs it had just + made), and rendered the errors it did report as ````. The tool said "0 errors" about a file it had silently altered. + +T0.6 — no per-call timeout. Every ezdxf method funnels through ``_async``, which + serialises on a single ``asyncio.Lock``; one hung sync call used to hold + that lock forever and deadlock every later tool call on the server. +""" + +from __future__ import annotations + +import asyncio +import threading +import time +import types + +import pytest + +import config +from backends.com_backend import ComBackend +from backends.ezdxf_backend import EzdxfBackend + +pytestmark = pytest.mark.asyncio + + +# ── T0.1 — DWG refusal ────────────────────────────────────────────────────── + + +async def test_save_as_dwg_refuses_with_capability_tagged_shape(backend, tmp_path): + target = tmp_path / "part.dwg" + with pytest.raises(RuntimeError) as excinfo: + await backend.drawing_save_as(str(target), "dwg") + + payload = excinfo.value.to_dict() + assert payload["ok"] is False + assert payload["capability"] == "dwg" + assert excinfo.value.capability == "dwg" + + message = payload["error"] + assert message == str(excinfo.value) + lowered = message.lower() + assert "dwg" in lowered + assert ".dxf" in lowered # points at the headless alternative + assert "com" in lowered # ...and at the live backend + assert not target.exists(), "refusal must not leave a mislabelled file behind" + + +async def test_save_as_refuses_dwg_extension_even_when_fmt_says_dxf(backend, tmp_path): + """The bytes must match the name: a .dwg path is a DWG request, whatever fmt says.""" + target = tmp_path / "sneaky.dwg" + with pytest.raises(RuntimeError) as excinfo: + await backend.drawing_save_as(str(target), "dxf") + assert excinfo.value.capability == "dwg" + assert not target.exists() + + +async def test_drawing_save_refuses_dwg_path(backend, tmp_path): + """drawing_save(path) took any path and wrote DXF bytes into it.""" + target = tmp_path / "saved.dwg" + with pytest.raises(RuntimeError) as excinfo: + await backend.drawing_save(str(target)) + assert excinfo.value.capability == "dwg" + assert not target.exists() + + +async def test_dxf_save_still_works(backend, tmp_path): + import ezdxf + + await backend.entity_create_line(0, 0, 10, 0) + + as_path = tmp_path / "ok_saveas.dxf" + result = await backend.drawing_save_as(str(as_path), "dxf") + assert result["ok"] is True + assert result["format"] == "dxf" + assert ezdxf.readfile(str(as_path)) is not None + + save_path = tmp_path / "ok_save.dxf" + assert (await backend.drawing_save(str(save_path)))["ok"] is True + assert save_path.exists() + + export_path = tmp_path / "ok_export.dxf" + assert (await backend.drawing_export_dxf(str(export_path)))["ok"] is True + assert export_path.exists() + + +async def test_capability_map_reports_the_dwg_boundary(backend): + features = backend.capabilities().to_dict()["features"] + assert features["dxf"]["supported"] is True + assert features["dwg"]["supported"] is False + assert features["dwg"]["reason"] + + +async def test_both_backends_declare_the_same_capability_keys(): + """A capability only helps clients if both maps answer the same question.""" + ezdxf_keys = set(EzdxfBackend().capabilities().features) + com_keys = set(ComBackend().capabilities().features) + assert ezdxf_keys == com_keys + assert "dwg" in com_keys + assert ComBackend().capabilities().features["dwg"].supported is True + + +# ── T0.6 — per-call timeout / anti-deadlock ───────────────────────────────── + + +async def test_slow_call_times_out_and_backend_stays_usable(backend, monkeypatch): + monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 0.2) + entered = threading.Event() + + def _hang(): + entered.set() + time.sleep(2.0) + return "too late" + + started = time.monotonic() + with pytest.raises(RuntimeError) as excinfo: + await backend._async(_hang) + assert entered.is_set() + assert time.monotonic() - started < 1.5, "must abandon the call, not wait it out" + + message = str(excinfo.value) + assert "EZDXF_CALL_TIMEOUT" in message + assert "0.2" in message + + # The anti-deadlock guarantee: the NEXT call must not queue behind the + # abandoned worker thread (which is still sleeping). + monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 10) + info = await asyncio.wait_for(backend.drawing_info(), timeout=3) + assert info.backend == "ezdxf" + line = await asyncio.wait_for(backend.entity_create_line(0, 0, 1, 1), timeout=3) + assert line.handle + + +async def test_waiting_on_a_busy_backend_times_out_without_stealing_the_lock(backend, monkeypatch): + """A live call owns its lock: queueing past the timeout must not abandon it.""" + monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 0.2) + live = backend._lock + await live.acquire() # stand in for a call that is still running + try: + started = time.monotonic() + with pytest.raises(RuntimeError, match="busy"): + await backend._async(lambda: "never runs") + assert time.monotonic() - started < 1.0 + assert backend._lock is live, "the in-flight call's lock must not be swapped out" + finally: + live.release() + + monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 5) + assert await asyncio.wait_for(backend._async(lambda: "after"), timeout=2) == "after" + + +async def test_zero_timeout_disables_the_check(backend, monkeypatch): + monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 0) + + def _slow(): + time.sleep(0.3) + return "finished" + + assert await backend._async(_slow) == "finished" + + +async def test_async_still_serialises_calls(backend): + """The timeout rewrite must not drop mutual exclusion on the ezdxf document.""" + trace: list[str] = [] + + def _work(tag): + trace.append(f"enter:{tag}") + time.sleep(0.05) + trace.append(f"exit:{tag}") + return tag + + results = await asyncio.gather(*(backend._async(_work, tag) for tag in "abc")) + assert sorted(results) == ["a", "b", "c"] + assert trace == [step for tag in results for step in (f"enter:{tag}", f"exit:{tag}")] + + +async def test_a_call_raising_TimeoutError_is_not_reported_as_the_deadline(backend, monkeypatch): + """A TimeoutError *from the call* is a normal failure, not our deadline. + + ``TimeoutError`` is ``asyncio.TimeoutError`` on 3.11+ and an ``OSError`` + subclass, so an ezdxf read off a dead network share raises exactly what + ``wait_for`` raises. Blaming the deadline for it would tell the user their + drawing "may be left mid-write" and orphan a lock, for a call that never + ran long at all. + """ + monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 30) + live = backend._lock + + def _boom(): + raise TimeoutError("the network share went away") + + with pytest.raises(TimeoutError, match="network share"): + await backend._async(_boom) + + assert backend._lock is live, "a call that merely failed must not abandon its lock" + assert await asyncio.wait_for(backend._async(lambda: "next"), timeout=2) == "next" + + +async def test_errors_release_the_lock(backend, monkeypatch): + monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 5) + + def _boom(): + raise ValueError("nope") + + with pytest.raises(ValueError): + await backend._async(_boom) + assert await asyncio.wait_for(backend._async(lambda: "next"), timeout=2) == "next" + + +# ── T0.3 — audit reports what it repaired, and keeps it ───────────────────── + + +def _write_damaged_dxf(path) -> None: + """A DXF referencing a linetype and a text style that were never defined. + + Both are defects ezdxf's ``Auditor`` *repairs* — UNDEFINED_LINETYPE (100) + and UNDEFINED_TEXT_STYLE (102) — by discarding the dangling attribute. The + names are chosen to survive as literal bytes in the saved file so a test can + prove the repair reached disk. + """ + import ezdxf + + doc = ezdxf.new("R2010") + msp = doc.modelspace() + msp.add_line((0, 0), (10, 0), dxfattribs={"linetype": "GHOSTLINE"}) + msp.add_text("hello", dxfattribs={"style": "NOSUCHSTYLE"}) + doc.saveas(str(path)) + + +def _write_block_cycle_dxf(path) -> None: + """Two blocks that insert each other — a defect the Auditor CANNOT repair. + + This is the only way to get entries into ``auditor.errors`` (as opposed to + ``auditor.fixes``), which is what makes the error-rendering assertion + possible at all. + """ + import ezdxf + + doc = ezdxf.new("R2010") + block_a = doc.blocks.new("BLK_A") + block_b = doc.blocks.new("BLK_B") + block_a.add_blockref("BLK_B", (0, 0)) + block_b.add_blockref("BLK_A", (0, 0)) + doc.modelspace().add_blockref("BLK_A", (0, 0)) + doc.saveas(str(path)) + + +async def test_audit_reports_the_repairs_it_applied(backend, tmp_path): + """``doc.audit()`` mutates; a tool that hides those mutations is lying.""" + target = tmp_path / "damaged.dxf" + _write_damaged_dxf(target) + await backend.drawing_open(str(target)) + + result = await backend.drawing_audit() + + assert result["repaired"] is True + assert result["fix_count"] == 2 + assert {fix["code"] for fix in result["fixes"]} == {100, 102} + for fix in result["fixes"]: + assert isinstance(fix["message"], str) and fix["message"] + assert fix["name"] + + +async def test_audit_marks_the_document_dirty_when_it_repaired(backend, tmp_path): + """The load-bearing half: a reported repair that close() throws away is worse. + + ``drawing_close`` saves only ``if save and self._dirty``. Reporting the fixes + without marking the document dirty would promise three repairs and then lose + every one of them. + """ + target = tmp_path / "damaged.dxf" + _write_damaged_dxf(target) + before = target.read_bytes() + assert b"GHOSTLINE" in before and b"NOSUCHSTYLE" in before + + await backend.drawing_open(str(target)) + await backend.drawing_audit() + + info = await backend.drawing_info() + assert info.saved is False, "audit mutated the document; it is no longer 'saved'" + + await backend.drawing_close(save=True) + after = target.read_bytes() + assert after != before, "the repair must reach disk, not be discarded on close" + assert b"GHOSTLINE" not in after + assert b"NOSUCHSTYLE" not in after + + +async def test_audit_clean_document_reports_no_repair(backend): + """The opposite direction of the same lie: never claim a repair not made.""" + result = await backend.drawing_audit() + assert result["repaired"] is False + assert result["fix_count"] == 0 + assert result["fixes"] == [] + assert (await backend.drawing_info()).saved is True + + +async def test_audit_errors_are_readable_messages_not_object_reprs(backend, tmp_path): + """``ErrorEntry`` has no ``__str__``; ``str(e)`` yielded a memory address.""" + target = tmp_path / "cycle.dxf" + _write_block_cycle_dxf(target) + await backend.drawing_open(str(target)) + + result = await backend.drawing_audit() + + assert result["error_count"] >= 2 + for err in result["errors"]: + assert isinstance(err, dict) + assert err["code"] == 104 + assert "block reference cycle" in err["message"].lower() + assert "ErrorEntry object at" not in err["message"] + + +async def test_audit_detail_capability_is_declared_per_backend(): + """A client must be able to tell "nothing was wrong" from "we cannot see".""" + assert EzdxfBackend().capabilities().features["audit_detail"].supported is True + + com_audit = ComBackend().capabilities().features["audit_detail"] + assert com_audit.supported is False + assert com_audit.reason == "audit_result_not_readable_over_com" + + +async def test_audit_discloses_an_entity_it_deleted(backend): + """The most destructive repair is the one that names no handle. + + ``Auditor.check_owner_exist`` *deletes* an entity whose owner handle points + nowhere, and calls ``fixed_error`` without ``dxf_entity=`` — so ``handle`` is + ``None`` for exactly the fix a user most needs to know about. Before the + dirty-flag fix the missing ``_mark_dirty()`` accidentally shielded people + from this; now the deletion reaches disk on the next save, so the report has + to carry it. + """ + await backend.entity_create_line(0, 0, 5, 5) + await backend.entity_create_circle(1, 1, 2) + doomed = next(iter(backend._doc.modelspace())) + doomed.dxf.owner = "DEAD" # the damage a truncated write leaves behind + + result = await backend.drawing_audit() + + assert result["repaired"] is True + deletions = [fix for fix in result["fixes"] if "Deleted" in fix["message"]] + assert deletions, f"a deletion must be reported, got {result['fixes']}" + assert "LINE" in deletions[0]["message"] + assert deletions[0]["handle"] is None, ( + "ezdxf reports this fix without the entity, so the handle is genuinely " + "unknown — inventing one would be worse than admitting it" + ) + assert len(list(backend._doc.modelspace())) == 1, "the entity really is gone" + assert (await backend.drawing_info()).saved is False + + +async def test_audit_entry_names_an_unknown_code_honestly(): + """A code outside AuditError must not be dressed up as a name.""" + from backends.ezdxf_backend import _audit_entry + + entry = types.SimpleNamespace(code=999, message="something new", entity=None) + assert _audit_entry(entry) == { + "code": 999, + "name": "UNKNOWN", + "message": "something new", + "handle": None, + } diff --git a/tests/test_fits.py b/tests/test_fits.py index e236e31..d5f0d07 100644 --- a/tests/test_fits.py +++ b/tests/test_fits.py @@ -65,8 +65,10 @@ def test_parse_fit_code(): @pytest.mark.parametrize( "code,nominal", [ - ("K7", 20.0), # transition hole letters need the delta-rule — excluded - ("P7", 20.0), + # K7/P7 used to be here: the delta rule derives them from tables already + # in the module, so they are authored as of 1.5.0 and pinned against + # published values above. + ("t6", 20.0), # ISO 286 does not define shaft t below 24 mm ("x6", 20.0), # letter outside the authored subset (regex rejects) ("H3", 20.0), # grade below authored range ("H12", 20.0), # grade above authored range @@ -116,3 +118,210 @@ def test_fit_passthrough_without_code(): "deviation", "x", ) + + +# ── ISO 286 transition / interference HOLES (K, M, N, P) ──────────────────── +# +# These were refused by name until 1.5.0. They are not new table data: ISO 286 +# derives an upper-letter hole from the same-letter shaft by the delta rule, +# +# ES = -ei + Δ, Δ = IT(n) − IT(n−1) +# +# so every value below comes from tables already authored in this module. The +# expected numbers are the published ISO 286 ones, cross-checked by hand — the +# point of pinning them is that the *rule* is right, not that the arithmetic runs. + + +@pytest.mark.parametrize( + ("code", "nominal", "upper_um", "lower_um"), + [ + ("K7", 20.0, 6, -15), + ("M7", 20.0, 0, -21), + ("N7", 20.0, -7, -28), + ("P7", 20.0, -14, -35), + ("K7", 50.0, 7, -18), + ("N7", 50.0, -8, -33), + ("P7", 50.0, -17, -42), + ("K6", 20.0, 2, -11), + ("N6", 20.0, -11, -24), + ("P6", 20.0, -18, -31), + ], +) +def test_transition_hole_letters_match_published_iso286(code, nominal, upper_um, lower_um): + fit = fit_lookup(code, nominal) + assert fit.upper_mm == pytest.approx(upper_um / 1000.0, abs=1e-9) + assert fit.lower_mm == pytest.approx(lower_um / 1000.0, abs=1e-9) + + +def test_h7_over_p6_is_a_genuine_interference_fit(): + """The pairing check the tables exist for: the shaft must be bigger.""" + hole = fit_lookup("H7", 20.0) + shaft = fit_lookup("p6", 20.0) + assert shaft.lower_mm > hole.upper_mm - hole.it_value_mm, "min shaft > min hole" + assert shaft.lower_mm > 0 and hole.lower_mm == 0 + + +def test_the_delta_collapses_above_the_grade_the_rule_covers(): + """ISO 286 applies Δ only to IT<=8 for K/M/N and IT<=7 for P; then ES = -ei.""" + from engineering.fits import _SHAFT_EI, _size_index + + index = _size_index(20.0) + n9 = fit_lookup("N9", 20.0) + assert n9.upper_mm == pytest.approx(-_SHAFT_EI["n"][index] / 1000.0, abs=1e-9) + + p8 = fit_lookup("P8", 20.0) + assert p8.upper_mm == pytest.approx(-_SHAFT_EI["p"][index] / 1000.0, abs=1e-9) + + +def test_grade_4_is_refused_rather_than_guessed(): + """Δ needs IT3, which is outside the authored IT4-IT11 range.""" + with pytest.raises(ValueError, match="IT5"): + fit_lookup("K4", 20.0) + + +# ── ISO 286 interference SHAFTS (r, s, t, u) ──────────────────────────────── +# +# These need their own fundamental deviations, sub-stepped more finely than the +# main size steps above 50 mm, so unlike K/M/N/P they are table data rather than +# a rule. Authored table data is only as good as its provenance, so every value +# is cross-checked below against ISO 286-1's own derivation formulae — an +# independent source that agrees or does not. What survives is stated; what +# diverges is listed by name, so a reader with the standard checks five numbers +# instead of ninety-five. + + +@pytest.mark.parametrize( + ("code", "nominal", "upper_um", "lower_um"), + [ + # ei + IT above the nominal — these are all interference shafts. + ("r6", 20.0, 41, 28), + ("s6", 20.0, 48, 35), + ("u6", 20.0, 54, 41), + ("s7", 50.0, 68, 43), + ("u6", 30.0, 61, 48), # 24-30 sub-step: u jumps 41 -> 48 inside 18-30 + ("s6", 60.0, 72, 53), # 50-65 sub-step, IT6 from the main 50-80 step + ("t6", 60.0, 85, 66), + ], +) +def test_interference_shafts_match_published_iso286(code, nominal, upper_um, lower_um): + fit = fit_lookup(code, nominal) + assert fit.upper_mm == pytest.approx(upper_um / 1000.0, abs=1e-9) + assert fit.lower_mm == pytest.approx(lower_um / 1000.0, abs=1e-9) + + +def test_the_sub_steps_are_real_and_not_flattened(): + """Above 50 mm these letters split the main steps; collapsing them is a + silent error of tens of microns.""" + assert fit_lookup("u6", 55.0).lower_mm != fit_lookup("u6", 70.0).lower_mm + assert fit_lookup("u6", 20.0).lower_mm != fit_lookup("u6", 28.0).lower_mm + + +def test_t_is_refused_below_24_mm_because_iso_does_not_define_it(): + with pytest.raises(ValueError, match="24"): + fit_lookup("t6", 20.0) + assert fit_lookup("t6", 30.0).lower_mm > 0 + + +# -- provenance: the table against ISO 286-1's own formulae ------------------ + + +def test_r_is_the_geometric_mean_of_p_and_s(): + """ISO 286 *defines* r that way, and p is already pinned in this module. + + 24/24 sub-steps agree within 1 um, which validates r and s together against + data that was trusted before either was authored. + """ + import math + + from engineering.fits import _FINE_SIZE_STEPS, _SHAFT_EI, _SHAFT_EI_FINE, _size_index + + for index, step in enumerate(_FINE_SIZE_STEPS): + mid = (step[0] + step[1]) / 2.0 + p = _SHAFT_EI["p"][_size_index(mid)] + s = _SHAFT_EI_FINE["s"][index] + r = _SHAFT_EI_FINE["r"][index] + assert abs(round(math.sqrt(p * s)) - r) <= 1, f"r at {step}: table {r}, sqrt(p*s) {p}/{s}" + + +#: Steps where ISO 286's rounding rules put the published value more than 1 um +#: from the formula. Listed rather than hidden: these are the only values a +#: reader with the standard needs to check by hand. +KNOWN_FORMULA_DIVERGENCES = { + ("u", (250, 280)), + ("u", (400, 450)), + ("u", (450, 500)), + ("t", (24, 30)), + ("t", (450, 500)), +} + + +#: Below this the formulae do not describe the published tables at all — ISO +#: tabulates the small sizes directly. Measured for u: the formula is 6 um low at +#: 1-3 mm and only converges from 18 mm up (-6, -7, -5, -2, +1). Scoping the +#: check here is a statement about where the cross-check has authority, not a +#: threshold moved until the test passed. +FORMULA_VALID_FROM_MM = 18 + + +@pytest.mark.parametrize(("letter", "coefficient"), [("t", 0.63), ("u", 1.0)]) +def test_t_and_u_track_their_iso_formula(letter, coefficient): + """ISO 286-1: ei = IT7 + k*D, D the geometric mean of the step bounds. + + The standard rounds its published tables, so exact equality is not the + claim — agreement within 1 um everywhere except a listed handful is. + """ + import math + + from engineering.fits import _FINE_SIZE_STEPS, _SHAFT_EI_FINE, _it_um, _size_index + + checked = 0 + for index, step in enumerate(_FINE_SIZE_STEPS): + ei = _SHAFT_EI_FINE[letter][index] + if ei is None or step[0] < FORMULA_VALID_FROM_MM: + continue + checked += 1 + mid = (step[0] + step[1]) / 2.0 + it7 = _it_um(7, _size_index(mid)) + formula = it7 + coefficient * math.sqrt(step[0] * step[1]) + drift = abs(round(formula) - ei) + if (letter, step) in KNOWN_FORMULA_DIVERGENCES: + assert 1 < drift <= 3, f"{letter} at {step} drifted {drift} um — re-check the table" + else: + assert drift <= 1, f"{letter} at {step}: table {ei}, formula {formula:.1f}" + assert checked >= 18, f"only {checked} steps cross-checked for {letter}" + + +def test_s_above_50_tracks_its_iso_formula(): + import math + + from engineering.fits import _FINE_SIZE_STEPS, _SHAFT_EI_FINE, _it_um, _size_index + + for index, step in enumerate(_FINE_SIZE_STEPS): + if step[0] < 50: + continue + mid = (step[0] + step[1]) / 2.0 + formula = _it_um(7, _size_index(mid)) + 0.4 * math.sqrt(step[0] * step[1]) + assert abs(round(formula) - _SHAFT_EI_FINE["s"][index]) <= 1, f"s at {step}" + + +def test_s_below_50_sits_in_the_iso_band_above_it8(): + """ISO 286: for D <= 50, s is IT8 plus a small step. Monotone, 0..4 um.""" + from engineering.fits import _FINE_SIZE_STEPS, _SHAFT_EI_FINE, _it_um, _size_index + + deltas = [] + for index, step in enumerate(_FINE_SIZE_STEPS): + if step[0] >= 50: + break + mid = (step[0] + step[1]) / 2.0 + deltas.append(_SHAFT_EI_FINE["s"][index] - _it_um(8, _size_index(mid))) + assert all(0 <= d <= 4 for d in deltas), deltas + assert deltas == sorted(deltas), f"the step above IT8 must not decrease: {deltas}" + + +def test_h7_over_s6_is_tighter_than_h7_over_p6(): + """The reason these letters exist: progressively heavier interference.""" + p6 = fit_lookup("p6", 20.0).lower_mm + r6 = fit_lookup("r6", 20.0).lower_mm + s6 = fit_lookup("s6", 20.0).lower_mm + u6 = fit_lookup("u6", 20.0).lower_mm + assert p6 < r6 < s6 < u6 diff --git a/tests/test_hatch_depth.py b/tests/test_hatch_depth.py new file mode 100644 index 0000000..6749850 --- /dev/null +++ b/tests/test_hatch_depth.py @@ -0,0 +1,364 @@ +"""F4 — hatch depth: gradient fill, in-place edit, curved boundary edges. + +`entity_create_hatch` can do exactly one thing: stamp a pattern fill into a +closed *polyline* boundary. Everything a section drawing actually needs after +that — restyling the fill, changing the island rule, bounding the fill with an +arc — has no tool. These three fill that gap, and each carries a measured trap: + +* **Gradient fill is a replacement, not a layer.** `set_gradient()` flips + `solid_fill` to 1, rewrites `pattern_name` to `SOLID` and drops `.pattern` to + ``None``. A tool that reported "gradient applied" while leaving the caller + believing the ANSI31 hatching is still underneath would be describing a + drawing that does not exist. Every gradient assertion here goes through + saveas/readfile, because gradient data lives in its own DXF subrecord and a + gradient that vanishes on save is the classic failure mode. + +* **`pattern_scale` is two numbers, not one.** ezdxf stores the scale twice: + as `dxf.pattern_scale` *and* baked into the pattern definition lines' offset + vectors. Setting only the DXF attribute is the perfect silent no-op — every + report says scale 4.0, every renderer draws scale 2.0. So the scale and angle + tests measure the pattern line *pitch* and *angle*, not the attribute. + Measured here: ANSI31 at scale 1.0 has a 3.175 mm pitch; scale 2.0 is 6.35. + ezdxf's `set_pattern_scale`/`set_pattern_angle` are absolute, not relative. + +* **A partial edit must stay partial.** `hatch_edit` takes five optional + parameters; the data-loss bug is the implementation that rebuilds the hatch + from defaults and quietly resets the four the caller did not name. Note that + a hatch born from `entity_create_hatch` already has `hatch_style == 1` + (OUTER), because that is ezdxf's `set_pattern_fill` default — so "normal" is + a real change, not a no-op, and `changed` has to distinguish a parameter that + was *supplied* from an attribute that actually *moved*. + +* **Flat vertex lists straighten curves.** This is the fidelity point of + `hatch_add_boundary`. A hatch boundary API that only accepts points turns + every fillet and bore into a chord and still reports a boundary. So the arc + test asserts the reloaded path is an `EdgePath` holding an `ArcEdge` with its + radius and sweep intact — a flattened arc would come back as a `PolylinePath` + and pass any "did a path appear?" check. +""" + +from __future__ import annotations + +import math + +import pytest + +pytestmark = pytest.mark.asyncio + +SQUARE = [[0.0, 0.0], [40.0, 0.0], [40.0, 40.0], [0.0, 40.0]] + +RED = [255, 0, 0] +BLUE = [0, 0, 255] + + +def _entity(backend, handle): + return backend._doc.entitydb.get(handle) + + +async def _reloaded(backend, tmp_path, handle, name="hatch.dxf"): + """Round-trip the whole document and hand back the same entity.""" + import ezdxf + + path = tmp_path / name + await backend.drawing_save_as(str(path)) + return ezdxf.readfile(str(path)).entitydb.get(handle) + + +def _pattern_pitch(hatch): + """Distance between the hatch pattern's definition lines, in drawing units.""" + offset = hatch.pattern.lines[0].offset + return math.hypot(offset[0], offset[1]) + + +def _path_types(hatch): + return [type(path).__name__ for path in hatch.paths] + + +# ── gradient fill ─────────────────────────────────────────────────────────── + + +async def test_a_gradient_survives_save_and_reload(backend, tmp_path): + """Gradient data lives in its own subrecord; losing it on save is silent.""" + created = await backend.entity_create_hatch("ANSI31", SQUARE) + + result = await backend.hatch_set_gradient( + created.handle, RED, BLUE, rotation=30.0, tint=0.4, name="CURVED" + ) + assert result["ok"] is True + assert result["gradient"]["name"] == "CURVED" + assert list(result["gradient"]["color1"]) == RED + + reloaded = await _reloaded(backend, tmp_path, created.handle) + gradient = reloaded.gradient + assert gradient is not None, "the gradient did not survive the round-trip" + assert tuple(gradient.color1) == (255, 0, 0) + assert tuple(gradient.color2) == (0, 0, 255) + assert gradient.rotation == pytest.approx(30.0) + assert gradient.tint == pytest.approx(0.4) + assert gradient.name == "CURVED" + + +async def test_a_gradient_replaces_the_pattern_fill_it_lands_on(backend, tmp_path): + """A hatch is gradient-filled *or* pattern-filled. Never both.""" + created = await backend.entity_create_hatch("ANSI31", SQUARE, scale=2.0) + assert _entity(backend, created.handle).dxf.pattern_name == "ANSI31" + + await backend.hatch_set_gradient(created.handle, RED, BLUE) + + reloaded = await _reloaded(backend, tmp_path, created.handle) + assert reloaded.pattern is None, "the ANSI31 line definition is still there" + assert reloaded.dxf.solid_fill == 1 + assert reloaded.dxf.pattern_name == "SOLID" + + +async def test_a_gradient_keeps_the_boundary_it_fills(backend, tmp_path): + """A gradient with no boundary paints nothing at all.""" + created = await backend.entity_create_hatch("ANSI31", SQUARE) + + await backend.hatch_set_gradient(created.handle, RED, BLUE) + + reloaded = await _reloaded(backend, tmp_path, created.handle) + assert _path_types(reloaded) == ["PolylinePath"] + assert len(reloaded.paths[0].vertices) == len(SQUARE) + + +async def test_a_one_colour_gradient_stores_its_tint(backend, tmp_path): + """one_color+tint is a distinct DXF mode; dropping the tint flattens it.""" + created = await backend.entity_create_hatch("ANSI31", SQUARE) + + result = await backend.hatch_set_gradient( + created.handle, [10, 20, 30], [0, 0, 0], one_color=True, tint=0.75, name="SPHERICAL" + ) + assert result["ok"] is True + + gradient = (await _reloaded(backend, tmp_path, created.handle)).gradient + assert int(gradient.one_color) == 1 + assert gradient.tint == pytest.approx(0.75) + assert gradient.name == "SPHERICAL" + + +async def test_a_gradient_on_a_line_is_refused_naming_the_type(backend): + """Only a HATCH carries gradient data; a LINE would swallow it silently.""" + line = await backend.entity_create_line(0, 0, 10, 0) + + result = await backend.hatch_set_gradient(line.handle, RED, BLUE) + + assert result["ok"] is False + assert "LINE" in result["error"] + assert "HATCH" in result["error"] + + +# ── hatch edit ────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("style, expected", [("normal", 0), ("outer", 1), ("ignore", 2)]) +async def test_each_island_style_maps_to_its_dxf_value(backend, tmp_path, style, expected): + created = await backend.entity_create_hatch("ANSI31", SQUARE) + # Start from a different style so every case is a real move, not a no-op. + _entity(backend, created.handle).dxf.hatch_style = (expected + 1) % 3 + + result = await backend.hatch_edit(created.handle, style=style) + + assert result["ok"] is True + assert "style" in result["changed"] + reloaded = await _reloaded(backend, tmp_path, created.handle, f"style-{style}.dxf") + assert reloaded.dxf.hatch_style == expected + + +async def test_editing_the_scale_leaves_every_other_attribute_alone(backend): + """A partial edit that resets what it was not asked about is data loss.""" + created = await backend.entity_create_hatch( + "ANSI31", SQUARE, scale=2.0, angle=15.0, layer="HATCH", color=3 + ) + style_before = _entity(backend, created.handle).dxf.hatch_style + + await backend.hatch_edit(created.handle, scale=4.0) + + hatch = _entity(backend, created.handle) + assert hatch.dxf.pattern_name == "ANSI31" + assert hatch.dxf.pattern_angle == pytest.approx(15.0) + assert hatch.dxf.color == 3 + assert hatch.dxf.layer == "HATCH" + assert hatch.dxf.hatch_style == style_before + assert len(hatch.paths) == 1 + + +async def test_a_new_scale_rescales_the_pattern_definition(backend, tmp_path): + """Setting only dxf.pattern_scale is the perfect silent no-op.""" + created = await backend.entity_create_hatch("ANSI31", SQUARE, scale=2.0) + assert _pattern_pitch(_entity(backend, created.handle)) == pytest.approx(6.35) + + await backend.hatch_edit(created.handle, scale=4.0) + + reloaded = await _reloaded(backend, tmp_path, created.handle) + assert reloaded.dxf.pattern_scale == pytest.approx(4.0) + assert _pattern_pitch(reloaded) == pytest.approx(12.7), "the lines did not move apart" + + +async def test_a_new_angle_rotates_the_pattern_definition(backend, tmp_path): + """Same trap as scale: the angle is stored twice and must move twice.""" + created = await backend.entity_create_hatch("ANSI31", SQUARE, angle=15.0) + assert _entity(backend, created.handle).pattern.lines[0].angle == pytest.approx(60.0) + + await backend.hatch_edit(created.handle, angle=30.0) + + reloaded = await _reloaded(backend, tmp_path, created.handle) + assert reloaded.dxf.pattern_angle == pytest.approx(30.0) + assert reloaded.pattern.lines[0].angle == pytest.approx(75.0), "the lines did not rotate" + + +async def test_a_new_pattern_and_colour_round_trip(backend, tmp_path): + created = await backend.entity_create_hatch("ANSI31", SQUARE, scale=2.0, color=3) + + result = await backend.hatch_edit(created.handle, pattern="ANSI32", color=5) + + assert result["ok"] is True + assert set(result["changed"]) == {"pattern", "color"} + reloaded = await _reloaded(backend, tmp_path, created.handle) + assert reloaded.dxf.pattern_name == "ANSI32" + assert reloaded.dxf.color == 5 + assert reloaded.dxf.pattern_scale == pytest.approx(2.0), "the scale was reset" + + +async def test_changed_lists_only_the_attributes_that_moved(backend): + """ "I was asked to set it" and "it is now different" are different answers.""" + created = await backend.entity_create_hatch("ANSI31", SQUARE, scale=2.0, angle=15.0) + + result = await backend.hatch_edit(created.handle, scale=4.0, angle=15.0) + + assert result["ok"] is True + assert set(result["changed"]) == {"scale"} + + +async def test_an_edit_that_moves_nothing_reports_nothing(backend): + created = await backend.entity_create_hatch("ANSI31", SQUARE, scale=2.0) + + result = await backend.hatch_edit(created.handle, scale=2.0) + + assert result["ok"] is True + assert result["changed"] == [] + + +@pytest.mark.parametrize("bad", [0.0, -1.0]) +async def test_a_non_positive_pattern_scale_is_refused(backend, bad): + """Scale 0 collapses the definition offsets to zero: an infinite line count.""" + created = await backend.entity_create_hatch("ANSI31", SQUARE) + + result = await backend.hatch_edit(created.handle, scale=bad) + + assert result["ok"] is False + assert "scale" in result["error"] + + +async def test_an_unknown_island_style_is_refused_naming_the_valid_ones(backend): + """Silently falling back to `normal` would fill islands nobody asked to fill.""" + created = await backend.entity_create_hatch("ANSI31", SQUARE) + + result = await backend.hatch_edit(created.handle, style="outermost") + + assert result["ok"] is False + for valid in ("normal", "outer", "ignore"): + assert valid in result["error"] + + +# ── boundary edges ────────────────────────────────────────────────────────── + +ARC_EDGE = { + "type": "arc", + "center": [110.0, 0.0], + "radius": 10.0, + "start_angle": 0.0, + "end_angle": 180.0, + "ccw": True, +} +LINE_EDGE = {"type": "line", "start": [100.0, 0.0], "end": [120.0, 0.0]} + + +async def test_an_arc_boundary_edge_survives_as_an_arc(backend, tmp_path): + """The fidelity point: a flattened arc comes back as a PolylinePath.""" + created = await backend.entity_create_hatch("ANSI31", SQUARE) + + result = await backend.hatch_add_boundary(created.handle, [LINE_EDGE, ARC_EDGE]) + assert result["ok"] is True + + reloaded = await _reloaded(backend, tmp_path, created.handle) + assert _path_types(reloaded) == ["PolylinePath", "EdgePath"] + edges = reloaded.paths[1].edges + assert [type(edge).__name__ for edge in edges] == ["LineEdge", "ArcEdge"] + arc = edges[1] + assert arc.radius == pytest.approx(10.0) + assert tuple(arc.center)[:2] == pytest.approx((110.0, 0.0)) + assert arc.start_angle == pytest.approx(0.0) + assert arc.end_angle == pytest.approx(180.0) + assert arc.ccw is True + + +async def test_the_added_path_is_counted_alongside_the_existing_one(backend): + """path_count is the hatch's total, so the original boundary is still there.""" + created = await backend.entity_create_hatch("ANSI31", SQUARE) + + result = await backend.hatch_add_boundary(created.handle, [LINE_EDGE, ARC_EDGE]) + + assert result["handle"] == created.handle + assert result["path_count"] == 2 + assert result["edge_types"] == ["line", "arc"] + + +async def test_an_ellipse_boundary_edge_keeps_its_ratio(backend, tmp_path): + """The minor/major ratio *is* the ellipse; drop it and you have a circle.""" + created = await backend.entity_create_hatch("ANSI31", SQUARE) + + edge = { + "type": "ellipse", + "center": [200.0, 100.0], + "major_axis": [30.0, 0.0], + "ratio": 0.4, + "start_angle": 0.0, + "end_angle": 360.0, + "ccw": True, + } + result = await backend.hatch_add_boundary(created.handle, [edge]) + assert result["edge_types"] == ["ellipse"] + + reloaded = await _reloaded(backend, tmp_path, created.handle) + stored = reloaded.paths[1].edges[0] + assert type(stored).__name__ == "EllipseEdge" + assert stored.ratio == pytest.approx(0.4) + assert tuple(stored.major_axis)[:2] == pytest.approx((30.0, 0.0)) + assert tuple(stored.center)[:2] == pytest.approx((200.0, 100.0)) + + +async def test_an_unknown_edge_type_is_refused_naming_the_valid_ones(backend): + """Skipping the edge it cannot build would leave an open boundary.""" + created = await backend.entity_create_hatch("ANSI31", SQUARE) + + result = await backend.hatch_add_boundary( + created.handle, [{"type": "bezier", "start": [0, 0], "end": [1, 1]}] + ) + + assert result["ok"] is False + assert "bezier" in result["error"] + for valid in ("line", "arc", "ellipse"): + assert valid in result["error"] + + +async def test_an_edge_missing_a_key_is_refused_naming_the_key(backend): + """An arc defaulted to radius 0 is a boundary that encloses nothing.""" + created = await backend.entity_create_hatch("ANSI31", SQUARE) + + result = await backend.hatch_add_boundary( + created.handle, + [{"type": "arc", "center": [0.0, 0.0], "start_angle": 0.0, "end_angle": 90.0}], + ) + + assert result["ok"] is False + assert "radius" in result["error"] + + +async def test_a_boundary_with_no_edges_is_refused(backend): + """An empty edge path bounds nothing and renders as a hatch with a hole.""" + created = await backend.entity_create_hatch("ANSI31", SQUARE) + + result = await backend.hatch_add_boundary(created.handle, []) + + assert result["ok"] is False diff --git a/tests/test_layout_lifecycle.py b/tests/test_layout_lifecycle.py new file mode 100644 index 0000000..e1f0ebe --- /dev/null +++ b/tests/test_layout_lifecycle.py @@ -0,0 +1,392 @@ +"""M6 — deleting, renaming and copying sheet tabs. + +`layout_list/create/set_current` shipped in v1.4; the rest of the tab lifecycle +did not, so a sheet could be made and never unmade. What makes this more than +three thin wrappers is that ezdxf's own guards stop well short of where a tool +boundary has to stop, and the gaps are all silent: + +* `doc.layouts.delete("")` does not refuse — it deletes the *first* paper-space + layout and reports nothing unusual, and `doc.layouts.get("")` likewise + resolves to the active paper space. A caller who passed an empty string by + accident loses a sheet. +* `doc.layouts.rename()` skips the `is_valid_table_name` check that `new()` + performs, so `Bad/Name` and even `""` are accepted and the file still audits + clean. +* Deleting or renaming the *current* layout leaves `self._current_space` + pointing at a name that no longer exists. `_msp()` then silently falls back to + model space while `layout_list()` still reports the dead name — so + `entity_create_*` lands somewhere the caller was never told about. +* The entities on a deleted layout are destroyed but stay in `entitydb`, where + `_get_entity`'s `is None` check does not see them. Fetching a handle from a + deleted sheet used to raise `AttributeError: 'Line' object has no attribute + 'dxf'` instead of a refusal. + +ezdxf has no layout-copy API at all (`xref.load_paperspace` is cross-document +only), so `layout_copy` is a construction — and everything a construction can +get wrong here is invisible to the Auditor, which reports zero errors on a +duplicated tab order, a duplicated main viewport and a hatch whose boundary +still points into the layout it was copied from. +""" + +from __future__ import annotations + +import pytest + +pytestmark = pytest.mark.asyncio + +SHEET = "A3-Sheet" +OTHER = "A4-Sheet" + + +def _names(backend) -> list[str]: + return list(backend._doc.layouts.names_in_taborder()) + + +def _entities_in(backend, layout_name: str) -> list[str]: + space = backend._doc.layouts.get(layout_name) + return sorted(e.dxftype() for e in space) + + +# ── layout_delete ─────────────────────────────────────────────────────────── + + +async def test_deleting_a_layout_removes_the_tab_and_its_geometry(backend): + await backend.layout_create(SHEET) + await backend.layout_set_current(SHEET) + await backend.entity_create_line(0, 0, 100, 0) + await backend.layout_set_current("Model") + + result = await backend.layout_delete(SHEET) + + assert result["ok"] is True + assert result["deleted"] == SHEET + assert result["entities_destroyed"] == 1 + assert SHEET not in _names(backend) + + +async def test_deleting_model_space_is_refused(backend): + result = await backend.layout_delete("Model") + assert result["ok"] is False + assert "Model" in result["error"] + assert "Model" in _names(backend) + + +async def test_a_blank_name_does_not_delete_the_first_sheet_it_finds(backend): + """ezdxf's own delete("") destroys Layout1 and says nothing.""" + before = _names(backend) + for blank in ("", " "): + result = await backend.layout_delete(blank) + assert result["ok"] is False, f"delete({blank!r}) must refuse" + assert _names(backend) == before + + +async def test_deleting_a_missing_layout_refuses_with_a_readable_error(backend): + """ezdxf raises a bare KeyError here; the tool must not leak that.""" + result = await backend.layout_delete("NOSUCH") + assert result["ok"] is False + assert "NOSUCH" in result["error"] + + +async def test_deleting_the_last_paper_space_layout_refuses(backend): + """A DXF document must keep at least one paper-space layout.""" + for name in _names(backend): + if name != "Model" and name != "Layout1": + await backend.layout_delete(name) + remaining = [n for n in _names(backend) if n != "Model"] + assert len(remaining) == 1, "fixture assumption: exactly one sheet left" + + result = await backend.layout_delete(remaining[0]) + assert result["ok"] is False + assert remaining[0] in _names(backend) + + +async def test_deleting_the_current_layout_leaves_every_report_agreeing(backend): + """The three-way disagreement this test exists to prevent. + + Before the fix: ``layout_list`` said the deleted sheet, ``$TILEMODE`` said + paper space, and ``_msp()`` quietly wrote to model space. Whatever the tool + decides the new current space is, all three must say the same thing and the + next entity must actually land there. + """ + await backend.layout_create(SHEET) + await backend.layout_set_current(SHEET) + + result = await backend.layout_delete(SHEET) + assert result["ok"] is True + + current = (await backend.layout_list())["current"] + assert current == result["current"] + assert current in _names(backend) + + line = await backend.entity_create_line(0, 0, 10, 0) + landed = backend._doc.modelspace() if current == "Model" else backend._doc.layouts.get(current) + assert line.handle in {e.dxf.handle for e in landed} + + +async def test_a_handle_from_a_deleted_layout_refuses_instead_of_crashing(backend): + """The destroyed entity stays in entitydb; `is None` does not catch it.""" + await backend.layout_create(SHEET) + await backend.layout_set_current(SHEET) + line = await backend.entity_create_line(0, 0, 100, 0) + await backend.layout_set_current("Model") + await backend.layout_delete(SHEET) + + with pytest.raises(RuntimeError, match=line.handle): + await backend.entity_get(line.handle) + + +# ── layout_rename ─────────────────────────────────────────────────────────── + + +async def test_renaming_keeps_the_entities_and_their_handles(backend): + await backend.layout_create(SHEET) + await backend.layout_set_current(SHEET) + line = await backend.entity_create_line(0, 0, 100, 0) + await backend.layout_set_current("Model") + + result = await backend.layout_rename(SHEET, OTHER) + + assert result["ok"] is True + assert OTHER in _names(backend) and SHEET not in _names(backend) + info = await backend.entity_get(line.handle) + assert info.handle == line.handle + + +async def test_renaming_the_current_layout_keeps_the_caller_on_it(backend): + await backend.layout_create(SHEET) + await backend.layout_set_current(SHEET) + + await backend.layout_rename(SHEET, OTHER) + + assert (await backend.layout_list())["current"] == OTHER + line = await backend.entity_create_line(0, 0, 10, 0) + assert line.handle in {e.dxf.handle for e in backend._doc.layouts.get(OTHER)} + + +@pytest.mark.parametrize("bad", ["", " ", "Bad/Name", "Bad*Name", "Bad?Name"]) +async def test_an_invalid_new_name_is_refused(backend, bad): + """`rename()` accepts all of these and the file still audits clean.""" + await backend.layout_create(SHEET) + result = await backend.layout_rename(SHEET, bad) + assert result["ok"] is False + assert SHEET in _names(backend) + + +async def test_renaming_onto_an_existing_name_is_refused(backend): + await backend.layout_create(SHEET) + await backend.layout_create(OTHER) + result = await backend.layout_rename(SHEET, OTHER) + assert result["ok"] is False + assert SHEET in _names(backend) and OTHER in _names(backend) + + +@pytest.mark.parametrize("args", [("Model", OTHER), (SHEET, "Model")]) +async def test_model_space_cannot_be_renamed_or_shadowed(backend, args): + await backend.layout_create(SHEET) + result = await backend.layout_rename(*args) + assert result["ok"] is False + assert "Model" in _names(backend) + + +async def test_renaming_a_missing_layout_refuses(backend): + result = await backend.layout_rename("NOSUCH", OTHER) + assert result["ok"] is False + + +# ── names are case-insensitive, like the engine underneath ────────────────── +# +# ezdxf resolves layout names case-insensitively in `get`, `new`, `rename`, +# `delete` and `set_active_layout` — but `names()` returns the *stored* case, so +# every guard written as `name in doc.layouts.names()` disagrees with the engine +# it is guarding. Both directions of that disagreement are bugs: a duplicate +# slips past the check and dies on a raw `DXFValueError` from inside ezdxf, and +# an existing layout typed in another case is reported as missing. + + +async def test_creating_a_layout_that_differs_only_by_case_refuses_cleanly(backend): + await backend.layout_create(SHEET) + result = await backend.layout_create(SHEET.upper()) + assert result["ok"] is False + assert _names(backend).count(SHEET) == 1 + + +@pytest.mark.parametrize( + "call", + [ + lambda b, name: b.layout_delete(name), + lambda b, name: b.layout_set_current(name), + lambda b, name: b.viewport_list(name), + lambda b, name: b.layout_copy(name, "Copy-Of-It"), + ], + ids=["delete", "set_current", "viewport_list", "copy_source"], +) +async def test_an_existing_layout_is_found_whatever_case_it_is_typed_in(backend, call): + await backend.layout_create(SHEET) + await backend.layout_create("Spare") # so delete is not refused as the last sheet + + result = await call(backend, SHEET.upper()) + + assert result["ok"] is True, f"{SHEET.upper()} must resolve to {SHEET}" + + +@pytest.mark.parametrize( + "call", + [ + lambda b, name: b.layout_rename("Spare", name), + lambda b, name: b.layout_copy("Spare", name), + ], + ids=["rename_target", "copy_target"], +) +async def test_a_target_name_colliding_only_by_case_refuses_cleanly(backend, call): + await backend.layout_create(SHEET) + await backend.layout_create("Spare") + + result = await call(backend, SHEET.upper()) + + assert result["ok"] is False + assert "Spare" in _names(backend) + + +async def test_a_layout_can_be_renamed_to_a_different_spelling_of_itself(backend): + """Re-casing a name is a rename onto itself, not a collision.""" + await backend.layout_create(SHEET) + result = await backend.layout_rename(SHEET, SHEET.upper()) + assert result["ok"] is True + assert SHEET.upper() in _names(backend) + + +# ── layout_copy ───────────────────────────────────────────────────────────── + + +async def test_a_copy_carries_the_geometry_and_leaves_the_source_alone(backend): + await backend.layout_create(SHEET) + await backend.layout_set_current(SHEET) + await backend.entity_create_line(0, 0, 100, 0) + await backend.entity_create_circle(50, 50, 10) + await backend.layout_set_current("Model") + + result = await backend.layout_copy(SHEET, OTHER) + + assert result["ok"] is True + assert result["entities_copied"] == 2 + assert result["skipped"] == [] + assert _entities_in(backend, OTHER) == _entities_in(backend, SHEET) + + +async def test_the_copy_is_independent_of_its_source(backend): + await backend.layout_create(SHEET) + await backend.layout_set_current(SHEET) + await backend.entity_create_line(0, 0, 100, 0) + await backend.layout_set_current("Model") + await backend.layout_copy(SHEET, OTHER) + + await backend.layout_delete(SHEET) + + assert _entities_in(backend, OTHER) == ["LINE"] + + +async def test_the_copy_survives_a_save_and_reload_with_a_clean_audit(backend, tmp_path): + """The Auditor is not evidence on its own, so check the structure too.""" + import ezdxf + from ezdxf.audit import Auditor + + await backend.layout_create(SHEET) + await backend.layout_set_current(SHEET) + await backend.entity_create_line(0, 0, 100, 0) + await backend.layout_set_current("Model") + await backend.layout_copy(SHEET, OTHER) + + path = tmp_path / "copied.dxf" + await backend.drawing_save_as(str(path)) + doc = ezdxf.readfile(str(path)) + + auditor = Auditor(doc) + auditor.run() + assert not auditor.errors and not auditor.fixes + assert OTHER in doc.layouts.names() + assert [e.dxftype() for e in doc.layouts.get(OTHER)] == ["LINE"] + + +async def test_no_two_layouts_end_up_sharing_a_tab_order(backend): + """A delete leaves a gap; letting new() assign taborder duplicates it.""" + await backend.layout_create(SHEET) + await backend.layout_create("Scratch") + await backend.layout_delete("Scratch") + await backend.layout_copy(SHEET, OTHER) + + orders = [backend._doc.layouts.get(n).dxf.taborder for n in _names(backend) if n != "Model"] + assert len(orders) == len(set(orders)), f"duplicate taborder: {orders}" + + +async def test_the_copy_keeps_the_source_paper_size(backend): + await backend.layout_create(SHEET) + source = backend._doc.layouts.get(SHEET) + source.page_setup(size=(420, 297), margins=(0, 0, 0, 0), units="mm") + expected = source.get_paper_limits() + + await backend.layout_copy(SHEET, OTHER) + + assert backend._doc.layouts.get(OTHER).get_paper_limits() == expected + + +async def test_copying_model_space_is_refused(backend): + """Otherwise the construction happily dumps model geometry onto a sheet.""" + result = await backend.layout_copy("Model", OTHER) + assert result["ok"] is False + assert OTHER not in _names(backend) + + +async def test_a_blank_source_does_not_resolve_to_whatever_sheet_is_active(backend): + """ezdxf's `layouts.get("")` returns the active paper space.""" + result = await backend.layout_copy("", OTHER) + assert result["ok"] is False + assert OTHER not in _names(backend) + + +async def test_copying_onto_an_existing_name_is_refused(backend): + await backend.layout_create(SHEET) + await backend.layout_create(OTHER) + result = await backend.layout_copy(SHEET, OTHER) + assert result["ok"] is False + + +async def test_an_associative_hatch_does_not_keep_pointing_into_the_source(backend): + """A dangling cross-layout boundary reference the Auditor cannot see.""" + from ezdxf.math import Vec3 + + await backend.layout_create(SHEET) + space = backend._doc.layouts.get(SHEET) + poly = space.add_lwpolyline([(0, 0), (10, 0), (10, 10), (0, 10)], close=True) + hatch = space.add_hatch(color=2) + path = hatch.paths.add_polyline_path( + [(Vec3(v[0], v[1]).x, Vec3(v[0], v[1]).y) for v in [(0, 0), (10, 0), (10, 10), (0, 10)]], + is_closed=True, + ) + path.source_boundary_objects = [poly.dxf.handle] + hatch.set_seed_points([(5, 5)]) + + result = await backend.layout_copy(SHEET, OTHER) + assert result["ok"] is True + + source_handles = {e.dxf.handle for e in backend._doc.layouts.get(SHEET)} + for entity in backend._doc.layouts.get(OTHER): + if entity.dxftype() != "HATCH": + continue + for boundary in entity.paths: + leaked = set(getattr(boundary, "source_boundary_objects", [])) & source_handles + assert not leaked, f"copy still points at the source layout: {leaked}" + + +async def test_the_copy_does_not_end_up_with_two_main_viewports(backend): + """Copying a page-set-up layout duplicates the id-1 viewport silently.""" + await backend.layout_create(SHEET) + source = backend._doc.layouts.get(SHEET) + source.page_setup(size=(420, 297), margins=(0, 0, 0, 0), units="mm") + + await backend.layout_copy(SHEET, OTHER) + + target = backend._doc.layouts.get(OTHER) + mains = [vp for vp in target.viewports() if vp.dxf.get("id", 0) == 1] + assert len(mains) <= 1, "a layout may have at most one main viewport" + if mains: + assert target.dxf.viewport_handle == mains[0].dxf.handle diff --git a/tests/test_layouts_solids.py b/tests/test_layouts_solids.py index 28bc792..4e56ce8 100644 --- a/tests/test_layouts_solids.py +++ b/tests/test_layouts_solids.py @@ -10,6 +10,7 @@ import pytest_asyncio import config import server +from backends.base import UnsupportedCapabilityError from backends.ezdxf_backend import EzdxfBackend pytestmark = pytest.mark.asyncio @@ -100,20 +101,35 @@ async def test_export_pdf_layout(backend, tmp_path): async def test_solids_unsupported_headlessly(backend): - box = await backend.solid_box(0, 0, 0, 10, 10, 10) - cylinder = await backend.solid_cylinder(0, 0, 0, 5, 10) - extrude = await backend.solid_extrude("FF", 10) - revolve = await backend.solid_revolve("FF", 0, 0, 0, 1) - boolean = await backend.solid_boolean("FF", "FE", "union") - for result in (box, cylinder, extrude, revolve, boolean): - assert result["ok"] is False - assert result["capability"] == "solid_3d" + """T0.7: these refuse by *raising*, so the refusal middleware can see them. + + They used to return ``{"ok": False, "capability": "solid_3d"}``, which left + ``is_error`` False on a call that did nothing and put the refusal outside + the one place that turns a capability boundary into a wire payload. The + payload itself is unchanged — ``to_dict()`` still yields exactly what the + old return value did. + """ + calls = [ + backend.solid_box(0, 0, 0, 10, 10, 10), + backend.solid_cylinder(0, 0, 0, 5, 10), + backend.solid_extrude("FF", 10), + backend.solid_revolve("FF", 0, 0, 0, 1), + backend.solid_boolean("FF", "FE", "union"), + ] + for call in calls: + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await call + assert excinfo.value.capability == "solid_3d" + assert excinfo.value.to_dict()["ok"] is False + assert excinfo.value.to_dict()["capability"] == "solid_3d" async def test_capability_map_reports_paper_space_and_solids(backend): features = backend.capabilities().to_dict()["features"] assert features["paper_space"]["supported"] is True - assert features["viewport_render"]["supported"] is False + # Not False: the headless renderer does project model content through + # viewports. See tests/test_viewport_ops.py for the measurement. + assert features["viewport_render"]["supported"] is True assert features["solid_3d"]["supported"] is False diff --git a/tests/test_measure.py b/tests/test_measure.py new file mode 100644 index 0000000..d815944 --- /dev/null +++ b/tests/test_measure.py @@ -0,0 +1,200 @@ +"""T0.5 — measuring the drawing, not the caller's memory of it. + +``analysis_measure_area`` never touched the document: it ran a shoelace over +points the *caller* typed in. So "what is the area of the polyline I just drew?" +had no answer — the model had to recall the vertices, which is precisely what +CLAUDE.md's no-coordinate-guessing rule forbids. + +The only workflow available was worse than absent. ``entity_get`` reported +LWPOLYLINE vertices through the default ``get_points()`` format and kept just +x and y, silently dropping the **bulge** that makes an edge an arc. Measured on a +100x100 square with one edge bulged into an outward semicircle: shoelace over the +reported points gives 10000 against a true 13926.9908 — **28.20% low** — and the +perimeter 400 against 457.0796, **12.49% low**, with nothing in the response +saying so. ``props["length"]`` was dead code: ``LWPolyline`` has no ``length()`` +method, so the line raised into a debug log and the key simply never appeared. + +Bulge geometry is always a circular arc, so this needs no approximation: area is +the shoelace plus each arc's signed circular segment, and perimeter uses arc +length. The tests below check that against two independent oracles — closed-form +values worked out by hand, and ezdxf's own flattening at a fine tolerance. +""" + +from __future__ import annotations + +import math + +import pytest + +from engineering.measure import ( + ellipse_area, + ellipse_perimeter_ramanujan, + is_self_intersecting, + polygon_area_perimeter, +) + +#: 100x100 square, bottom edge bulged outward into a semicircle (bulge=1.0 means +#: a 180 deg sweep, so the chord of 100 is the diameter and the radius is 50). +SQUARE_WITH_SEMICIRCLE = [ + (0.0, 0.0, 0.0), + (100.0, 0.0, 1.0), + (100.0, 100.0, 0.0), + (0.0, 100.0, 0.0), +] +SEMICIRCLE_AREA = 10000.0 + math.pi * 50.0**2 / 2.0 # 13926.990817 +SEMICIRCLE_PERIMETER = 300.0 + math.pi * 50.0 # 457.079633 + +PLAIN_SQUARE = [(0.0, 0.0, 0.0), (100.0, 0.0, 0.0), (100.0, 100.0, 0.0), (0.0, 100.0, 0.0)] + + +def _flattened_oracle(vertices, closed, segments_per_arc=200_000): + """Area and perimeter from a dense polyline approximation of the same shape. + + Independent of the implementation under test: it subdivides each arc and + runs a plain shoelace, so agreement to 6 decimal places is real evidence + rather than a formula checking itself. + """ + points: list[tuple[float, float]] = [] + count = len(vertices) + limit = count if closed else count - 1 + for i in range(limit): + x1, y1, bulge = vertices[i] + x2, y2, _ = vertices[(i + 1) % count] + points.append((x1, y1)) + if abs(bulge) < 1e-12: + continue + theta = 4.0 * math.atan(bulge) + chord = math.hypot(x2 - x1, y2 - y1) + radius = chord / (2.0 * math.sin(abs(theta) / 2.0)) + # Centre lies on the perpendicular bisector, offset by the sagitta side. + mx, my = (x1 + x2) / 2.0, (y1 + y2) / 2.0 + height = radius * math.cos(abs(theta) / 2.0) + ux, uy = (y2 - y1) / chord, -(x2 - x1) / chord + sign = 1.0 if theta > 0 else -1.0 + cx, cy = mx - sign * ux * height, my - sign * uy * height + a1 = math.atan2(y1 - cy, x1 - cx) + steps = max(2, segments_per_arc // max(1, limit)) + for step in range(1, steps): + angle = a1 + theta * step / steps + points.append((cx + radius * math.cos(angle), cy + radius * math.sin(angle))) + if not closed: + points.append((vertices[-1][0], vertices[-1][1])) + + total = len(points) + shoelace = sum( + points[i][0] * points[(i + 1) % total][1] - points[(i + 1) % total][0] * points[i][1] + for i in range(total) + ) + perimeter = sum( + math.dist(points[i], points[(i + 1) % total]) for i in range(total if closed else total - 1) + ) + return abs(shoelace) / 2.0, perimeter + + +# ── the arithmetic the server was missing ─────────────────────────────────── + + +def test_a_straight_polygon_is_the_plain_shoelace(): + area, perimeter = polygon_area_perimeter(PLAIN_SQUARE, closed=True) + assert area == pytest.approx(10000.0) + assert perimeter == pytest.approx(400.0) + + +def test_an_outward_bulge_adds_its_circular_segment(): + """The 28.20% the old path lost, recovered exactly.""" + area, perimeter = polygon_area_perimeter(SQUARE_WITH_SEMICIRCLE, closed=True) + assert area == pytest.approx(SEMICIRCLE_AREA, rel=1e-12) + assert perimeter == pytest.approx(SEMICIRCLE_PERIMETER, rel=1e-12) + # ...and it really is 28% more than the xy-only answer. + assert area / 10000.0 == pytest.approx(1.3926990817, rel=1e-9) + + +def test_an_inward_bulge_subtracts_it(): + inward = [(0.0, 0.0, 0.0), (100.0, 0.0, -0.5), (100.0, 100.0, 0.0), (0.0, 100.0, 0.0)] + area, perimeter = polygon_area_perimeter(inward, closed=True) + + oracle_area, oracle_perimeter = _flattened_oracle(inward, closed=True) + assert area == pytest.approx(oracle_area, rel=1e-9) + assert perimeter == pytest.approx(oracle_perimeter, rel=1e-7) + assert area < 10000.0, "an inward bulge must remove material" + + +def test_a_bulge_matches_a_dense_flattening_of_the_same_arc(): + area, perimeter = polygon_area_perimeter(SQUARE_WITH_SEMICIRCLE, closed=True) + oracle_area, oracle_perimeter = _flattened_oracle(SQUARE_WITH_SEMICIRCLE, closed=True) + assert area == pytest.approx(oracle_area, rel=1e-9) + assert perimeter == pytest.approx(oracle_perimeter, rel=1e-7) + + +def test_an_open_boundary_is_closed_implicitly(bulge_free=PLAIN_SQUARE): + """AutoCAD's AREA closes an open boundary; the caller is told it happened.""" + open_area, open_perimeter = polygon_area_perimeter(bulge_free, closed=False) + assert open_area == pytest.approx(10000.0), "the area is of the closed region" + assert open_perimeter == pytest.approx(300.0), "the walked length is the open one" + + +# ── the trap shoelace hides ───────────────────────────────────────────────── + + +def test_a_self_intersecting_loop_is_detected(): + """Shoelace CANCELS crossed lobes: a bowtie of two 2500 triangles reads 0.""" + bowtie = [(0.0, 0.0, 0.0), (100.0, 100.0, 0.0), (100.0, 0.0, 0.0), (0.0, 100.0, 0.0)] + area, _ = polygon_area_perimeter(bowtie, closed=True) + assert area == pytest.approx(0.0, abs=1e-9), "this is exactly why it must be flagged" + assert is_self_intersecting([(x, y) for x, y, _ in bowtie]) is True + + +def test_a_simple_polygon_is_not_flagged(): + assert is_self_intersecting([(x, y) for x, y, _ in PLAIN_SQUARE]) is False + + +def test_self_intersection_reports_unknown_rather_than_guessing_on_huge_loops(): + """The check is O(n^2); above the cap it must answer None, not False.""" + from engineering.measure import MAX_SELF_INTERSECT_VERTS + + huge = [(float(i), float(i % 7), 0.0) for i in range(MAX_SELF_INTERSECT_VERTS + 1)] + assert is_self_intersecting([(x, y) for x, y, _ in huge]) is None + + +# ── ellipses ──────────────────────────────────────────────────────────────── + + +def test_ellipse_area_is_exact_and_perimeter_is_flagged_approximate(): + assert ellipse_area(10.0, 0.5) == pytest.approx(math.pi * 10.0 * 5.0) + # A circle is the one ellipse whose perimeter is closed-form, so Ramanujan + # must be exact there — a cheap check that the approximation is wired right. + assert ellipse_perimeter_ramanujan(10.0, 1.0) == pytest.approx(2 * math.pi * 10.0, rel=1e-12) + + +# ── the two-vertex circle ─────────────────────────────────────────────────── + + +def test_a_circle_stored_as_two_bulged_vertices_has_its_real_area(): + """DXF's most compact circle, and the arithmetic answered 0.0 for it. + + Two semicircular arcs joined into a circle store as a closed LWPOLYLINE of + *two* vertices, each bulge 1.0 — what AutoCAD writes for a JOINed pair of + arcs, and what `boundary_from_entities` builds from any two-arc loop. The + `count < 3` guard treated that as degenerate and returned zero area, which + is the same silent-nothing this module exists to stop: the shoelace over + two points is genuinely 0, but the two circular segments are the whole + shape and they were never added. + """ + circle = [(-10.0, 0.0, 1.0), (10.0, 0.0, 1.0)] + + area, perimeter = polygon_area_perimeter(circle, closed=True) + + assert area == pytest.approx(math.pi * 100.0, rel=1e-12) + assert perimeter == pytest.approx(2 * math.pi * 10.0, rel=1e-12) + + +def test_two_straight_vertices_still_enclose_nothing(): + """The fix must not invent area where there is none.""" + area, perimeter = polygon_area_perimeter([(0.0, 0.0, 0.0), (10.0, 0.0, 0.0)], closed=True) + + assert area == 0.0 + assert perimeter == pytest.approx(20.0), "there and back" + + +def test_a_single_vertex_is_still_degenerate(): + assert polygon_area_perimeter([(0.0, 0.0, 1.0)], closed=True) == (0.0, 0.0) diff --git a/tests/test_ocs_extrusion.py b/tests/test_ocs_extrusion.py new file mode 100644 index 0000000..933ad59 --- /dev/null +++ b/tests/test_ocs_extrusion.py @@ -0,0 +1,274 @@ +"""T0.4 — coordinates that cross the MCP boundary are WCS, on both engines. + +DXF does not store 2D geometry in world coordinates. A CIRCLE, ARC, LWPOLYLINE, +TEXT or INSERT lives in an Object Coordinate System derived from its ``extrusion`` +vector by the Arbitrary Axis Algorithm. Mirroring flips the extrusion to +``(0, 0, -1)`` and leaves the stored x *unnegated*, so reading ``dxf.center`` +raw returns a point the entity is not at. + +The word "extrusion" appeared nowhere in this repository, so every such read was +wrong and silent. Measured: after the server's own ``entity_mirror`` across the Y +axis, a circle drawn at (30, 20) reported ``center [30.0, 20.0]`` while the *same +response's* ``bounding_box`` said ``min [-35, 15] / max [-25, 25]`` — the payload +contradicted itself by 60 mm, and ``point_from_snap``, ``entity_select_smart`` and +``entity_offset`` all inherited the error. + +Two fences guard the obvious wrong fixes: + +* ``test_wcs_entities_are_left_alone`` — LINE, MTEXT, ELLIPSE and SPLINE all carry + an ``extrusion`` attribute too, and are already correct because ezdxf makes + their ``ocs()`` a pass-through. Keying the fix off "has extrusion" breaks them. +* ``test_read_write_round_trip_is_a_no_op_on_a_mirrored_circle`` — the read and + write paths were *consistently* wrong, so normalising reads without the write + inverse would turn a harmless no-op into a real 60 mm teleport. +""" + +from __future__ import annotations + +import pytest + +from backends.base import UnsupportedCapabilityError +from backends.com_backend import ComBackend +from backends.ezdxf_backend import EzdxfBackend + +pytestmark = pytest.mark.asyncio + +#: Mirror line = the Y axis, i.e. x -> -x. This is what produces extrusion (0,0,-1). +Y_AXIS = (0.0, 0.0, 0.0, 1.0) + + +def _assert_points(actual, expected) -> None: + """pytest.approx refuses nested sequences, so compare point by point.""" + assert len(actual) == len(expected), f"{actual} != {expected}" + for got, want in zip(actual, expected, strict=True): + assert got == pytest.approx(want, abs=1e-9), f"{actual} != {expected}" + + +def _bbox_centre(info) -> tuple[float, float]: + """Centre of the entity's own bounding box — an independent oracle. + + ``ezdxf.bbox.extents`` flattens the entity through the same renderer AutoCAD + would, so it is WCS whatever the entity's frame is. It is the field that + exposed the contradiction, and it is not computed from the OCS attributes. + """ + box = info.properties["bounding_box"] + return ( + (box["min"][0] + box["max"][0]) / 2.0, + (box["min"][1] + box["max"][1]) / 2.0, + ) + + +# ── the fence: entities that were never wrong ─────────────────────────────── + + +async def test_wcs_entities_are_left_alone(backend): + """LINE/MTEXT/ELLIPSE/SPLINE carry extrusion but report WCS already.""" + line = await backend.entity_create_line(10, 5, 30, 5) + ellipse = await backend.entity_create_ellipse(20, 40, 10, 5) + text = await backend.entity_create_mtext("hello", 25, 60, 2.5) + + for created in (line, ellipse, text): + mirrored = await backend.entity_mirror(created.handle, *Y_AXIS) + info = await backend.entity_get(mirrored.handle) + cx, cy = _bbox_centre(info) + for key in ("start", "end", "center", "insertion"): + point = info.properties.get(key) + if point is None: + continue + assert point[0] < 0, f"{info.type}.{key} must land left of x=0 after mirroring" + if "center" in info.properties: + assert info.properties["center"] == pytest.approx([cx, cy], abs=1e-6) + + +# ── the defect ────────────────────────────────────────────────────────────── + + +async def test_entity_get_center_agrees_with_its_own_bounding_box(backend): + """A response that contradicts itself is the clearest possible proof.""" + circle = await backend.entity_create_circle(30, 20, 5) + mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS) + + info = await backend.entity_get(mirrored.handle) + assert info.properties["center"] == pytest.approx([-30.0, 20.0]) + assert info.properties["center"] == pytest.approx(list(_bbox_centre(info)), abs=1e-6) + assert info.properties["radius"] == pytest.approx(5.0) + + +async def test_mirrored_arc_reports_wcs_centre_and_sweep(backend): + """A left-handed frame reverses the sweep, so start/end also swap.""" + arc = await backend.entity_create_arc(30, 20, 5, 0, 90) + mirrored = await backend.entity_mirror(arc.handle, *Y_AXIS) + + info = await backend.entity_get(mirrored.handle) + assert info.properties["center"] == pytest.approx([-30.0, 20.0]) + + # Mirroring the first quadrant across x=0 gives the second: the bbox says the + # arc occupies x in [-35, -30], y in [20, 25], i.e. 90 deg -> 180 deg. + assert info.properties["start_angle"] == pytest.approx(90.0, abs=1e-6) + assert info.properties["end_angle"] == pytest.approx(180.0, abs=1e-6) + # Length is frame-invariant and must not change. + assert info.properties["length"] == pytest.approx(5 * 3.141592653589793 / 2, rel=1e-9) + + +async def test_mirrored_polyline_points_are_wcs(backend): + poly = await backend.entity_create_polyline([[10, 0], [20, 0], [20, 10]], closed=False) + mirrored = await backend.entity_mirror(poly.handle, *Y_AXIS) + + info = await backend.entity_get(mirrored.handle) + _assert_points(info.properties["points"], [[-10, 0], [-20, 0], [-20, 10]]) + + +async def test_mirrored_text_insertion_is_wcs(backend): + text = await backend.entity_create_text("PART A", 40, 15, 3.0) + mirrored = await backend.entity_mirror(text.handle, *Y_AXIS) + + info = await backend.entity_get(mirrored.handle) + assert info.properties["insertion"] == pytest.approx([-40.0, 15.0]) + + +# ── the trap: reads must not be normalised without the write inverse ──────── + + +async def test_read_write_round_trip_is_a_no_op_on_a_mirrored_circle(backend): + """Read the centre, write it straight back: the circle must not move. + + Before the fix this passed for the wrong reason — read and write were + *consistently* wrong. It is here so that fixing only the read is caught. + """ + circle = await backend.entity_create_circle(30, 20, 5) + mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS) + + before = await backend.entity_get(mirrored.handle) + centre = before.properties["center"] + await backend.entity_edit_geometry(mirrored.handle, cx=centre[0], cy=centre[1]) + after = await backend.entity_get(mirrored.handle) + + assert after.properties["center"] == pytest.approx(centre) + assert after.properties["bounding_box"] == before.properties["bounding_box"] + + +async def test_entity_edit_geometry_moves_a_mirrored_circle_where_asked(backend): + circle = await backend.entity_create_circle(30, 20, 5) + mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS) + + await backend.entity_edit_geometry(mirrored.handle, cx=-10.0, cy=40.0) + + info = await backend.entity_get(mirrored.handle) + assert info.properties["center"] == pytest.approx([-10.0, 40.0]) + assert info.properties["center"] == pytest.approx(list(_bbox_centre(info)), abs=1e-6) + + +async def test_entity_offset_on_a_mirrored_circle_is_concentric_and_shrinks(backend): + """The offset used to land 60 mm away *and* grow instead of shrink.""" + circle = await backend.entity_create_circle(30, 20, 10) + mirrored = await backend.entity_mirror(circle.handle, *Y_AXIS) + + inner = await backend.entity_offset(mirrored.handle, 2.0, side_x=-30.0, side_y=20.0) + + info = await backend.entity_get(inner.handle) + assert info.properties["center"] == pytest.approx([-30.0, 20.0]) + assert info.properties["radius"] == pytest.approx(8.0), "offset toward the centre shrinks" + + +# ── tilted planes: the one case 2D xy cannot address ──────────────────────── + + +def _tilt(backend, handle, normal=(0.0, 0.6, 0.8)) -> None: + """Put an entity in a plane that is not parallel to WCS XY.""" + backend._doc.entitydb.get(handle).dxf.extrusion = normal + + +async def test_a_tilted_entity_omits_what_xy_cannot_express(backend): + """A tilted circle projects to an ellipse, so `radius` has no WCS-XY meaning. + + Omitting the field is silence; reporting 5.0 for a footprint of 5.0 x 3.99 + would be a fresh silent wrong number of exactly the kind this release exists + to remove. + """ + circle = await backend.entity_create_circle(30, 20, 5) + _tilt(backend, circle.handle) + + info = await backend.entity_get(circle.handle) + assert info.properties["center"] == pytest.approx(list(_bbox_centre(info)), abs=1e-6) + assert "plane_normal" in info.properties + assert info.properties["plane_normal"] == pytest.approx([0.0, 0.6, 0.8]) + assert "radius" not in info.properties + + +async def test_a_tilted_arc_omits_its_angles(backend): + arc = await backend.entity_create_arc(30, 20, 5, 0, 90) + _tilt(backend, arc.handle) + + info = await backend.entity_get(arc.handle) + assert "start_angle" not in info.properties + assert "end_angle" not in info.properties + assert "plane_normal" in info.properties + + +async def test_entity_edit_geometry_refuses_a_tilted_plane_with_capability_shape(backend): + circle = await backend.entity_create_circle(30, 20, 5) + _tilt(backend, circle.handle) + + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await backend.entity_edit_geometry(circle.handle, cx=1.0, cy=2.0) + + assert excinfo.value.capability == "ocs_tilted_plane" + payload = excinfo.value.to_dict() + assert payload["ok"] is False + assert "entity_move" in payload["error"], "a refusal must name the way forward" + + +# ── the boundary is pre-checkable ─────────────────────────────────────────── + + +async def test_ocs_capability_keys_are_declared_on_both_backends(): + for features in ( + EzdxfBackend().capabilities().features, + ComBackend().capabilities().features, + ): + assert features["ocs_normalized"].supported is True + assert features["ocs_normalized"].reason, "the residual must be named, not implied" + assert features["ocs_tilted_plane"].supported is False + + +# ── COM: the one genuinely wrong read on that engine ──────────────────────── + + +def _fake_lwpolyline(coords, normal): + """The minimum surface ``_entity_info`` touches for a lightweight polyline.""" + import types + + return types.SimpleNamespace( + ObjectName="AcDbLWPolyline", + Coordinates=coords, + Normal=normal, + Elevation=0.0, + Handle="2F", + Layer="0", + Color=256, + Linetype="ByLayer", + Visible=True, + Closed=False, + Length=30.0, + GetBoundingBox=lambda: ((0.0, 0.0, 0.0), (1.0, 1.0, 0.0)), + ) + + +async def test_com_lwpolyline_coordinates_are_translated_to_wcs(): + """ActiveX returns WCS for everything *except* polyline Coordinates (OCS). + + Unit-tested against a fake COM object: this machine has a live AutoCAD and + connecting to it is forbidden, so this path ships verified only at this + level — the CHANGELOG says so. + """ + from backends.com_backend import _entity_info + + info = _entity_info(_fake_lwpolyline((10.0, 0.0, 20.0, 0.0, 20.0, 10.0), (0.0, 0.0, -1.0))) + _assert_points(info.properties["points"], [[-10, 0], [-20, 0], [-20, 10]]) + + +async def test_com_lwpolyline_with_identity_normal_is_untouched(): + from backends.com_backend import _entity_info + + info = _entity_info(_fake_lwpolyline((10.0, 0.0, 20.0, 0.0), (0.0, 0.0, 1.0))) + _assert_points(info.properties["points"], [[10, 0], [20, 0]]) diff --git a/tests/test_paper_space.py b/tests/test_paper_space.py new file mode 100644 index 0000000..b399493 --- /dev/null +++ b/tests/test_paper_space.py @@ -0,0 +1,142 @@ +"""M5 — drawing into the layout you selected. + +`layout_set_current("A3-Sheet")` answered `{"ok": true, "current": "A3-Sheet"}` +and then every entity created afterwards went into **model space** anyway, +because `_msp()` returned `doc.modelspace()` unconditionally on both engines. +The tool reported a state change that changed nothing about where geometry +landed — proven: a line drawn straight after selecting the layout was in +modelspace and not in the layout. + +That is also why the title block could not go on a sheet: it draws through the +same `entity_create_*` calls, so `apply_iso_a3_titleblock` could only ever +produce a border around model-space geometry. +""" + +from __future__ import annotations + +import pytest + +pytestmark = pytest.mark.asyncio + +SHEET = "A3-Sheet" + + +async def _handles_in(backend, layout_name: str) -> set[str]: + doc = backend._doc + space = doc.modelspace() if layout_name == "Model" else doc.layouts.get(layout_name) + return {e.dxf.handle for e in space} + + +# ── the current space is where geometry goes ──────────────────────────────── + + +async def test_entities_land_in_the_layout_that_was_selected(backend): + await backend.layout_create(SHEET) + await backend.layout_set_current(SHEET) + + line = await backend.entity_create_line(0, 0, 100, 0) + + assert line.handle in await _handles_in(backend, SHEET) + assert line.handle not in await _handles_in(backend, "Model") + + +async def test_switching_back_to_model_puts_geometry_back_in_model_space(backend): + await backend.layout_create(SHEET) + await backend.layout_set_current(SHEET) + on_sheet = await backend.entity_create_circle(10, 10, 5) + await backend.layout_set_current("Model") + + in_model = await backend.entity_create_circle(20, 20, 5) + + assert on_sheet.handle in await _handles_in(backend, SHEET) + assert in_model.handle in await _handles_in(backend, "Model") + + +async def test_a_new_drawing_starts_in_model_space(backend): + await backend.layout_create(SHEET) + await backend.layout_set_current(SHEET) + await backend.drawing_new() + + line = await backend.entity_create_line(0, 0, 10, 0) + assert line.handle in await _handles_in(backend, "Model") + + +async def test_selecting_a_missing_layout_does_not_move_the_current_space(backend): + """A refused switch must leave the caller where they were, not in limbo.""" + result = await backend.layout_set_current("NOSUCH") + assert result["ok"] is False + + line = await backend.entity_create_line(0, 0, 10, 0) + assert line.handle in await _handles_in(backend, "Model") + + +async def test_entity_get_finds_an_entity_drawn_on_a_layout(backend): + """Lookups must follow the geometry, or the handle becomes unusable.""" + await backend.layout_create(SHEET) + await backend.layout_set_current(SHEET) + line = await backend.entity_create_line(0, 0, 100, 0) + + info = await backend.entity_get(line.handle) + assert info.handle == line.handle + assert info.type == "LINE" + + +# ── the title block, on a sheet ───────────────────────────────────────────── + + +async def test_titleblock_can_be_drawn_on_a_paper_space_layout(backend): + from engineering import TitleBlockMetadata, apply_iso_a3_titleblock + + await backend.layout_create(SHEET) + before_model = await _handles_in(backend, "Model") + + result = await apply_iso_a3_titleblock( + backend, + metadata=TitleBlockMetadata(title="BRACKET", drawing_no="D-001"), + layout=SHEET, + ) + + assert result["ok"] is True + assert result["layout"] == SHEET + on_sheet = await _handles_in(backend, SHEET) + assert len(on_sheet) > 5, "border, inner frame and the block's text all belong here" + assert await _handles_in(backend, "Model") == before_model, "model space untouched" + + +async def test_titleblock_restores_the_space_the_caller_was_in(backend): + from engineering import TitleBlockMetadata, apply_iso_a3_titleblock + + await backend.layout_create(SHEET) + await apply_iso_a3_titleblock( + backend, + metadata=TitleBlockMetadata(title="X", drawing_no="1"), + layout=SHEET, + ) + + line = await backend.entity_create_line(0, 0, 10, 0) + assert line.handle in await _handles_in(backend, "Model"), ( + "drawing a sheet border must not silently move the caller onto the sheet" + ) + + +async def test_titleblock_without_a_layout_still_draws_in_model_space(backend): + """The pre-1.5.0 behaviour stays the default.""" + from engineering import TitleBlockMetadata, apply_iso_a3_titleblock + + result = await apply_iso_a3_titleblock( + backend, metadata=TitleBlockMetadata(title="X", drawing_no="1") + ) + assert result["ok"] is True + assert result["layout"] == "Model" + assert len(await _handles_in(backend, "Model")) > 5 + + +async def test_titleblock_on_a_missing_layout_refuses(backend): + from engineering import TitleBlockMetadata, apply_iso_a3_titleblock + + with pytest.raises(Exception, match="NOSUCH"): + await apply_iso_a3_titleblock( + backend, + metadata=TitleBlockMetadata(title="X", drawing_no="1"), + layout="NOSUCH", + ) diff --git a/tests/test_preflight.py b/tests/test_preflight.py index 3bec70a..8b8f7ac 100644 --- a/tests/test_preflight.py +++ b/tests/test_preflight.py @@ -51,6 +51,50 @@ def test_preflight_normalizes_complete_spec_and_hash_is_deterministic(): assert first.spec_hash == second.spec_hash +def test_preflight_question_text_is_english(): + """drawing_preflight returns this text to every user of a public English + package; it shipped in Turkish through v1.4.""" + result = preflight_drawing("L bracket", requirements={}) + by_code = {q.code: q.question for q in result.questions} + + assert by_code["MISSING_UNITS"] == "What drawing units should be used?" + assert by_code["MISSING_PART_TYPE"] == "What kind of part or drawing is being produced?" + assert by_code["MISSING_DIMENSIONS"] == "What are the main production dimensions?" + assert by_code["MISSING_TOLERANCE_POLICY"] == "Which tolerance policy should apply?" + + +def test_preflight_conflict_message_is_english(): + requirements = _complete_requirements() + requirements["constraints"] = [ + {"field": "dimensions.width", "value": 80}, + {"field": "dimensions.width", "value": 90}, + ] + + result = preflight_drawing("L bracket", requirements=requirements) + + assert result.conflicts[0].message == ("Conflicting values were supplied for dimensions.width.") + + +def test_preflight_user_facing_text_is_ascii(): + """Guard against any future non-English string leaking back into the tool + surface — codes and field names stay machine-readable, prose stays ASCII.""" + requirements = _complete_requirements() + requirements["constraints"] = [ + {"field": "dimensions.width", "value": 80}, + {"field": "dimensions.width", "value": 90}, + ] + empty = preflight_drawing("L bracket", requirements={}) + conflicting = preflight_drawing("L bracket", requirements=requirements) + + prose = [q.question for q in empty.questions] + prose += [choice for q in empty.questions for choice in q.choices] + prose += [c.message for c in conflicting.conflicts] + + assert prose, "expected some user-facing text to check" + for text in prose: + assert text.isascii(), f"non-ASCII user-facing text: {text!r}" + + def test_preflight_detects_conflicting_constraints(): requirements = _complete_requirements() requirements["constraints"] = [ diff --git a/tests/test_refiner.py b/tests/test_refiner.py index 1e0d748..d6e3d60 100644 --- a/tests/test_refiner.py +++ b/tests/test_refiner.py @@ -2,20 +2,24 @@ from __future__ import annotations import pytest +import config from engineering.refiner import RepairRegistry, refine_drawing pytestmark = pytest.mark.asyncio -async def test_transaction_stack_is_separate_from_drawing_undo(backend): +async def test_transaction_stack_is_separate_from_drawing_undo(backend, monkeypatch): + """An undo must not consume a transaction checkpoint, or vice versa.""" + monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 4) + await backend.drawing_new() await backend.entity_create_line(0, 0, 10, 0) await backend.transaction_begin() undo = await backend.drawing_undo() status = await backend.system_status() - assert undo["ok"] is False - assert status["transaction_depth"] == 1 + assert undo["ok"] is True, "undo walks its own history" + assert status["transaction_depth"] == 1, "and leaves the checkpoint standing" await backend.transaction_rollback() diff --git a/tests/test_release_consistency.py b/tests/test_release_consistency.py index a39ffe6..5e3652c 100644 --- a/tests/test_release_consistency.py +++ b/tests/test_release_consistency.py @@ -6,13 +6,28 @@ none of them can drift again: - pyproject version == CHANGELOG top release == README snapshot major.minor - version.py fallback parser returns the pyproject version -- README snapshot tool/resource/prompt counts == live @mcp.* decorator counts +- README snapshot tool/resource/prompt counts == the generated tool inventory - every ``SECTION n: ... (N tools)`` header matches the decorators below it +- server.json version == pyproject, and the README carries the mcp-name marker - every wheel ``only-include`` entry is COPY'd into the Docker image + +Two of these used to count the literal text ``@mcp.tool(`` in server.py, which +made that string load-bearing in prose: an example in a docstring inflated the +count. Neither does any more. + +* The README snapshot counts now come from ``docs/tool-inventory.json``, which + ``tests/test_tool_inventory.py`` holds equal to the live component registry — + so the chain README -> inventory -> registry is closed without this module + having to import the server. +* The section-header gate stays (a "SECTION n" is a fact about server.py's + layout that no runtime registry can see) but counts *parsed* decorators + instead of matched text. """ from __future__ import annotations +import ast +import json import re import tomllib from pathlib import Path @@ -90,31 +105,76 @@ def test_readme_snapshot_matches_pyproject_minor() -> None: def test_readme_snapshot_counts_match_registrations() -> None: - src = _server_source() - tools = len(re.findall(r"@mcp\.tool\(", src)) - resources = len(re.findall(r"@mcp\.resource\(", src)) - prompts = len(re.findall(r"@mcp\.prompt\(", src)) + """README snapshot == the surface, via the generated inventory. + + The counts used to be a regex tally of ``@mcp.tool(`` / ``@mcp.resource(`` / + ``@mcp.prompt(`` in server.py's text. They now come from + ``docs/tool-inventory.json``, which is built from the live component + registry and gated against it by tests/test_tool_inventory.py — so this + still fails when a registration is added without updating the README, but + it can no longer be moved by prose that merely *mentions* a decorator. + """ + inventory = json.loads((ROOT / "docs" / "tool-inventory.json").read_text(encoding="utf-8")) + totals = inventory["totals"] + live = (totals["tools"], totals["resources"], totals["prompts"]) _, readme_tools, readme_resources, readme_prompts = _readme_snapshot() - assert (readme_tools, readme_resources, readme_prompts) == (tools, resources, prompts), ( + assert (readme_tools, readme_resources, readme_prompts) == live, ( f"README snapshot says {readme_tools}/{readme_resources}/{readme_prompts} " - f"(tools/resources/prompts) but server.py registers {tools}/{resources}/{prompts}" + f"(tools/resources/prompts) but the server registers {live[0]}/{live[1]}/{live[2]} " + "(per docs/tool-inventory.json; regenerate it with " + "`python scripts/generate_tool_inventory.py` if that is the stale one)" ) +def _tool_decorator_lines(src: str) -> list[int]: + """Line number of every ``mcp.tool`` decorator, parsed rather than matched. + + Parsing is what keeps the *string* ``@mcp.tool(`` from being load-bearing: + an occurrence in a docstring or comment is not a decorator node, so it + cannot inflate a section's count. + """ + lines: list[int] = [] + for node in ast.walk(ast.parse(src)): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + for decorator in node.decorator_list: + # Both spellings count: `@mcp.tool` and `@mcp.tool(...)`. + target = decorator.func if isinstance(decorator, ast.Call) else decorator + if ( + isinstance(target, ast.Attribute) + and target.attr == "tool" + and isinstance(target.value, ast.Name) + and target.value.id == "mcp" + ): + lines.append(decorator.lineno) + return sorted(lines) + + def test_section_header_counts_match_decorators() -> None: + """Kept, and not replaceable by the inventory: a section is a source-layout + fact (which tools sit under which header comment), invisible to the live + registry, and it does not line up with the tag-derived groups the inventory + records — SECTION 8 alone spans analysis + batch + templates + validation.""" src = _server_source() + lines = src.splitlines() + decorators = _tool_decorator_lines(src) + # Segment boundaries: every SECTION header (counted or not) ends a segment. + boundaries = [ + number + for number, line in enumerate(lines, start=1) + if re.search(r"#[^\n]*SECTION [\w]+:", line) + ] + boundaries.append(len(lines) + 1) headers = [ - (m.start(), m.group(1), m.group(2)) - for m in re.finditer(r"#[^\n]*SECTION ([\w]+):[^\n]*?\((\d+) tools?\)", src) + (number, match.group(1), match.group(2)) + for number, line in enumerate(lines, start=1) + if (match := re.search(r"#[^\n]*SECTION ([\w]+):[^\n]*?\((\d+) tools?\)", line)) ] assert headers, "no counted SECTION headers found in server.py" - # Segment boundaries: every SECTION header (counted or not) ends a segment. - boundaries = sorted(m.start() for m in re.finditer(r"#[^\n]*SECTION [\w]+:", src)) - boundaries.append(len(src)) mismatches: list[str] = [] for start, section_id, declared in headers: end = min(b for b in boundaries if b > start) - actual = len(re.findall(r"@mcp\.tool\(", src[start:end])) + actual = sum(1 for line in decorators if start <= line < end) if actual != int(declared): mismatches.append(f"SECTION {section_id}: header says {declared}, actual {actual}") assert not mismatches, "; ".join(mismatches) @@ -122,8 +182,6 @@ def test_section_header_counts_match_decorators() -> None: def test_server_json_versions_match_pyproject() -> None: """MCP registry manifest must ship the same version as the package.""" - import json - manifest = json.loads((ROOT / "server.json").read_text(encoding="utf-8")) expected = _pyproject_version() assert manifest["version"] == expected, ( @@ -141,8 +199,6 @@ def test_server_json_versions_match_pyproject() -> None: def test_readme_contains_mcp_name_marker() -> None: """PyPI ownership validation for the MCP registry needs this marker in the README (which ships to PyPI as the long description).""" - import json - manifest = json.loads((ROOT / "server.json").read_text(encoding="utf-8")) readme = (ROOT / "README.md").read_text(encoding="utf-8") assert f"mcp-name: {manifest['name']}" in readme diff --git a/tests/test_result_projection.py b/tests/test_result_projection.py new file mode 100644 index 0000000..a28e3be --- /dev/null +++ b/tests/test_result_projection.py @@ -0,0 +1,477 @@ +"""Result shaping — field projection + compact rows on the collection tools. + +The release made *discovery* cheap and left the other half of the token bill +untouched: what the tools hand back. On a 300-entity drawing a single default +``entity_list(limit=100)`` is ~24 kB of JSON the model has to read, and roughly +a third of it is a ``properties.bounding_box`` nobody asked for. + +ONE mechanism, two parameters, spelled identically on every collection tool +(:data:`server.SHAPED_RESULT_TOOLS`):: + + fields=["handle", "type", "layer"] # projection, order preserved + compact=True # columnar envelope instead of dicts + +What these tests guard is the mechanism, not the byte counts: + +* **Backwards compatibility is the hard constraint.** Omitting both parameters + must return exactly the 1.4.0 value. Every pre-existing test in the suite is + the real gate; the test here only pins the intent. +* **No per-tool dialect.** ``SHAPED_RESULT_TOOLS`` is checked against the live + registry in *both* directions, and the two parameters' JSON Schemas are + compared across every tool that carries them. A tool that grows a ``columns=`` + or a ``brief=`` of its own fails here. +* **A typo is an error, never an empty column.** An unknown field name must come + back naming the valid fields. This is the difference between a projection and + a footgun: ``fields=["handel"]`` returning 100 rows of ``{"handel": null}`` + reads as "the drawing has no handles". +* **Truncation honesty.** A silently truncated list read as complete is a + correctness bug, not a token one. ``entity_list`` pages, and in 1.4.0 the + return value carried no signal at all that more rows existed; the two + ``analysis_select_by_*`` tools capped at ``MAX_LIST_LIMIT`` and said so only + in a log notification most clients never show the model. The compact envelope + is the mode that can carry ``total``/``truncated``/``next_offset``, and + ``total`` is measured against the *unfiltered* matching set rather than the + page, which is why the backends gained ``entity_count``. +""" + +from __future__ import annotations + +import json + +import pytest +import pytest_asyncio +from fastmcp import Client +from fastmcp.exceptions import ToolError + +import config +import server +from backends.base import BlockInfo, EntityInfo, LayerInfo + +pytestmark = pytest.mark.asyncio + + +ENTITY_FIELDS = ("handle", "type", "layer", "color", "linetype", "visible", "properties") + + +@pytest_asyncio.fixture +async def client(monkeypatch): + """In-memory client on a fresh headless document. + + The env var is what ``server._make_backend`` reads; setting + ``config.settings.backend`` would leave a Windows box with AutoCAD open + talking to the operator's live drawing. + """ + monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf") + async with Client(server.mcp) as connected: + status = (await connected.call_tool("system_status", {})).structured_content or {} + assert status.get("backend") == "ezdxf", "these tests must never touch live AutoCAD" + await connected.call_tool("drawing_new", {}) + yield connected + + +async def _draw(client, circles: int = 30, lines: int = 30) -> None: + """A small mixed drawing: CIRCLEs carry properties LINEs do not.""" + steps = [] + for i in range(circles): + steps.append( + { + "tool": "entity_create_circle", + "args": {"cx": float(i), "cy": 0.0, "radius": 1.0 + i, "layer": "GEOMETRY"}, + } + ) + for i in range(lines): + steps.append( + { + "tool": "entity_create_line", + "args": { + "x1": 0.0, + "y1": float(i), + "x2": 10.0, + "y2": float(i), + "layer": "HIDDEN", + }, + } + ) + result = (await client.call_tool("cad_batch", {"steps": steps})).structured_content + assert result["failed"] == 0, result + + +def _rows(result) -> list | dict: + """Unwrap FastMCP's ``{"result": ...}`` output wrapper.""" + return (result.structured_content or {})["result"] + + +def dumps(obj) -> str: + """Compact JSON — how a payload is actually charged for.""" + return json.dumps(obj, separators=(",", ":"), ensure_ascii=False) + + +# --------------------------------------------------------------------------- +# The mechanism is one mechanism +# --------------------------------------------------------------------------- + + +async def test_every_shaped_tool_carries_both_parameters(): + tools = {t.name: t for t in await server._registered_tools()} + missing = [name for name in server.SHAPED_RESULT_TOOLS if name not in tools] + assert not missing, f"SHAPED_RESULT_TOOLS names unregistered tools: {missing}" + for name in server.SHAPED_RESULT_TOOLS: + properties = (tools[name].parameters or {}).get("properties", {}) + for param in server.RESULT_SHAPE_PARAMS: + assert param in properties, f"{name} is listed as shaped but has no {param!r} parameter" + + +async def test_no_tool_carries_the_parameters_without_being_declared_shaped(): + """The closed list is checked in both directions, so it cannot silently rot.""" + tools = await server._registered_tools() + carriers = { + tool.name + for tool in tools + if set(server.RESULT_SHAPE_PARAMS) & set((tool.parameters or {}).get("properties", {})) + } + assert carriers == set(server.SHAPED_RESULT_TOOLS) + + +async def test_a_shaped_tool_never_carries_only_half_the_mechanism(): + for tool in await server._registered_tools(): + properties = set((tool.parameters or {}).get("properties", {})) + present = [param for param in server.RESULT_SHAPE_PARAMS if param in properties] + assert len(present) in (0, len(server.RESULT_SHAPE_PARAMS)), ( + f"{tool.name} declares {present} — the two parameters ship together or not at all" + ) + + +async def test_the_parameter_schemas_are_identical_across_every_shaped_tool(): + """One spelling everywhere: no per-tool dialect of the same idea.""" + tools = {t.name: t for t in await server._registered_tools()} + seen: dict[str, list[tuple[str, str]]] = {param: [] for param in server.RESULT_SHAPE_PARAMS} + for name in server.SHAPED_RESULT_TOOLS: + properties = (tools[name].parameters or {})["properties"] + for param in server.RESULT_SHAPE_PARAMS: + seen[param].append((name, dumps(properties[param]))) + for param, entries in seen.items(): + distinct = {schema for _, schema in entries} + assert len(distinct) == 1, f"{param!r} is spelled {len(distinct)} different ways: {entries}" + + +# --------------------------------------------------------------------------- +# Backwards compatibility +# --------------------------------------------------------------------------- + + +async def test_omitting_both_parameters_returns_the_untouched_record(client): + await _draw(client, circles=3, lines=0) + rows = _rows(await client.call_tool("entity_list", {})) + assert isinstance(rows, list) and len(rows) == 3 + assert set(rows[0]) == set(ENTITY_FIELDS), "the default record must keep every field" + assert rows[0]["properties"]["radius"] == pytest.approx(1.0) + + +async def test_explicit_defaults_are_the_same_value_as_omitting_them(client): + await _draw(client, circles=4, lines=4) + omitted = _rows(await client.call_tool("entity_list", {})) + explicit = _rows(await client.call_tool("entity_list", {"fields": None, "compact": False})) + assert omitted == explicit + + +@pytest.mark.parametrize( + "tool,args,spec", + [ + ("layer_list", {}, LayerInfo), + ("block_list", {}, BlockInfo), + ("analysis_select_by_layer", {"layer_name": "GEOMETRY"}, EntityInfo), + ("analysis_select_by_type", {"entity_type": "CIRCLE"}, EntityInfo), + ("analysis_find_in_region", {"x1": -1e6, "y1": -1e6, "x2": 1e6, "y2": 1e6}, EntityInfo), + ("entity_select_smart", {"predicate": {"type": "CIRCLE"}}, EntityInfo), + ], +) +async def test_default_shape_is_a_list_of_full_records(client, tool, args, spec): + await _draw(client, circles=3, lines=3) + rows = _rows(await client.call_tool(tool, args)) + assert isinstance(rows, list) + for row in rows: + assert set(row) == set(spec.__dataclass_fields__) + + +# --------------------------------------------------------------------------- +# Projection +# --------------------------------------------------------------------------- + + +async def test_projection_keeps_only_the_named_fields_in_order(client): + await _draw(client, circles=5, lines=0) + rows = _rows(await client.call_tool("entity_list", {"fields": ["handle", "type"]})) + assert all(list(row) == ["handle", "type"] for row in rows) + assert all(row["type"] == "CIRCLE" for row in rows) + + +async def test_the_projection_order_is_the_callers_not_the_dataclasses(client): + await _draw(client, circles=3, lines=0) + rows = _rows(await client.call_tool("entity_list", {"fields": ["layer", "handle"]})) + assert all(list(row) == ["layer", "handle"] for row in rows) + + +async def test_a_repeated_field_is_projected_once(client): + """Repeats are a caller slip, not an error — but they must not double the column.""" + await _draw(client, circles=2, lines=0) + rows = _rows(await client.call_tool("entity_list", {"fields": ["handle", "handle", "type"]})) + assert all(list(row) == ["handle", "type"] for row in rows) + + +async def test_a_dict_valued_field_can_still_be_taken_whole(client): + await _draw(client, circles=2, lines=0) + rows = _rows(await client.call_tool("entity_list", {"fields": ["properties"]})) + assert all(set(row) == {"properties"} for row in rows) + assert rows[0]["properties"]["radius"] == pytest.approx(1.0) + + +async def test_projection_reaches_one_nested_property(client): + await _draw(client, circles=3, lines=0) + rows = _rows(await client.call_tool("entity_list", {"fields": ["handle", "properties.radius"]})) + assert [row["properties.radius"] for row in rows] == [1.0, 2.0, 3.0] + + +async def test_a_nested_key_absent_from_some_rows_is_null_not_an_error(client): + """LINEs have no radius; the column exists because CIRCLEs in the set do.""" + await _draw(client, circles=2, lines=2) + rows = _rows(await client.call_tool("entity_list", {"fields": ["type", "properties.radius"]})) + by_type = {row["type"]: row["properties.radius"] for row in rows} + assert by_type["CIRCLE"] is not None + assert by_type["LINE"] is None + + +async def test_an_unknown_field_is_an_error_naming_the_valid_fields(client): + await _draw(client, circles=2, lines=0) + with pytest.raises(ToolError) as excinfo: + await client.call_tool("entity_list", {"fields": ["handel"]}) + message = str(excinfo.value) + assert "handel" in message + for field in ENTITY_FIELDS: + assert field in message, f"the error must name {field!r} as a valid field" + + +async def test_an_unknown_nested_key_is_an_error_naming_the_available_keys(client): + await _draw(client, circles=2, lines=0) + with pytest.raises(ToolError) as excinfo: + await client.call_tool("entity_list", {"fields": ["properties.diameter"]}) + message = str(excinfo.value) + assert "diameter" in message + assert "radius" in message, "the error must list the sub-keys this result actually has" + + +async def test_an_empty_projection_is_an_error_not_an_empty_column(client): + await _draw(client, circles=2, lines=0) + with pytest.raises(ToolError) as excinfo: + await client.call_tool("entity_list", {"fields": []}) + assert "handle" in str(excinfo.value) + + +async def test_the_valid_field_list_follows_the_row_dataclass_not_entityinfo(client): + """layer_list rows are LayerInfo, so its error must name LayerInfo's fields.""" + with pytest.raises(ToolError) as excinfo: + await client.call_tool("layer_list", {"fields": ["handle"]}) + message = str(excinfo.value) + assert "is_frozen" in message and "lineweight" in message + assert "linetype" in message + + +async def test_projection_works_on_every_shaped_collection_tool(client): + await _draw(client, circles=3, lines=3) + cases = [ + ("analysis_select_by_layer", {"layer_name": "GEOMETRY"}, ["handle", "type"]), + ("analysis_select_by_type", {"entity_type": "LINE"}, ["handle", "layer"]), + ( + "analysis_find_in_region", + {"x1": -1e6, "y1": -1e6, "x2": 1e6, "y2": 1e6}, + ["handle", "type"], + ), + ("entity_select_smart", {"predicate": {"type": "CIRCLE"}}, ["handle"]), + ("layer_list", {}, ["name", "color"]), + ("block_list", {}, ["name"]), + ] + for tool, args, fields in cases: + rows = _rows(await client.call_tool(tool, {**args, "fields": fields})) + assert isinstance(rows, list), tool + for row in rows: + assert list(row) == fields, tool + + +# --------------------------------------------------------------------------- +# Compact envelope +# --------------------------------------------------------------------------- + + +async def test_compact_returns_a_columnar_envelope(client): + await _draw(client, circles=4, lines=0) + envelope = _rows(await client.call_tool("entity_list", {"compact": True})) + assert isinstance(envelope, dict) + assert set(envelope) == { + "fields", + "rows", + "count", + "offset", + "total", + "truncated", + "next_offset", + } + assert envelope["fields"] == list(ENTITY_FIELDS) + assert envelope["count"] == 4 + assert all(len(row) == len(envelope["fields"]) for row in envelope["rows"]) + + +async def test_compact_rows_carry_the_same_values_as_the_dict_form(client): + await _draw(client, circles=3, lines=2) + fields = ["handle", "type", "layer"] + dicts = _rows(await client.call_tool("entity_list", {"fields": fields})) + envelope = _rows(await client.call_tool("entity_list", {"fields": fields, "compact": True})) + assert envelope["fields"] == fields + assert envelope["rows"] == [[row[name] for name in fields] for row in dicts] + + +async def test_compact_is_much_cheaper_than_the_default_record(client): + """The point of the exercise, guarded as a ratio rather than a byte count.""" + await _draw(client, circles=60, lines=60) + default = dumps(_rows(await client.call_tool("entity_list", {"limit": 120}))) + projected = dumps( + _rows( + await client.call_tool( + "entity_list", + {"limit": 120, "fields": ["handle", "type", "layer"], "compact": True}, + ) + ) + ) + assert len(projected) < len(default) / 5, ( + f"projected+compact was {len(projected)} chars against {len(default)} — " + "the mechanism is not paying for its schema cost" + ) + + +async def test_compact_on_a_shaped_tool_that_already_returns_a_dict(client): + """``selection_get``'s collection is nested, so its envelope lands under it.""" + result = (await client.call_tool("selection_get", {"compact": True})).structured_content + assert isinstance(result["entities"], dict) + assert result["entities"]["fields"] == list(ENTITY_FIELDS) + assert result["entities"]["rows"] == [] + plain = (await client.call_tool("selection_get", {})).structured_content + assert plain["entities"] == [], "the default must stay a plain list" + + +# --------------------------------------------------------------------------- +# Truncation honesty +# --------------------------------------------------------------------------- + + +async def test_a_paged_entity_list_reports_the_total_it_did_not_return(client): + await _draw(client, circles=40, lines=40) + envelope = _rows( + await client.call_tool("entity_list", {"limit": 10, "compact": True, "fields": ["handle"]}) + ) + assert envelope["count"] == 10 + assert envelope["total"] == 80, "total counts the matching set, not the page" + assert envelope["truncated"] is True + assert envelope["next_offset"] == 10 + + +async def test_the_last_page_is_not_reported_as_truncated(client): + await _draw(client, circles=10, lines=0) + envelope = _rows( + await client.call_tool( + "entity_list", {"limit": 10, "offset": 5, "compact": True, "fields": ["handle"]} + ) + ) + assert (envelope["count"], envelope["offset"], envelope["total"]) == (5, 5, 10) + assert envelope["truncated"] is False + assert envelope["next_offset"] is None + + +async def test_total_honours_the_same_filters_as_the_page(client): + await _draw(client, circles=20, lines=30) + envelope = _rows( + await client.call_tool( + "entity_list", + {"type_filter": "LINE", "limit": 5, "compact": True, "fields": ["handle"]}, + ) + ) + assert envelope["total"] == 30, "a filtered page must be measured against the filtered set" + envelope = _rows( + await client.call_tool( + "entity_list", + {"layer_filter": "GEOMETRY", "limit": 5, "compact": True, "fields": ["handle"]}, + ) + ) + assert envelope["total"] == 20 + + +async def test_the_max_list_limit_cap_is_reported_as_truncation(client, monkeypatch): + """``analysis_select_by_layer`` capped silently in the value before this.""" + monkeypatch.setattr(config.settings, "max_list_limit", 4) + await _draw(client, circles=9, lines=0) + envelope = _rows( + await client.call_tool( + "analysis_select_by_layer", + {"layer_name": "GEOMETRY", "compact": True, "fields": ["handle"]}, + ) + ) + assert envelope["count"] == 4 + assert envelope["total"] == 9 + assert envelope["truncated"] is True + assert envelope["next_offset"] == 4 + + +async def test_an_untruncated_selection_says_so(client): + await _draw(client, circles=6, lines=0) + envelope = _rows( + await client.call_tool( + "analysis_select_by_type", + {"entity_type": "CIRCLE", "compact": True, "fields": ["handle"]}, + ) + ) + assert (envelope["count"], envelope["total"]) == (6, 6) + assert envelope["truncated"] is False + + +async def test_default_mode_still_truncates_the_way_it_always_did(client, monkeypatch): + """The compatibility contract cuts both ways: the old value, cap included.""" + monkeypatch.setattr(config.settings, "max_list_limit", 4) + await _draw(client, circles=9, lines=0) + rows = _rows(await client.call_tool("analysis_select_by_layer", {"layer_name": "GEOMETRY"})) + assert isinstance(rows, list) and len(rows) == 4 + + +# --------------------------------------------------------------------------- +# entity_count — the backend method the honest `total` needs +# --------------------------------------------------------------------------- + + +async def test_entity_count_matches_a_full_listing(backend): + for i in range(7): + await backend.entity_create_circle(float(i), 0.0, 1.0, layer="GEOMETRY") + for i in range(5): + await backend.entity_create_line(0.0, float(i), 1.0, float(i), layer="HIDDEN") + assert await backend.entity_count() == 12 + assert await backend.entity_count(type_filter="CIRCLE") == 7 + assert await backend.entity_count(layer_filter="HIDDEN") == 5 + assert await backend.entity_count(type_filter="LINE", layer_filter="GEOMETRY") == 0 + + +async def test_entity_count_filters_case_insensitively_like_entity_list(backend): + await backend.entity_create_circle(0.0, 0.0, 1.0, layer="GEOMETRY") + assert await backend.entity_count(type_filter="circle") == 1 + assert await backend.entity_count(layer_filter="geometry") == 1 + + +async def test_entity_count_is_on_the_backend_contract(): + from backends.base import AutoCADBackend + + assert "entity_count" in AutoCADBackend.__abstractmethods__ + + +async def test_entity_count_never_materialises_the_rows(backend, monkeypatch): + """A count that paged through entity_list would defeat its own purpose.""" + + async def _forbidden(*args, **kwargs): + raise AssertionError("entity_count must not call entity_list") + + monkeypatch.setattr(backend, "entity_list", _forbidden) + await backend.entity_create_circle(0.0, 0.0, 1.0) + assert await backend.entity_count() == 1 diff --git a/tests/test_screenshot_overlay.py b/tests/test_screenshot_overlay.py new file mode 100644 index 0000000..8fe5e5b --- /dev/null +++ b/tests/test_screenshot_overlay.py @@ -0,0 +1,94 @@ +"""M5 — handle grounding: connecting the picture to the handles. + +A screenshot shows the model what the drawing looks like and nothing it can act +on. Every modify tool takes a handle, and there was no way to tell which of the +hex strings from `entity_list` is the circle in the top-left — so the model +either guessed or re-derived the mapping from coordinates, which is exactly what +CLAUDE.md's no-coordinate-guessing rule exists to prevent. + +The overlay labels each entity with its handle at its own bounding-box centre, +so "the circle at the top-left" and "handle 2F" become the same statement. + +Off by default: labels are ink on the drawing, and on a busy sheet they would +bury the geometry they are meant to explain. When there are more entities than +labels allowed, the image says so rather than labelling a subset and letting it +read as the whole picture. +""" + +from __future__ import annotations + +import pytest + +pytestmark = pytest.mark.asyncio + +pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra") + + +def _png_size(data: bytes) -> tuple[int, int]: + """Width/height straight out of the PNG IHDR — no Pillow needed.""" + assert data[:8] == b"\x89PNG\r\n\x1a\n", "not a PNG" + return int.from_bytes(data[16:20], "big"), int.from_bytes(data[20:24], "big") + + +async def test_a_plain_screenshot_is_unchanged(backend): + await backend.entity_create_circle(10, 10, 5) + png = await backend.view_screenshot() + + assert png is not None + assert _png_size(png)[0] > 0 + + +async def test_the_overlay_changes_the_image(backend): + """The labels have to actually be drawn, not merely computed.""" + await backend.entity_create_circle(10, 10, 5) + await backend.entity_create_line(0, 0, 20, 0) + + plain = await backend.view_screenshot() + labelled = await backend.view_screenshot(overlay_handles=True) + + assert labelled is not None + assert labelled != plain, "the overlay produced a byte-identical image" + + +async def test_the_overlay_reports_which_handles_it_drew(backend): + circle = await backend.entity_create_circle(10, 10, 5) + line = await backend.entity_create_line(0, 0, 20, 0) + + report = await backend.view_screenshot_grounded() + + assert set(report["labels"]) == {circle.handle, line.handle} + assert report["labelled"] == 2 + assert report["total"] == 2 + assert report["truncated"] is False + assert report["png"][:8] == b"\x89PNG\r\n\x1a\n" + + +async def test_each_label_sits_on_its_own_entity(backend): + """A label in the wrong place is worse than no label — it grounds a lie.""" + left = await backend.entity_create_circle(-50, 0, 5) + right = await backend.entity_create_circle(50, 0, 5) + + report = await backend.view_screenshot_grounded() + + assert report["labels"][left.handle][0] == pytest.approx(-50, abs=1.0) + assert report["labels"][right.handle][0] == pytest.approx(50, abs=1.0) + + +async def test_a_crowded_drawing_says_it_only_labelled_some(backend): + for i in range(30): + await backend.entity_create_circle(i * 10, 0, 2) + + report = await backend.view_screenshot_grounded(max_labels=10) + + assert report["labelled"] == 10 + assert report["total"] == 30 + assert report["truncated"] is True, ( + "labelling a subset silently would let the picture read as the whole drawing" + ) + + +async def test_an_empty_drawing_grounds_nothing_without_failing(backend): + report = await backend.view_screenshot_grounded() + assert report["labels"] == {} + assert report["total"] == 0 + assert report["png"] is not None diff --git a/tests/test_security.py b/tests/test_security.py index 4b06c5b..1a787bf 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -2,10 +2,12 @@ from __future__ import annotations +import os + import pytest from fastmcp.exceptions import ToolError -from security import sanitize_command, sanitize_lisp, validate_path +from security import command_verbs, sanitize_command, sanitize_lisp, validate_path # --------------------------------------------------------------------------- # Path validation tests @@ -276,3 +278,117 @@ class TestSanitizerBypassVectors: def test_lisp_legitimate_not_overblocked(self): assert sanitize_lisp("(setq x (* 2 3))") == "(setq x (* 2 3))" + + +# ── T0.8-A — what the guards actually guard ──────────────────────────────── + + +class TestNonLocalPathAnchors: + r"""Windows accepts several spellings of the same file that compare unequal. + + ``_BLOCKED_PATH_PATTERNS`` rejects the backslash forms, but ``Path.resolve()`` + normalises the *forward-slash* spellings into them afterwards — so + ``//?/C:/Windows/win.ini`` slipped past the pattern check and then compared + unequal to ``C:/Windows`` in the system-directory loop. Proven: it returned a + handle that ``os.path.samefile``'d ``C:\Windows\win.ini``. + """ + + @pytest.mark.skipif(os.name != "nt", reason="drive anchors are a Windows concept") + def test_extended_length_prefix_is_rejected(self): + with pytest.raises(ToolError, match="not a plain local drive path"): + validate_path("//?/C:/Windows/win.ini") + + @pytest.mark.skipif(os.name != "nt", reason="drive anchors are a Windows concept") + def test_unc_forward_slash_form_is_rejected(self): + with pytest.raises(ToolError, match="not a plain local drive path"): + validate_path("//localhost/C$/Windows/win.ini") + + @pytest.mark.skipif(os.name != "nt", reason="drive anchors are a Windows concept") + def test_device_form_still_reaches_the_system_directory_check(self): + """``//./C:/...`` normalises to a plain drive path — it must be caught + by the system-directory rule, not by the anchor rule, or the anchor + check would be masking rather than adding a guard.""" + with pytest.raises(ToolError, match="system directory"): + validate_path("//./C:/Windows/win.ini") + + @pytest.mark.skipif(os.name != "nt", reason="drive anchors are a Windows concept") + def test_an_ordinary_path_is_unaffected(self, tmp_path): + assert validate_path(str(tmp_path / "part.dxf")) + + +class TestLispStringLiteralsAreData: + """A quoted literal is drawing text, not code. + + Reading inside quotes made the server refuse its own domain vocabulary: an + engineering CAD server that rejects ``"Max load 500 kg"`` because the word + "load" appears in a note is broken for the job it exists to do. + """ + + @pytest.mark.parametrize( + "expression", + [ + '(setq desc "Max load 500 kg")', + '(entmakex (list (cons 0 "TEXT") (cons 1 "LOAD CASE 1")))', + '(setq layer "C:CENTER")', + '(setq note "Design load = 12 kN")', + ], + ) + def test_engineering_text_is_not_mistaken_for_code(self, expression): + assert sanitize_lisp(expression) == expression + + @pytest.mark.parametrize( + "expression", + [ + '(load "malicious.lsp")', + '(vl-catch-all-apply (quote command) (list "ERASE"))', + '(startapp "cmd")', + "(eval (read user-input))", + ], + ) + def test_the_dangerous_vectors_still_block(self, expression): + with pytest.raises(ToolError): + sanitize_lisp(expression) + + +class TestCommandVerbScoping: + """Match the command, not the characters. + + ``\b(ERASE|...)\b`` over the whole string refused ``MTEXT 0,0 10,10 Do not + erase this part`` — annotation text, not a command — while missing spellings + AutoCAD accepts. SendCommand takes a macro: segments split on newline or + ``;``, and only a segment's FIRST token is a verb. + """ + + def test_the_repos_own_safe_fixture_still_passes(self): + assert sanitize_command("_ZOOM E") == "_ZOOM E" + + def test_zoom_extents_as_a_macro_passes(self): + """``E`` here is the Extents *option*, not the ERASE alias. Blocking the + canonical zoom-extents macro is why single-letter aliases stay out of + the verb set.""" + assert sanitize_command("_ZOOM\nE\n") == "_ZOOM\nE\n" + + def test_annotation_text_mentioning_a_verb_passes(self): + text = "MTEXT 0,0 10,10 Do not erase this part" + assert sanitize_command(text) == text + + def test_a_linetype_load_macro_passes(self): + macro = "_-LINETYPE _LOAD CENTER acad.lin\n\n" + assert sanitize_command(macro) == macro + + @pytest.mark.parametrize( + "command", + ["ERASE ALL", "_ZOOM E\n_ERASE", "SAVE", "VBASTMT", "ARX", ".SHELL", "'SCRIPT x.scr"], + ) + def test_dangerous_verbs_are_blocked_in_every_decoration(self, command): + with pytest.raises(ToolError): + sanitize_command(command) + + def test_the_refusal_names_the_offending_verb(self): + """Without this a user whose annotation was refused cannot tell why.""" + with pytest.raises(ToolError, match="ERASE"): + sanitize_command("_ZOOM E\n_ERASE") + + def test_command_verbs_strips_every_decorator(self): + assert command_verbs("_-PURGE") == ["PURGE"] + assert command_verbs("'_ZOOM E;.REGEN") == ["ZOOM", "REGEN"] diff --git a/tests/test_selection_filters.py b/tests/test_selection_filters.py new file mode 100644 index 0000000..12b64a9 --- /dev/null +++ b/tests/test_selection_filters.py @@ -0,0 +1,546 @@ +"""F1 — selection filters: window, polygon and property selection. + +Three tools that answer "which entities do you mean?" without the caller having +to memorise handles. Each carries a failure mode that is silent unless it is +pinned here: + +* **window vs crossing** is the whole point of the family. AutoCAD's *window* + returns only entities that are *fully contained*; *crossing* also returns the + ones that merely straddle the edge. Under the hood these are + ``select.bbox_inside`` and ``select.bbox_overlap`` — one character apart in a + call, and getting them backwards produces a plausible-looking handle list that + is quietly the wrong set. Every mode test below builds one entity inside, one + straddling and one outside, and pins exactly which of the three comes back. +* **a zero-area box** (``x1 == x2`` or ``y1 == y2``) can never contain anything. + ``select.Window((0, 0), (0, 100))`` returns ``[]`` — measured — so a tool that + passed it through would answer "nothing is there" to a question that was + really "you asked for nothing". It is refused instead. +* **a polygon is not its bounding box.** A triangle and a square with identical + bounding boxes must not select the same entities; the triangle test uses + exactly that pair so a lazy bbox implementation cannot pass. +* **mirrored geometry** is where stored coordinates and real position diverge. A + mirrored CIRCLE keeps ``center = (30, 20)`` on disk and carries + ``extrusion = (0, 0, -1)``; it is *drawn* at (-30, 20), which is what + ``entity_get`` reports. Selection must agree with the reported position, or + the caller would have to window an entity where nothing is visible. +* **``selection_filter`` takes named parameters, not a query string,** on + purpose. ``layout.query("LINE[nosuchattr=='x']")`` returns ``[]`` silently — + measured — so a typo is indistinguishable from "no matches". Named parameters + make the typo a ``TypeError`` at the tool boundary. The response carries + ``filtered_by`` for the same reason: a caller must be able to tell "I filtered + by layer and found none" from "I never filtered by layer at all". +""" + +from __future__ import annotations + +import pytest + +pytestmark = pytest.mark.asyncio + +# The selection box every window/polygon test uses. +BOX = (0.0, 0.0, 100.0, 100.0) +SQUARE_POLY = [[0.0, 0.0], [100.0, 0.0], [100.0, 100.0], [0.0, 100.0]] +# Same bounding box as SQUARE_POLY, half the area. +TRIANGLE_POLY = [[0.0, 0.0], [100.0, 0.0], [0.0, 100.0]] + + +async def _three_positions(backend): + """One entity fully inside BOX, one straddling its edge, one fully outside. + + Plus a second contained entity on another layer, so a layer filter has + something to remove without emptying the result. + """ + contained = await backend.entity_create_circle(50, 50, 10, layer="GEOMETRY") + contained_other_layer = await backend.entity_create_circle(20, 80, 5, layer="HIDDEN") + straddling = await backend.entity_create_line(90, 50, 110, 50, layer="GEOMETRY") + outside = await backend.entity_create_circle(200, 200, 5, layer="HIDDEN") + return { + "contained": contained.handle, + "contained_other_layer": contained_other_layer.handle, + "straddling": straddling.handle, + "outside": outside.handle, + } + + +async def _mixed_properties(backend): + """Three entities that differ in type, layer, colour, linetype and area.""" + circle = await backend.entity_create_circle(50, 50, 10, layer="GEOMETRY", color=1) + square = await backend.entity_create_polyline( + [[0, 0], [20, 0], [20, 20], [0, 20]], closed=True, layer="GEOMETRY", color=3 + ) + line = await backend.entity_create_line(0, 0, 50, 0, layer="HIDDEN", color=0, linetype="HIDDEN") + return {"circle": circle.handle, "square": square.handle, "line": line.handle} + + +# ── selection_window: the mode contract ───────────────────────────────────── + + +async def test_a_window_returns_only_fully_contained_entities(backend): + """The straddling line is the one that must NOT come back.""" + handles = await _three_positions(backend) + + result = await backend.selection_window(*BOX, mode="window") + + assert result["ok"] is True + assert set(result["handles"]) == { + handles["contained"], + handles["contained_other_layer"], + } + + +async def test_a_crossing_also_returns_entities_that_straddle_the_edge(backend): + """Crossing = window + the straddlers. Never fewer, never the outside one.""" + handles = await _three_positions(backend) + + result = await backend.selection_window(*BOX, mode="crossing") + + assert set(result["handles"]) == { + handles["contained"], + handles["contained_other_layer"], + handles["straddling"], + } + + +async def test_neither_mode_reaches_an_entity_outside_the_box(backend): + handles = await _three_positions(backend) + + window = await backend.selection_window(*BOX, mode="window") + crossing = await backend.selection_window(*BOX, mode="crossing") + + assert handles["outside"] not in window["handles"] + assert handles["outside"] not in crossing["handles"] + + +async def test_crossing_is_a_strict_superset_of_window(backend): + """If these two ever come back equal for this drawing, one of them is wrong.""" + await _three_positions(backend) + + window = set((await backend.selection_window(*BOX, mode="window"))["handles"]) + crossing = set((await backend.selection_window(*BOX, mode="crossing"))["handles"]) + + assert window < crossing + assert len(crossing) - len(window) == 1 + + +async def test_window_is_the_default_mode(backend): + """The safer of the two: contained-only never over-selects.""" + handles = await _three_positions(backend) + + result = await backend.selection_window(*BOX) + + assert result["mode"] == "window" + assert handles["straddling"] not in result["handles"] + + +async def test_the_reported_count_matches_the_handles(backend): + await _three_positions(backend) + + result = await backend.selection_window(*BOX, mode="crossing") + + assert result["count"] == len(result["handles"]) == 3 + assert result["mode"] == "crossing" + + +async def test_the_corners_may_be_given_in_any_order(backend): + """(x2, y2) top-right is a convention, not a requirement of the geometry.""" + await _three_positions(backend) + + normal = await backend.selection_window(0, 0, 100, 100, mode="crossing") + flipped = await backend.selection_window(100, 100, 0, 0, mode="crossing") + + assert set(flipped["handles"]) == set(normal["handles"]) + assert flipped["count"] == normal["count"] == 3 + + +# ── selection_window: refusals ────────────────────────────────────────────── + + +@pytest.mark.parametrize("bad_mode", ["inside", "fence", "WINDOWS", "", "all"]) +async def test_an_unknown_window_mode_is_refused(backend, bad_mode): + """Falling back to a default mode would answer a question nobody asked.""" + await _three_positions(backend) + + result = await backend.selection_window(*BOX, mode=bad_mode) + + assert result["ok"] is False + assert "mode" in result["error"] + + +@pytest.mark.parametrize( + "box", + [ + (10.0, 0.0, 10.0, 100.0), # zero width + (0.0, 40.0, 100.0, 40.0), # zero height + (5.0, 5.0, 5.0, 5.0), # a point + ], +) +async def test_a_zero_area_window_is_refused(backend, box): + """It can never contain anything, so [] would read as "nothing is there".""" + await _three_positions(backend) + + result = await backend.selection_window(*box, mode="crossing") + + assert result["ok"] is False + assert "area" in result["error"] or "zero" in result["error"] + + +# ── selection_window: filters compose with the mode ───────────────────────── + + +async def test_an_entity_type_filter_narrows_the_crossing_set(backend): + """The type filter must cut the crossing set, not replace it.""" + handles = await _three_positions(backend) + + result = await backend.selection_window(*BOX, mode="crossing", entity_type="CIRCLE") + + assert set(result["handles"]) == { + handles["contained"], + handles["contained_other_layer"], + } + assert handles["straddling"] not in result["handles"] + + +async def test_an_entity_type_filter_can_keep_the_straddler_alone(backend): + """Same box, same mode, LINE instead of CIRCLE: the complement comes back.""" + handles = await _three_positions(backend) + + result = await backend.selection_window(*BOX, mode="crossing", entity_type="LINE") + + assert set(result["handles"]) == {handles["straddling"]} + + +async def test_a_layer_filter_composes_with_crossing(backend): + handles = await _three_positions(backend) + + result = await backend.selection_window(*BOX, mode="crossing", layer="GEOMETRY") + + assert set(result["handles"]) == {handles["contained"], handles["straddling"]} + + +async def test_a_layer_filter_composes_with_window(backend): + """Same layer, window mode: the straddler drops out and only one is left.""" + handles = await _three_positions(backend) + + result = await backend.selection_window(*BOX, mode="window", layer="GEOMETRY") + + assert set(result["handles"]) == {handles["contained"]} + + +async def test_type_and_layer_filters_compose_with_each_other(backend): + handles = await _three_positions(backend) + + result = await backend.selection_window( + *BOX, mode="crossing", entity_type="CIRCLE", layer="HIDDEN" + ) + + assert set(result["handles"]) == {handles["contained_other_layer"]} + + +async def test_an_empty_selection_is_ok_with_zero_handles(backend): + """Nothing there is an answer, not an error — unlike a zero-area box.""" + await _three_positions(backend) + + result = await backend.selection_window(500, 500, 600, 600, mode="crossing") + + assert result["ok"] is True + assert result["handles"] == [] + assert result["count"] == 0 + + +# ── selection_window: mirrored geometry ───────────────────────────────────── + + +async def _mirrored_circle(backend): + """A CIRCLE stored at (30, 20) but drawn at (-30, 20). + + Mirroring about the Y axis gives extrusion (0, 0, -1) and leaves the stored + centre untouched — measured. entity_get resolves it to WCS (-30, 20). + """ + source = await backend.entity_create_circle(30, 20, 5, layer="GEOMETRY") + return (await backend.entity_mirror(source.handle, 0, 0, 0, 10, delete_original=True)).handle + + +async def test_a_mirrored_entity_is_selected_where_entity_get_reports_it(backend): + handle = await _mirrored_circle(backend) + reported = await backend.entity_get(handle) + assert reported.properties["center"] == [-30.0, 20.0] + + result = await backend.selection_window(-40, 10, -20, 30, mode="window") + + assert set(result["handles"]) == {handle} + + +async def test_a_mirrored_entity_is_not_selected_at_its_stored_coordinates(backend): + """Selecting at (30, 20) would select an entity nobody can see there.""" + handle = await _mirrored_circle(backend) + + result = await backend.selection_window(20, 10, 40, 30, mode="crossing") + + assert handle not in result["handles"] + assert result["count"] == 0 + + +async def test_the_mirrored_coordinates_really_do_diverge_on_disk(backend, tmp_path): + """Proof the divergence is stored, not an artefact of the in-memory copy.""" + import ezdxf + + handle = await _mirrored_circle(backend) + path = tmp_path / "mirrored.dxf" + await backend.drawing_save_as(str(path)) + + reloaded = ezdxf.readfile(str(path)).entitydb.get(handle) + assert tuple(reloaded.dxf.extrusion) == (0.0, 0.0, -1.0) + assert tuple(reloaded.dxf.center)[:2] == (30.0, 20.0) + + selected = await backend.selection_window(-40, 10, -20, 30, mode="window") + assert selected["handles"] == [handle] + + +# ── selection_polygon ─────────────────────────────────────────────────────── + + +async def test_a_polygon_window_returns_only_fully_contained_entities(backend): + handles = await _three_positions(backend) + + result = await backend.selection_polygon(SQUARE_POLY, mode="window") + + assert result["ok"] is True + assert set(result["handles"]) == { + handles["contained"], + handles["contained_other_layer"], + } + + +async def test_a_polygon_crossing_also_returns_the_straddler(backend): + handles = await _three_positions(backend) + + result = await backend.selection_polygon(SQUARE_POLY, mode="crossing") + + assert set(result["handles"]) == { + handles["contained"], + handles["contained_other_layer"], + handles["straddling"], + } + assert result["mode"] == "crossing" + + +async def test_a_polygon_is_not_its_bounding_box(backend): + """The triangle has exactly SQUARE_POLY's bounding box and half its area. + + Measured: both contained circles sit inside the square but poke out of the + triangle's hypotenuse, so a bbox-only implementation returns two here and + fails. + """ + await _three_positions(backend) + + square = await backend.selection_polygon(SQUARE_POLY, mode="window") + triangle = await backend.selection_polygon(TRIANGLE_POLY, mode="window") + + assert square["count"] == 2 + assert triangle["handles"] == [] + + +async def test_the_triangle_still_reaches_them_in_crossing_mode(backend): + """They poke out of it — which is exactly what crossing is for.""" + handles = await _three_positions(backend) + + result = await backend.selection_polygon(TRIANGLE_POLY, mode="crossing") + + assert set(result["handles"]) == { + handles["contained"], + handles["contained_other_layer"], + } + + +@pytest.mark.parametrize( + "bad", + [[], [[0.0, 0.0]], [[0.0, 0.0], [100.0, 100.0]]], +) +async def test_a_polygon_needs_at_least_three_points(backend, bad): + """Two points enclose no area; a zero-area lasso catches nothing.""" + await _three_positions(backend) + + result = await backend.selection_polygon(bad, mode="crossing") + + assert result["ok"] is False + assert "3" in result["error"] or "three" in result["error"].lower() + + +@pytest.mark.parametrize("bad_mode", ["inside", "fence", ""]) +async def test_an_unknown_polygon_mode_is_refused(backend, bad_mode): + await _three_positions(backend) + + result = await backend.selection_polygon(SQUARE_POLY, mode=bad_mode) + + assert result["ok"] is False + assert "mode" in result["error"] + + +async def test_a_polygon_filter_composes_with_the_mode(backend): + handles = await _three_positions(backend) + + result = await backend.selection_polygon(SQUARE_POLY, mode="crossing", entity_type="LINE") + + assert set(result["handles"]) == {handles["straddling"]} + + +async def test_a_polygon_layer_filter_composes_with_the_mode(backend): + handles = await _three_positions(backend) + + result = await backend.selection_polygon(SQUARE_POLY, mode="window", layer="HIDDEN") + + assert set(result["handles"]) == {handles["contained_other_layer"]} + + +# ── selection_filter: what was actually filtered ──────────────────────────── + + +async def test_no_filters_returns_everything_and_says_it_filtered_by_nothing(backend): + handles = await _mixed_properties(backend) + + result = await backend.selection_filter() + + assert result["ok"] is True + assert set(result["handles"]) == set(handles.values()) + assert result["count"] == 3 + assert list(result["filtered_by"]) == [] + + +async def test_filtering_by_layer_names_the_layer_filter_in_the_response(backend): + handles = await _mixed_properties(backend) + + result = await backend.selection_filter(layer="GEOMETRY") + + assert set(result["handles"]) == {handles["circle"], handles["square"]} + assert set(result["filtered_by"]) == {"layer"} + + +async def test_an_empty_result_is_distinguishable_from_an_unfiltered_one(backend): + """The honesty case: "found none" and "never looked" must not both be []. + + DIM exists and holds nothing, so the handle list is empty either way; only + ``filtered_by`` tells the caller a layer filter ran at all. + """ + await _mixed_properties(backend) + await backend.layer_create("DIM") + + filtered = await backend.selection_filter(layer="DIM") + unfiltered = await backend.selection_filter() + + assert filtered["ok"] is True + assert filtered["count"] == 0 + assert set(filtered["filtered_by"]) == {"layer"} + assert list(unfiltered["filtered_by"]) == [] + assert unfiltered["count"] == 3 + + +async def test_a_typo_in_a_filter_name_is_a_type_error_not_an_empty_list(backend): + """Why this tool takes named parameters instead of a query string. + + ``query("LINE[nosuchattr=='x']")`` answers [] — measured — and the caller + reads it as "no matches". A wrong keyword must fail loudly instead. + """ + await _mixed_properties(backend) + + with pytest.raises(TypeError): + await backend.selection_filter(layar="GEOMETRY") + + +async def test_every_applied_filter_is_reported_not_just_the_first(backend): + handles = await _mixed_properties(backend) + + result = await backend.selection_filter(layer="GEOMETRY", entity_type="CIRCLE") + + assert set(result["handles"]) == {handles["circle"]} + assert set(result["filtered_by"]) == {"layer", "entity_type"} + + +# ── selection_filter: the individual filters ──────────────────────────────── + + +async def test_filtering_by_entity_type(backend): + handles = await _mixed_properties(backend) + + result = await backend.selection_filter(entity_type="LWPOLYLINE") + + assert set(result["handles"]) == {handles["square"]} + assert set(result["filtered_by"]) == {"entity_type"} + + +async def test_filtering_by_colour(backend): + handles = await _mixed_properties(backend) + + result = await backend.selection_filter(color=3) + + assert set(result["handles"]) == {handles["square"]} + assert set(result["filtered_by"]) == {"color"} + + +async def test_colour_zero_is_byblock_not_no_filter(backend): + """ACI 0 is ByBlock, a real colour. ``if color:`` would drop this filter. + + The failure it guards is the worst kind: the caller asks for ByBlock and + gets the entire drawing back, reported as a successful colour filter. + """ + handles = await _mixed_properties(backend) + + result = await backend.selection_filter(color=0) + + assert set(result["handles"]) == {handles["line"]} + assert set(result["filtered_by"]) == {"color"} + + +async def test_filtering_by_linetype(backend): + handles = await _mixed_properties(backend) + + result = await backend.selection_filter(linetype="HIDDEN") + + assert set(result["handles"]) == {handles["line"]} + assert set(result["filtered_by"]) == {"linetype"} + + +async def test_min_area_keeps_only_the_shapes_that_are_big_enough(backend): + """Measured: the square encloses 400, the circle 314.16.""" + handles = await _mixed_properties(backend) + + result = await backend.selection_filter(min_area=350.0) + + assert set(result["handles"]) == {handles["square"]} + assert set(result["filtered_by"]) == {"min_area"} + + +async def test_min_area_admits_both_shapes_when_the_threshold_is_low(backend): + handles = await _mixed_properties(backend) + + result = await backend.selection_filter(min_area=100.0) + + assert set(result["handles"]) == {handles["circle"], handles["square"]} + + +async def test_min_area_excludes_entities_that_enclose_no_area(backend): + """A LINE has no area at all — it is not an entity of area zero to be kept + by a zero threshold, it is an entity the question does not apply to.""" + handles = await _mixed_properties(backend) + + result = await backend.selection_filter(min_area=0.0) + + assert handles["line"] not in result["handles"] + assert set(result["filtered_by"]) == {"min_area"} + + +async def test_a_negative_min_area_is_refused(backend): + """No area is negative, so the threshold is either a typo or a unit error.""" + await _mixed_properties(backend) + + result = await backend.selection_filter(min_area=-1.0) + + assert result["ok"] is False + assert "min_area" in result["error"] + + +async def test_the_filter_count_matches_the_handles(backend): + await _mixed_properties(backend) + + result = await backend.selection_filter(layer="GEOMETRY") + + assert result["count"] == len(result["handles"]) == 2 diff --git a/tests/test_sprint4_ezdxf.py b/tests/test_sprint4_ezdxf.py index a718fe2..a740b9f 100644 --- a/tests/test_sprint4_ezdxf.py +++ b/tests/test_sprint4_ezdxf.py @@ -16,6 +16,8 @@ from __future__ import annotations import pytest +import config + pytestmark = pytest.mark.asyncio @@ -96,8 +98,13 @@ async def test_zoom_window_reports_not_applied(backend): # ── NEW-undo-1 — undo/transaction stack behaviour under lock ──────────────── -async def test_undo_empty_stack_returns_error(backend): +async def test_undo_empty_stack_returns_error(backend, monkeypatch): + """With history on but nothing done yet, undo declines rather than raising.""" + monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 4) + await backend.drawing_new() + res = await backend.drawing_undo() + assert res["ok"] is False assert "undo" in res["error"].lower() diff --git a/tests/test_token_suite.py b/tests/test_token_suite.py new file mode 100644 index 0000000..c05c315 --- /dev/null +++ b/tests/test_token_suite.py @@ -0,0 +1,306 @@ +"""Every token claim in this release has to be measured, and labelled as what it is. + +These tests guard the *method*, not the numbers. The numbers move with the catalog +(add a tool, the idle payload grows); the method must not move at all: + +* characters are the primary, exact unit; tokens are always marked as an estimate + with the divisor and its provenance attached, +* no single flat chars/token divisor is ever applied across formats (that error + flatters whichever format the author wants to win), +* every payload is priced twice - uncached and prompt-cached - because a + never-mutating tool prefix is cacheable and every real client caches it, +* the stock-BM25 arm of the discovery A/B is *configured* with the same compact + serializer ours uses, so the comparison measures the ranker rather than the + renderer, +* the suite measures the headless engine and leaves the shared server as it + found it. +""" + +from __future__ import annotations + +import asyncio +import json +import os + +import pytest + +import config +import server +from benchmarks import token_suite as ts + +# A small drawing keeps the module fast; the published run uses the default 300. +TEST_ENTITIES = 40 + +# Captured at import, i.e. before the suite has had a chance to pin the backend. +#: ``AUTOCAD_MCP_BACKEND`` straddling the suite run. Both are captured *around* +#: ``run_suite`` rather than at import or in the test body, because the suite is +#: the only thing being measured here: the conftest pins every test to the +#: headless backend, and — since pytest builds higher-scoped fixtures first — +#: that pin lands after this module-scoped fixture. Comparing the test-time value +#: against an import-time one measures the conftest, not the restore. +BACKEND_ENV_BEFORE: str | None = None +BACKEND_ENV_AFTER: str | None = None + + +@pytest.fixture(scope="module") +def report() -> dict: + """One real run of the whole suite, shared by every test in this module. + + Synchronous on purpose: ``asyncio_mode = strict`` makes a module-scoped async + fixture need its own event-loop scope, and the suite owns its loop anyway. + """ + global BACKEND_ENV_BEFORE, BACKEND_ENV_AFTER + BACKEND_ENV_BEFORE = os.environ.get(ts.BACKEND_ENV) + try: + return asyncio.run(ts.run_suite(entities=TEST_ENTITIES)) + finally: + BACKEND_ENV_AFTER = os.environ.get(ts.BACKEND_ENV) + + +def _rows(report: dict) -> list[dict]: + """Every measured row in the report, whatever lane it came from.""" + rows = list(report["idle"]) + for arm in report["discovery"]["arms"]: + rows.extend(arm["queries"]) + rows.extend(report["result"]["scenarios"]) + return rows + + +# -- the report contract ---------------------------------------------------- + + +def test_the_report_is_versioned_and_attributable(report): + assert report["schema_version"] == ts.SCHEMA_VERSION + env = report["environment"] + for key in ("python", "implementation", "platform", "machine", "fastmcp", "ezdxf"): + assert env[key], f"environment fingerprint is missing {key}" + + +def test_the_report_is_json_serializable(report): + assert json.loads(json.dumps(report, ensure_ascii=False)) == report + + +def test_the_measured_surface_is_the_headless_engine(report): + """A run that quietly drove live AutoCAD would be measuring a different server.""" + assert report["backend"] == "ezdxf" + + +# -- counting --------------------------------------------------------------- + + +def test_the_report_states_how_it_counted(report): + counting = report["counting"] + assert counting["primary_unit"] == "characters" + assert counting["token_method"] + assert counting["tokens_are_estimates"] is True + assert counting["method_note"] + + +def test_no_single_flat_divisor_is_applied_across_formats(): + """The measured error of the earlier design pass, guarded.""" + ratios = {name: ratio.chars_per_token for name, ratio in ts.CHARS_PER_TOKEN.items()} + assert len(set(ratios.values())) > 1, ratios + assert ratios["json_schema"] != ratios["compact_text"] + assert 4.0 not in ratios.values(), "chars/4 is the divisor this harness exists to avoid" + + +def test_the_same_text_costs_differently_in_different_formats(): + counter = ts.RatioTokenCounter() + text = "x" * 4190 + assert counter.count(text, "json_schema") != counter.count(text, "compact_text") + + +def test_an_unlabelled_format_is_refused_rather_than_guessed(): + counter = ts.RatioTokenCounter() + with pytest.raises(KeyError): + counter.count("some payload", "prose") + + +def test_every_ratio_carries_its_provenance_and_confidence(): + for name, ratio in ts.CHARS_PER_TOKEN.items(): + assert ratio.provenance, name + assert ratio.confidence in ts.RATIO_CONFIDENCE, name + + +def test_characters_are_exact_and_tokens_are_marked_estimated(): + counter = ts.RatioTokenCounter() + text = '{"name":"entity_create_circle"}' + row = ts.measure(text, "json_schema", counter) + assert row["chars"] == len(text) + assert row["bytes_utf8"] == len(text.encode("utf-8")) + assert row["tokens"]["estimated"] is True + assert row["tokens"]["chars_per_token"] == ts.CHARS_PER_TOKEN["json_schema"].chars_per_token + + +def test_an_unavailable_real_tokenizer_fails_loudly(monkeypatch): + """The fallback that must never be silent.""" + monkeypatch.setattr(ts, "_anthropic_client", lambda: None) + with pytest.raises(RuntimeError, match="anthropic"): + ts.build_counter("anthropic") + + +# -- cached / uncached ------------------------------------------------------ + + +def test_every_measured_row_is_priced_cached_and_uncached(report): + for row in _rows(report): + cost = row["cost"] + assert cost["uncached_input_tokens_per_request"] >= cost["cached_input_tokens_per_request"] + assert cost["session_uncached"] > cost["session_cached"] + + +def test_the_cache_arithmetic_follows_the_published_multipliers(): + cost = ts.cost_model(1000, turns=10) + assert cost["uncached_input_tokens_per_request"] == 1000 + assert cost["cached_input_tokens_per_request"] == pytest.approx(100.0) + assert cost["session_uncached"] == pytest.approx(10_000) + # one cache write (1.25x) plus nine cache reads (0.1x each) + assert cost["session_cached"] == pytest.approx(1250 + 9 * 100) + + +# -- lane 1: idle cost ------------------------------------------------------ + + +def test_the_idle_lane_covers_the_three_advertised_surfaces(report): + variants = {row["variant"]: row for row in report["idle"]} + assert set(variants) == {"default", "lean", "search"} + assert variants["default"]["advertised_tools"] > 100 + assert variants["lean"]["advertised_tools"] == len(server.LEAN_TOOL_NAMES) + assert variants["search"]["advertised_tools"] == 2 + assert variants["default"]["chars"] > variants["lean"]["chars"] > variants["search"]["chars"] + + +def test_the_idle_lane_separates_the_wire_payload_from_what_the_model_pays(report): + """tools/list carries annotations, titles and _meta a client need not forward.""" + for row in report["idle"]: + assert row["chars"] == row["wire_chars"] + assert 0 < row["prompt_chars"] < row["wire_chars"] + + +def test_the_search_mode_prefix_falls_below_the_prompt_cache_minimum(report): + """Small enough that no model can cache it - true, and worth saying out loud.""" + variants = {row["variant"]: row for row in report["idle"]} + assert variants["search"]["prompt_cache"]["cacheable"]["512"] is False + assert variants["default"]["prompt_cache"]["cacheable"]["4096"] is True + assert variants["lean"]["prompt_cache"]["cacheable"]["4096"] is True + + +# -- lane 2: per-discovery cost --------------------------------------------- + + +def test_the_stock_arm_is_configured_not_left_on_its_defaults(report): + arms = {arm["arm"]: arm for arm in report["discovery"]["arms"]} + assert set(arms) == {"cad_search", "stock_bm25_compact", "stock_bm25_default"} + assert ( + arms["stock_bm25_compact"]["serializer"] == "discovery.serialize.serialize_search_results" + ) + assert arms["stock_bm25_compact"]["strawman"] is False + assert arms["stock_bm25_default"]["strawman"] is True + + +def test_the_serializer_not_the_transform_is_what_saves_the_bytes(report): + """The honest read of the A/B: ours wins on finding tools, not on payload size.""" + summary = report["discovery"]["summary"] + ours = summary["mean_chars"]["cad_search"] + stock_compact = summary["mean_chars"]["stock_bm25_compact"] + stock_default = summary["mean_chars"]["stock_bm25_default"] + assert ours == pytest.approx(stock_compact, rel=0.25) + assert ours < stock_default / 4 + + +def test_a_zero_hit_answer_is_recorded_as_such_not_banked_as_a_saving(report): + """Stock BM25 finds nothing for WBLOCK; the cheapest payload is the worst answer. + + This used to be asserted on BPOLY. v1.5.0's `boundary_trace` names BPOLY in + its own docstring, so stock BM25 now finds it — the gap closed for that one + command, and measuring it as still-open would have flattered the corpus. + """ + arms = {arm["arm"]: arm for arm in report["discovery"]["arms"]} + stock = {row["query"]: row for row in arms["stock_bm25_compact"]["queries"]} + ours = {row["query"]: row for row in arms["cad_search"]["queries"]} + assert stock["WBLOCK"]["hits"] == 0 + assert ours["WBLOCK"]["hits"] > 0 + summary = report["discovery"]["summary"] + assert summary["comparable_queries"] < summary["queries"] + assert summary["hit_rate"]["cad_search"] > summary["hit_rate"]["stock_bm25_compact"] + + +# -- lane 3: result-side cost ----------------------------------------------- + + +def test_the_result_lane_measures_a_real_drawing(report): + result = report["result"] + assert result["entities_in_drawing"] == TEST_ENTITIES + assert result["entity_types"], "the drawing mix is part of the measurement" + + +def test_the_result_lane_prices_what_the_model_reads_and_what_the_wire_carries(report): + """FastMCP returns the same rows twice: a text block and structured content.""" + for row in report["result"]["scenarios"]: + assert row["chars"] == row["text_chars"] + assert row["structured_chars"] > 0 + assert row["wire_chars"] == row["text_chars"] + row["structured_chars"] + + +def test_the_result_payload_scales_with_the_rows_returned(report): + scenarios = {row["limit"]: row for row in report["result"]["scenarios"]} + for limit, row in scenarios.items(): + assert row["rows_returned"] == min(limit, TEST_ENTITIES) + per_entity = [row["chars_per_entity"] for row in scenarios.values()] + assert max(per_entity) < min(per_entity) * 1.15 + + +def test_the_result_lane_names_the_cap_that_cannot_actually_be_reached(report): + projection = report["result"]["projection"] + assert projection["schema_max_limit"] == 1000 + assert projection["config_max_list_limit"] == config.settings.max_list_limit + assert projection["extrapolated"] is True + assert "schema" in projection["note"] + + +def test_the_result_lane_shows_where_the_bytes_go(report): + """bounding_box and full-precision floats are measured, not assumed.""" + row = report["result"]["scenarios"][0] + counterfactuals = row["counterfactuals"] + assert counterfactuals["without_bounding_box"]["chars"] < row["chars"] + assert counterfactuals["floats_rounded_6dp"]["chars"] <= row["chars"] + for entry in counterfactuals.values(): + assert entry["counterfactual"] is True + + +# -- the suite is a guest on a shared server -------------------------------- + + +def test_a_live_autocad_backend_is_refused_rather_than_measured(): + with pytest.raises(RuntimeError, match="ezdxf"): + ts.require_ezdxf({"backend": "com"}) + + +def test_the_suite_leaves_the_shared_server_as_it_found_it(report): + assert config.settings.tool_profile == "full" + assert config.settings.discovery_mode == "off" + # Restored to whatever it was, rather than asserted absent: exporting + # AUTOCAD_MCP_BACKEND=ezdxf is exactly what a developer on a Windows box with + # AutoCAD open should do, and that must not fail this test. + assert BACKEND_ENV_AFTER == BACKEND_ENV_BEFORE + + async def _names() -> set[str]: + from fastmcp import Client + + async with Client(server.mcp) as client: + return {tool.name for tool in await client.list_tools()} + + names = asyncio.run(_names()) + assert len(names) > 100 + assert "search_tools" not in names + + +# -- the human summary ------------------------------------------------------ + + +def test_the_summary_states_the_method_next_to_the_numbers(report): + text = ts.summarize(report) + assert "characters" in text + assert "estimate" in text + assert "cached" in text + assert text.isascii(), "the console this runs on is cp1254" diff --git a/tests/test_tool_inventory.py b/tests/test_tool_inventory.py new file mode 100644 index 0000000..68f0ec3 --- /dev/null +++ b/tests/test_tool_inventory.py @@ -0,0 +1,232 @@ +"""``docs/tool-inventory.json`` must be the live surface, not a stale copy of it. + +The generated inventory replaces the oldest and most fragile release gate in +this repo: counting the literal text ``@mcp.tool(`` in server.py. Text counting +cannot see a group, a cost or a summary, and it miscounts the moment someone +writes the string in prose — which is why ``cad_tool``'s own docstring has to +spell its example without parentheses. This module holds the committed snapshot +equal to what ``scripts/generate_tool_inventory.py`` derives from the live +FastMCP component registry, so the JSON can be trusted as an input by anything +else (``tests/test_release_consistency.py`` reads its totals for the README +release-snapshot line). + +Three properties are pinned: + + * **No drift** — the committed document equals the freshly built one, field by + field, and byte for byte. Every failure names the regeneration command. + * **Honest totals** — the counts are re-derived here from the registry by a + path the generator does not share, so a generator that miscounts is caught + by something other than itself. + * **Usable rows** — every tool row carries the group/cost/summary a consumer + reads, with the same validity rules the live ``@cad_tool`` cards obey. +""" + +from __future__ import annotations + +import json +from typing import Any + +import pytest + +import server +from discovery.serialize import MAX_SUMMARY_CHARS +from scripts import generate_tool_inventory as generator + +INVENTORY_PATH = generator.INVENTORY_PATH + +_HOW_TO_FIX = ( + f"\n\nThis file is generated. Regenerate it with:\n {generator.REGENERATE_COMMAND}\n" + "(and commit the result). Nothing here is meant to be hand-edited." +) + + +def _committed() -> dict[str, Any]: + assert INVENTORY_PATH.exists(), f"{generator.INVENTORY_REL_PATH} is missing.{_HOW_TO_FIX}" + return json.loads(INVENTORY_PATH.read_text(encoding="utf-8")) + + +def _differences(committed: dict[str, Any], live: dict[str, Any]) -> list[str]: + """Human-readable drift, rather than a 131-row dict diff nobody can read. + + Deliberately reports field *names* and identifiers only, never a summary + string: tool names are ASCII, whereas summaries carry glyphs like the + diameter sign, and a cp1254 console would turn such a failure into a + UnicodeEncodeError instead of a readable diff. + """ + problems: list[str] = [] + for key in ("schema_version", "package_version", "totals", "groups"): + if committed.get(key) != live.get(key): + problems.append(f"{key}: committed {committed.get(key)!r} != live {live.get(key)!r}") + for section, id_key in (("tools", "name"), ("resources", "uri"), ("prompts", "name")): + old = {row.get(id_key): row for row in committed.get(section, [])} + new = {row.get(id_key): row for row in live.get(section, [])} + for name in sorted(set(new) - set(old), key=str): + problems.append(f"{section}: {name} is registered but absent from the committed file") + for name in sorted(set(old) - set(new), key=str): + problems.append(f"{section}: {name} is in the committed file but is not registered") + for name in sorted(set(old) & set(new), key=str): + if old[name] != new[name]: + changed = sorted( + field + for field in set(old[name]) | set(new[name]) + if old[name].get(field) != new[name].get(field) + ) + problems.append(f"{section}: {name} changed fields {changed}") + return problems + + +# ── the drift gate ────────────────────────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_committed_inventory_matches_the_live_surface(): + """The gate. Adding, renaming or re-costing a tool fails here until regenerated.""" + live = await generator.build_inventory() + problems = _differences(_committed(), live) + assert not problems, ( + f"{generator.INVENTORY_REL_PATH} no longer describes the live server:\n " + + "\n ".join(problems) + + _HOW_TO_FIX + ) + + +@pytest.mark.asyncio +async def test_committed_inventory_is_byte_identical_to_the_generator_output(): + """Formatting counts too: a hand-tweaked file must not survive review. + + Only fires when the *content* already matches (that check reports a readable + diff first), so this failing means indentation/ordering/newlines drifted. + """ + expected = generator.render(await generator.build_inventory()) + assert INVENTORY_PATH.read_text(encoding="utf-8") == expected, ( + f"{generator.INVENTORY_REL_PATH} has the right contents but not the " + f"generator's exact formatting.{_HOW_TO_FIX}" + ) + + +# ── the totals, re-derived independently of the generator ─────────────────── + + +@pytest.mark.asyncio +async def test_totals_agree_with_the_live_registry(): + """Counted straight off the registry keys, a path build_inventory does not use. + + Guards the generator itself: were its row builders to drop a component + category, the drift gate alone would happily lock the loss in. + """ + keys = list(server.mcp._local_provider._components) + expected = { + "tools": sum(1 for key in keys if key.startswith("tool:")), + # The README's single "resources" number covers both registry prefixes. + "resources": sum(1 for key in keys if key.startswith(("resource:", "template:"))), + "prompts": sum(1 for key in keys if key.startswith("prompt:")), + "groups": len(await server._tool_groups()), + } + assert _committed()["totals"] == expected + + +def test_totals_match_the_row_counts(): + """Internal consistency: the header numbers describe the body.""" + inventory = _committed() + totals = inventory["totals"] + assert totals["tools"] == len(inventory["tools"]) + assert totals["resources"] == len(inventory["resources"]) + assert totals["prompts"] == len(inventory["prompts"]) + assert totals["groups"] == len(inventory["groups"]) + + +def test_group_counts_match_the_tool_rows(): + inventory = _committed() + tallied: dict[str, int] = {} + for row in inventory["tools"]: + tallied[row["group"]] = tallied.get(row["group"], 0) + 1 + assert inventory["groups"] == dict(sorted(tallied.items())) + assert sum(inventory["groups"].values()) == inventory["totals"]["tools"] + + +# ── the rows a consumer actually reads ────────────────────────────────────── + + +@pytest.mark.asyncio +async def test_every_tool_row_names_a_real_group(): + groups = await server._tool_groups() + bad = sorted(row["name"] for row in _committed()["tools"] if row["group"] not in groups) + assert not bad, f"tool rows filed under a group that does not exist: {bad}" + + +def test_every_tool_row_carries_a_usable_cost_and_summary(): + """The minimum contract of a row: name, group, cost, one-line summary.""" + bad: list[str] = [] + for row in _committed()["tools"]: + summary = row.get("summary") + if not row.get("name") or not row.get("group"): + bad.append(f"{row.get('name')!r}: missing name/group") + elif row.get("cost") not in server.CAD_TOOL_COSTS: + bad.append(f"{row['name']}: cost {row.get('cost')!r} is not a CAD_TOOL_COSTS value") + elif not isinstance(summary, str) or not summary.strip() or "\n" in summary: + bad.append(f"{row['name']}: summary is empty or spans lines") + elif len(summary) > MAX_SUMMARY_CHARS: + bad.append(f"{row['name']}: summary is {len(summary)} chars, over the search budget") + assert not bad, f"unusable tool rows: {bad}" + + +def test_tool_rows_are_sorted_by_name(): + """A generated file whose order wobbles produces unreviewable diffs.""" + names = [row["name"] for row in _committed()["tools"]] + assert names == sorted(names) + assert len(names) == len(set(names)), "duplicate tool rows" + + +def test_the_resource_template_is_recorded_as_templated(): + """FastMCP files templates under a separate prefix; the merge must not lose that.""" + templated = [row for row in _committed()["resources"] if row["templated"]] + assert [row["uri"] for row in templated] == ["autocad://entities/{layer_name}"] + assert templated[0]["parameters"] == ["layer_name"] + + +def test_prompt_rows_record_their_arguments(): + prompts = {row["name"]: row for row in _committed()["prompts"]} + required = [a["name"] for a in prompts["prompt_quick_drawing"]["arguments"] if a["required"]] + assert required == ["description"] + + +def test_package_version_is_the_pyproject_version(): + """The snapshot says which release's surface it describes.""" + assert _committed()["package_version"] == generator._pyproject_version() + + +# ── the regeneration path itself ──────────────────────────────────────────── + + +def test_check_mode_accepts_the_committed_file(): + assert generator.main(["--check"]) == 0 + + +def test_check_mode_reports_drift_and_names_the_regeneration_command(tmp_path, monkeypatch, capsys): + """A developer who never opens this test file still gets told what to run.""" + stale = tmp_path / "tool-inventory.json" + stale.write_text('{"totals": {"tools": 1}}\n', encoding="utf-8") + monkeypatch.setattr(generator, "INVENTORY_PATH", stale) + + assert generator.main(["--check"]) == 1 + assert generator.REGENERATE_COMMAND in capsys.readouterr().out + + +def test_check_mode_reports_a_missing_file(tmp_path, monkeypatch, capsys): + monkeypatch.setattr(generator, "INVENTORY_PATH", tmp_path / "absent.json") + + assert generator.main(["--check"]) == 1 + output = capsys.readouterr().out + assert "is missing" in output + assert generator.REGENERATE_COMMAND in output + + +def test_writing_then_checking_round_trips(tmp_path, monkeypatch): + """Write mode produces exactly what check mode accepts, in a scratch location.""" + target = tmp_path / "nested" / "tool-inventory.json" + monkeypatch.setattr(generator, "INVENTORY_PATH", target) + + assert generator.main([]) == 0 + assert generator.main(["--check"]) == 0 + # Committed as LF regardless of platform, so the file does not churn on Windows. + assert b"\r\n" not in target.read_bytes() diff --git a/tests/test_tool_profiles.py b/tests/test_tool_profiles.py index 23f5579..46b33b4 100644 --- a/tests/test_tool_profiles.py +++ b/tests/test_tool_profiles.py @@ -1,7 +1,15 @@ -"""Tool-profile (TOOL_PROFILE=lean/core/full) behavior.""" +"""Tool-profile (TOOL_PROFILE=lean/full) behavior. + +v1.5.0 removed the `core` profile: the discovery layer (tool search + the +AutoCAD command aliases) is the answer to a crowded surface, so a third +hand-maintained deny-list had no reason to exist. `core` must still *start* +the server — it falls back to `full` with a deprecation warning that names it. +""" from __future__ import annotations +import logging + import pytest import pytest_asyncio from fastmcp import Client @@ -29,10 +37,10 @@ async def test_lean_names_all_exist_in_registry(): assert not missing, f"LEAN_TOOL_NAMES references unknown tools: {sorted(missing)}" -async def test_core_exclusions_all_exist_in_registry(): - registered = await _registered_names() - missing = server.CORE_EXCLUDED_TOOL_NAMES - registered - assert not missing, f"CORE_EXCLUDED_TOOL_NAMES references unknown tools: {sorted(missing)}" +async def test_only_lean_and_full_profiles_exist(): + """M1.1 — `core` is gone, deny-list and all.""" + assert server.TOOL_PROFILES == ("lean", "full") + assert not hasattr(server, "CORE_EXCLUDED_TOOL_NAMES") async def test_full_profile_hides_only_gated_solids(): @@ -41,35 +49,71 @@ async def test_full_profile_hides_only_gated_solids(): assert info["profile"] == "full" assert set(info["disabled_tools"]) == set(server.SOLID_TOOL_NAMES) assert info["enabled_count"] == info["registered_count"] - len(server.SOLID_TOOL_NAMES) + # An accepted profile is applied as requested, so there is nothing to report. + assert "requested" not in info -async def test_invalid_profile_falls_back_to_full(): - info = await server._apply_tool_profile("does-not-exist") +async def test_invalid_profile_falls_back_to_full(caplog): + with caplog.at_level(logging.WARNING, logger="autocad_mcp"): + info = await server._apply_tool_profile("does-not-exist") + assert info["profile"] == "full" + assert info["requested"] == "does-not-exist" + assert set(info["disabled_tools"]) == set(server.SOLID_TOOL_NAMES) + text = caplog.text + assert "does-not-exist" in text + # An unknown value is not a removed one; don't tell the user we deleted it. + assert "removed" not in text.lower() + + +async def test_removed_core_profile_falls_back_to_full_with_a_deprecation(caplog): + """TOOL_PROFILE=core must keep the server bootable, loudly.""" + with caplog.at_level(logging.WARNING, logger="autocad_mcp"): + info = await server._apply_tool_profile("core") assert info["profile"] == "full" assert set(info["disabled_tools"]) == set(server.SOLID_TOOL_NAMES) + text = caplog.text + assert "core" in text + assert "removed" in text.lower() + # The warning has to say what to use instead. + assert "lean" in text and "full" in text + + +async def test_removed_core_profile_is_reported_by_system_about(): + """A log line on a STDIO server is easy to miss; system_about must show it.""" + await server._apply_tool_profile("core") + ctx = _AboutCtx() + about = await server.system_about(ctx=ctx) + assert about["tool_profile"]["profile"] == "full" + assert about["tool_profile"]["requested"] == "core" + + +class _AboutCtx: + """system_about only reads lifespan_context; no backend needed.""" + + def __init__(self): + self.lifespan_context = {"backend": None} + + +async def test_core_env_value_still_serves_the_full_surface(monkeypatch): + """The removal must not brick an existing TOOL_PROFILE=core deployment.""" + monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf") # setattr here was a no-op (T0.8-B) + monkeypatch.setattr(config.settings, "tool_profile", "core") + async with Client(server.mcp) as client: + visible = {tool.name for tool in await client.list_tools()} + registered = await _registered_names() + assert visible == registered - server.SOLID_TOOL_NAMES + # The escape hatches core used to hide are back on the wire. + assert "system_run_command" in visible async def test_lean_profile_filters_client_view(monkeypatch): - monkeypatch.setattr(config.settings, "backend", "ezdxf") + monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf") # setattr here was a no-op (T0.8-B) monkeypatch.setattr(config.settings, "tool_profile", "lean") async with Client(server.mcp) as client: visible = {tool.name for tool in await client.list_tools()} assert visible == set(server.LEAN_TOOL_NAMES) -async def test_core_profile_hides_escape_hatches_keeps_engineering(monkeypatch): - monkeypatch.setattr(config.settings, "backend", "ezdxf") - monkeypatch.setattr(config.settings, "tool_profile", "core") - async with Client(server.mcp) as client: - visible = {tool.name for tool in await client.list_tools()} - assert "system_run_command" not in visible - assert "system_run_lisp" not in visible - assert "gear_draw_spur_front_view" in visible - assert "drawing_refine" in visible - registered = await _registered_names() - assert visible == registered - server.CORE_EXCLUDED_TOOL_NAMES - server.SOLID_TOOL_NAMES - - async def test_profile_switch_is_idempotent(): await server._apply_tool_profile("lean") info = await server._apply_tool_profile("full") diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py new file mode 100644 index 0000000..7b0ba48 --- /dev/null +++ b/tests/test_tool_registry.py @@ -0,0 +1,530 @@ +"""server.py must never report something it does not have. + +Three families of that rule live here. + +**The registry must be read untransformed.** ``_registered_tools()`` backs three +things that all need the *real* inventory: + + * ``system_status`` / ``system_about`` tool counts, + * ``_tool_groups()`` (the system_about per-group breakdown), + * ``_apply_tool_profile()`` (which tools get ``mcp.disable()``d). + +FastMCP applies :class:`~fastmcp.server.transforms.Transform` chains at two +levels, and only one of them reaches ``mcp._list_tools()``. Shipped search mode +(``_apply_discovery_mode("search")``) adds a *server-level* transform: it swaps +the catalog for ``search_tools``/``call_tool`` at the wire, while +``mcp._list_tools()`` upstream of it still lists everything — so search mode on +its own moves none of the three. A *provider-level* transform is the one that +rewrites ``mcp._list_tools()`` itself, and that is what ``_TruncatingTransform`` +below installs. So the property pinned here is the stronger, layer-agnostic one: +registry-truth is read below every transform, and no catalog rewrite — today's, +or a provider-level one someone mounts tomorrow — can make the server report a +surface it does not have or silently turn TOOL_PROFILE into a no-op. + +**A tool must not report a document it did not open** (T0.2) — the read-side +twin of the DWG write refusal, and the reason a ``.dwg`` path is refused on a +backend whose ``dwg`` capability is false. + +**Discovery metadata must not cost anything.** ``@cad_tool`` writes summary / +cost / alias metadata into the MCP ``meta`` channel; ``tags`` are load-bearing +(``_tool_groups()`` buckets every tool by them for system_about), so the +grouping is pinned to the tags literally written in server.py's source. The +channel is also gated for *coverage*: every registered tool must carry a card, +and no card may contradict the tool's own annotations. +""" + +from __future__ import annotations + +import ast +import shutil +from pathlib import Path + +import pytest +import pytest_asyncio +from fastmcp import Client +from fastmcp.exceptions import ToolError +from fastmcp.server.transforms import Transform + +import server +from backends.base import CapabilityMap, FeatureCapability +from discovery.aliases import aliases_for +from discovery.serialize import MAX_SUMMARY_CHARS + +pytestmark = pytest.mark.asyncio + + +class _TruncatingTransform(Transform): + """Stand-in for a catalog-replacing transform: collapses the list to two tools. + + Mirrors the shape of the problem rather than any shipped transform — a + transform that replaces the tool list wholesale instead of decorating it, + mounted (see the fixture) at the level that reaches ``mcp._list_tools()``. + """ + + def __init__(self, keep: int = 2): + self.keep = keep + + async def list_tools(self, tools): + return list(tools)[: self.keep] + + +@pytest.fixture +def truncating_transform(): + """Install the transform where FastMCP actually applies it, then remove it. + + ``mcp._list_tools()`` aggregates ``provider.list_tools()``, and + ``Provider.list_tools`` is what runs the provider's transforms — so a + provider-level transform is exactly what rewrites that call's output. + """ + transform = _TruncatingTransform() + server.mcp.local_provider.add_transform(transform) + try: + yield transform + finally: + server.mcp.local_provider._transforms.remove(transform) + + +@pytest_asyncio.fixture(autouse=True) +async def _restore_full_profile(): + """These tests call _apply_tool_profile, which mutates global enablement.""" + yield + await server._apply_tool_profile("full") + + +async def test_transform_really_rewrites_the_public_listing(truncating_transform): + """Guard the guard: prove the fixture actually breaks the public accessor. + + Without this the other tests would pass even if the transform silently did + nothing. + """ + assert len(await server.mcp._list_tools()) == 2 + + +async def test_registered_tools_unaffected_by_a_transform(truncating_transform): + names = {t.name for t in await server._registered_tools()} + assert len(names) > 100 + assert "system_about" in names + assert "gear_draw_spur_front_view" in names + + +async def test_registered_tool_count_unaffected_by_a_transform(truncating_transform): + count = await server._registered_tool_count() + assert count is not None + assert count > 100 + + +async def test_tool_groups_unaffected_by_a_transform(truncating_transform): + groups = await server._tool_groups() + # The transform collapses the catalog to two drawing tools, which would + # leave a single "drawing" bucket. + assert len(groups) > 5 + assert groups["transactions"] == [ + "transaction_begin", + "transaction_commit", + "transaction_rollback", + ] + assert "gear_draw_spur_front_view" in groups["engineering"] + + +async def test_tool_profile_still_disables_under_a_transform(truncating_transform): + """TOOL_PROFILE must keep working when a catalog-rewriting transform is mounted. + + Reading the transformed catalog would make ``registered`` tiny, so + ``registered - enabled`` collapses and ``mcp.disable()`` never fires. + """ + info = await server._apply_tool_profile("lean") + assert info["registered_count"] > 100 + assert info["enabled_count"] == len(server.LEAN_TOOL_NAMES) + assert info["disabled_count"] > 50 + # The escape hatches really are hidden, not merely counted as hidden. + assert "system_run_command" in info["disabled_tools"] + + +async def test_registered_tools_returns_empty_when_registry_unknown(monkeypatch): + """Contract: never raises, and reports [] / None rather than a bogus count.""" + + class _Broken: + @property + def _components(self): + raise RuntimeError("registry layout changed") + + async def _list_tools(self): + raise RuntimeError("registry layout changed") + + async def _boom(): + raise RuntimeError("public accessor gone") + + monkeypatch.setattr(server.mcp, "_local_provider", _Broken(), raising=False) + monkeypatch.setattr(server.mcp, "_list_tools", _boom, raising=False) + + assert await server._registered_tools() == [] + assert await server._registered_tool_count() is None + + +# ── T0.2 — drawing_open must not report a document it did not open ────────── + + +class _Ctx: + """Minimal async context for calling a tool function directly.""" + + def __init__(self, backend): + self.lifespan_context = {"backend": backend} + + async def info(self, message): + pass + + async def warning(self, message): + pass + + async def report_progress(self, progress, total): + pass + + +async def _saved_dxf(backend, tmp_path): + dxf = tmp_path / "part.dxf" + await backend.drawing_save_as(str(dxf), "dxf") + return dxf + + +async def test_drawing_open_opens_a_dxf(backend, tmp_path): + """Baseline: the honest path keeps working.""" + dxf = await _saved_dxf(backend, tmp_path) + result = await server.drawing_open(str(dxf), ctx=_Ctx(backend)) + assert result["ok"] is True + assert result["name"] == "part.dxf" + + +async def test_drawing_open_refuses_a_dwg_the_backend_cannot_read(backend, tmp_path): + """DXF bytes wearing a .dwg name must not be reported as an opened DWG. + + ezdxf sniffs content, not extensions, so it happily parses a mislabelled + file (exactly what pre-1.5.0 drawing_save produced) and the tool then + answers ``{"ok": True, "name": "part.dwg"}`` — a document that does not + exist in that format. + """ + dxf = await _saved_dxf(backend, tmp_path) + disguised = tmp_path / "part.dwg" + shutil.copy(dxf, disguised) + + with pytest.raises(ToolError) as excinfo: + await server.drawing_open(str(disguised), ctx=_Ctx(backend)) + + message = str(excinfo.value).lower() + assert "dwg" in message + # Blames the backend's missing capability and names the escape hatch, + # rather than ezdxf's bare "is not a DXF file". + assert "ezdxf" in message + assert "com" in message + + +async def test_drawing_open_refuses_a_real_dwg_without_leaking_a_parser_error(backend, tmp_path): + real_dwg = tmp_path / "real.dwg" + real_dwg.write_bytes(b"AC1032" + b"\x00" * 256) + + with pytest.raises(ToolError) as excinfo: + await server.drawing_open(str(real_dwg), ctx=_Ctx(backend)) + + assert "is not a DXF file" not in str(excinfo.value) + + +async def test_drawing_open_refusal_leaves_the_current_document_untouched(backend, tmp_path): + dxf = await _saved_dxf(backend, tmp_path) + ctx = _Ctx(backend) + await server.drawing_open(str(dxf), ctx=ctx) + + disguised = tmp_path / "other.dwg" + shutil.copy(dxf, disguised) + with pytest.raises(ToolError): + await server.drawing_open(str(disguised), ctx=ctx) + + info = await backend.drawing_info() + assert info.name == "part.dxf" + + +class _DwgCapableBackend: + """Stand-in for the live COM backend, which reads DWG natively.""" + + name = "com" + + def __init__(self): + self.opened: list[str] = [] + + def capabilities(self) -> CapabilityMap: + return CapabilityMap( + backend="com", + features={"dwg": FeatureCapability(True, "native")}, + ) + + async def drawing_open(self, path: str) -> dict: + self.opened.append(path) + return {"ok": True, "name": "part.dwg", "path": path} + + +async def test_drawing_open_allows_dwg_when_the_backend_supports_it(tmp_path): + """The refusal is capability-driven, not an ezdxf-shaped hardcode.""" + fake = _DwgCapableBackend() + result = await server.drawing_open(str(tmp_path / "part.dwg"), ctx=_Ctx(fake)) + assert result["ok"] is True + assert len(fake.opened) == 1 + + +async def test_drawing_open_does_not_advertise_unconditional_dwg(): + """The lie was also in the docs the model reads before calling.""" + tool = next(t for t in await server._registered_tools() if t.name == "drawing_open") + text = (tool.description or "").lower() + for param in (tool.parameters or {}).get("properties", {}).values(): + text += " " + str(param.get("description", "")).lower() + assert "dwg" in text, "DWG support should still be discoverable" + assert "com" in text, "but qualified by the backend that actually has it" + + +# ── @cad_tool — the discovery metadata channel ────────────────────────────── + +CAD_TOOL_SECTION = ("transaction_begin", "transaction_commit", "transaction_rollback") + + +def _component(name: str): + for tool in server._local_tool_components(): + if tool.name == name: + return tool + raise AssertionError(f"{name} is not registered") + + +@pytest.mark.parametrize("name", CAD_TOOL_SECTION) +async def test_cad_tool_publishes_discovery_meta(name): + cad = (_component(name).meta or {})["cad"] + assert cad["summary"] and "\n" not in cad["summary"] + assert cad["cost"] in server.CAD_TOOL_COSTS + + +@pytest.mark.parametrize("name", CAD_TOOL_SECTION) +async def test_cad_tool_pulls_aliases_from_the_discovery_corpus(name): + """The corpus is the single source of truth; server.py must not restate it.""" + record = aliases_for(name) + cad = (_component(name).meta or {})["cad"] + assert cad["acad"] == list(record.acad) + assert cad["synonyms"] == list(record.synonyms) + assert cad["synonyms"], "the transaction tools do carry synonyms" + + +async def test_cad_tool_meta_reaches_the_wire(monkeypatch): + monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf") + async with Client(server.mcp) as client: + tools = {tool.name: tool for tool in await client.list_tools()} + cad = tools["transaction_rollback"].meta["cad"] + assert cad["cost"] == "destructive" + assert "rollback" in cad["synonyms"] + + +async def test_cad_tool_rejects_an_unknown_cost(): + with pytest.raises(ValueError, match="cost"): + server.cad_tool(summary="x", cost="somewhat-dangerous") + + +async def test_cad_tool_requires_the_registration_below_it(): + """A misordered decorator must fail loudly at import, not silently no-op.""" + decorator = server.cad_tool(summary="x", cost="read") + + async def never_registered(ctx=None) -> dict: + return {} + + with pytest.raises(RuntimeError, match="not registered"): + decorator(never_registered) + + +# ── @cad_tool coverage: the whole surface, not a sample ───────────────────── +# +# The twin of the alias-corpus gate in tests/test_discovery_aliases.py. That one +# proves every registered tool has an alias record; this one proves every +# registered tool has a discovery card, and that the card agrees with the +# annotations the same tool already publishes. Both derive their subject from +# the live registry, so a tool added without the metadata fails here instead of +# shipping with a summary silently scraped off its docstring. + + +def _cad(tool) -> dict | None: + cad = (tool.meta or {}).get("cad") + return cad if isinstance(cad, dict) else None + + +async def test_every_registered_tool_carries_cad_meta(): + """Registering a tool without @cad_tool must fail this gate.""" + missing = sorted(t.name for t in await server._registered_tools() if _cad(t) is None) + assert not missing, f"tools with no @cad_tool metadata: {missing}" + + +async def test_every_cad_summary_is_a_single_non_empty_line(): + """A card is one line; a summary with a newline in it would break the format.""" + bad: list[str] = [] + for tool in await server._registered_tools(): + summary = (_cad(tool) or {}).get("summary") + if not isinstance(summary, str) or not summary.strip() or "\n" in summary: + bad.append(f"{tool.name}:{summary!r}") + assert not bad, f"summaries that are not one usable line: {bad}" + + +async def test_no_authored_summary_is_long_enough_to_be_truncated(): + """A hand-written summary that overruns the budget would ship rendered as "...". + + The serializer's truncation exists for the docstring fallback, not as a + licence to write a paragraph into @cad_tool. + """ + overlong = sorted( + (t.name, len((_cad(t) or {}).get("summary") or "")) + for t in await server._registered_tools() + if len((_cad(t) or {}).get("summary") or "") > MAX_SUMMARY_CHARS + ) + assert not overlong, f"summaries the serializer would truncate: {overlong}" + + +async def test_every_cad_cost_is_a_known_value(): + bad = sorted( + f"{t.name}:{(_cad(t) or {}).get('cost')!r}" + for t in await server._registered_tools() + if (_cad(t) or {}).get("cost") not in server.CAD_TOOL_COSTS + ) + assert not bad, f"costs outside CAD_TOOL_COSTS: {bad}" + + +async def test_a_read_only_tool_is_costed_read(): + """``readOnlyHint`` is what a client gates on; the cost must not contradict it.""" + bad: list[str] = [] + for tool in await server._registered_tools(): + annotations = tool.annotations + if annotations is not None and annotations.readOnlyHint: + cost = (_cad(tool) or {}).get("cost") + if cost != "read": + bad.append(f"{tool.name}:{cost!r}") + assert not bad, f"readOnlyHint tools not costed 'read': {bad}" + + +async def test_a_destructive_tool_is_never_costed_read_or_safe(): + """The other half of the agreement: an erase must not look harmless.""" + bad: list[str] = [] + for tool in await server._registered_tools(): + annotations = tool.annotations + if annotations is not None and annotations.destructiveHint: + cost = (_cad(tool) or {}).get("cost") + if cost in ("read", "safe"): + bad.append(f"{tool.name}:{cost!r}") + assert not bad, f"destructiveHint tools costed as harmless: {bad}" + + +async def test_only_the_raw_passthroughs_are_costed_escape(): + """'escape' means 'this hands your string to AutoCAD', and nothing else.""" + escapes = sorted( + t.name for t in await server._registered_tools() if (_cad(t) or {}).get("cost") == "escape" + ) + assert escapes == ["system_run_command", "system_run_lisp"] + + +async def test_no_tool_restates_its_aliases_in_server_py(): + """@cad_tool copies the corpus; server.py must never hand-write acad/synonyms.""" + bad: list[str] = [] + for tool in await server._registered_tools(): + cad = _cad(tool) or {} + record = aliases_for(tool.name) + expected_acad = list(record.acad) if record else [] + expected_synonyms = list(record.synonyms) if record else [] + if cad.get("acad") != expected_acad or cad.get("synonyms") != expected_synonyms: + bad.append(tool.name) + assert not bad, f"cad metadata out of step with discovery.aliases: {bad}" + + +# ── tags are load-bearing: _tool_groups() feeds system_about ──────────────── + + +def _tags_declared_in_source() -> dict[str, set[str]]: + """Every tool's tags as literally written in the @mcp.tool(...) call. + + An independent derivation of the input to _tool_groups(): if @cad_tool (or + anything else) rewrote a registered tool's tags, the live grouping would + stop matching what server.py declares. + """ + tree = ast.parse(Path(server.__file__).read_text(encoding="utf-8")) + declared: dict[str, set[str]] = {} + for node in ast.walk(tree): + if not isinstance(node, (ast.AsyncFunctionDef, ast.FunctionDef)): + continue + for decorator in node.decorator_list: + if not isinstance(decorator, ast.Call): + continue + func = decorator.func + if not ( + isinstance(func, ast.Attribute) + and func.attr == "tool" + and isinstance(func.value, ast.Name) + and func.value.id == "mcp" + ): + continue + name = node.name + tags: set[str] = set() + for keyword in decorator.keywords: + if keyword.arg == "name" and isinstance(keyword.value, ast.Constant): + name = keyword.value.value + if keyword.arg == "tags" and isinstance(keyword.value, ast.Set): + tags = { + element.value + for element in keyword.value.elts + if isinstance(element, ast.Constant) + } + declared[name] = tags + return declared + + +async def test_registered_tags_are_exactly_the_ones_written_in_source(): + declared = _tags_declared_in_source() + live = {tool.name: set(tool.tags) for tool in await server._registered_tools()} + assert live == declared + + +async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping(): + """_tool_groups() must be reproducible from server.py's own `tags={...}`.""" + expected: dict[str, list[str]] = {} + for name, tags in _tags_declared_in_source().items(): + label = "other" + for tag in server._GROUP_TAG_PRIORITY: + if tag in tags: + label = server._GROUP_TAG_LABELS[tag] + break + expected.setdefault(label, []).append(name) + for names in expected.values(): + names.sort() + + assert await server._tool_groups() == dict(sorted(expected.items())) + + +async def test_tool_group_sizes_are_unchanged(): + """Frozen snapshot of the surface (154 tools, 19 groups). + + Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when + v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and + `layouts` moved 4 -> 12 with M6's layout/viewport lifecycle. That group + gained `entity_change_space` too: it is tagged `layout` *and* `modify`, and + `_GROUP_TAG_PRIORITY` ranks `layout` higher, so CHSPACE files under layouts + rather than splitting a ninth tool off into entity_modification. Every other + number here has been unchanged since the snapshot was taken. + """ + sizes = {label: len(names) for label, names in (await server._tool_groups()).items()} + assert sizes == { + "analysis": 12, + "batch": 3, + "blocks": 8, + "corner_ops": 4, + "dimensions": 5, + "drawing": 11, + "engineering": 10, + "entity_creation": 18, + "entity_modification": 15, + "entity_query": 8, + "layers": 14, + "layouts": 12, + "premium": 12, + "solids": 5, + "system": 7, + "templates": 2, + "transactions": 3, + "validation": 1, + "view": 4, + } + assert sum(sizes.values()) == 154 diff --git a/tests/test_undo_redo.py b/tests/test_undo_redo.py new file mode 100644 index 0000000..01a5bfb --- /dev/null +++ b/tests/test_undo_redo.py @@ -0,0 +1,173 @@ +"""M5 — undo/redo on the headless backend, and what it costs. + +The plan called this "add ezdxf redo". The larger half was that **undo did not +work either**: `_undo_stack` was declared, read and cleaned up, but nothing ever +appended to it, so `drawing_undo` could only ever answer "Nothing to undo". Four +places promised otherwise — the `@cad_tool` summary ("Step back one operation"), +the tool title, the docstring, and the alias corpus, which routes `UNDO` and +"take that back" straight at it. The existing test asserted +``isinstance(result, dict)``, which a permanent failure satisfies. + +Why it is opt-in. ezdxf has no journal; the only way to restore a previous state +is a full DXF snapshot. Measured on this machine: + + 10 entities -> 3.1 ms, 19 KB + 100 entities -> 5.6 ms, 31 KB + 1000 entities -> 28.4 ms, 146 KB + 5000 entities -> 129.7 ms, 658 KB + +Snapshotting on every mutation would make each new entity in a 5000-entity +drawing cost 130 ms. So `EZDXF_UNDO_DEPTH` defaults to 0 (off) and the tools +refuse with a capability that names the setting, rather than the server quietly +either lying or getting slow. +""" + +from __future__ import annotations + +import pytest +import pytest_asyncio + +import config +from backends.base import UnsupportedCapabilityError +from backends.ezdxf_backend import EzdxfBackend + +pytestmark = pytest.mark.asyncio + + +@pytest_asyncio.fixture +async def undoable(monkeypatch): + """A backend with undo switched on, as a user opting in would have it.""" + monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 8) + backend = EzdxfBackend() + await backend.connect() + await backend.drawing_new() + yield backend + await backend.disconnect() + + +async def _count(backend) -> int: + return len(list(backend._doc.modelspace())) + + +# ── the promise, kept ─────────────────────────────────────────────────────── + + +async def test_undo_actually_removes_the_last_entity(undoable): + await undoable.entity_create_line(0, 0, 10, 0) + await undoable.entity_create_circle(5, 5, 2) + assert await _count(undoable) == 2 + + result = await undoable.drawing_undo() + + assert result["ok"] is True + assert await _count(undoable) == 1, "the circle must be gone" + + +async def test_redo_puts_it_back(undoable): + await undoable.entity_create_line(0, 0, 10, 0) + await undoable.entity_create_circle(5, 5, 2) + await undoable.drawing_undo() + + result = await undoable.drawing_redo() + + assert result["ok"] is True + assert await _count(undoable) == 2 + + +async def test_undo_and_redo_walk_the_whole_history(undoable): + for i in range(4): + await undoable.entity_create_line(0, i, 10, i) + assert await _count(undoable) == 4 + + for expected in (3, 2, 1): + await undoable.drawing_undo() + assert await _count(undoable) == expected + for expected in (2, 3, 4): + await undoable.drawing_redo() + assert await _count(undoable) == expected + + +async def test_undo_stops_at_the_start_without_claiming_success(undoable): + await undoable.entity_create_line(0, 0, 10, 0) + await undoable.drawing_undo() + + result = await undoable.drawing_undo() + assert result["ok"] is False + assert await _count(undoable) == 0 + + +async def test_redo_with_nothing_to_redo_says_so(undoable): + await undoable.entity_create_line(0, 0, 10, 0) + result = await undoable.drawing_redo() + assert result["ok"] is False + + +# ── the branch that must not be reachable ─────────────────────────────────── + + +async def test_drawing_after_an_undo_discards_the_redo_branch(undoable): + """Redoing onto a timeline the user left would restore a state that never + existed — geometry they had deleted reappearing beside geometry they drew + afterwards.""" + await undoable.entity_create_line(0, 0, 10, 0) + await undoable.entity_create_circle(5, 5, 2) + await undoable.drawing_undo() # circle gone + await undoable.entity_create_arc(0, 0, 5, 0, 90) # new branch + + result = await undoable.drawing_redo() + + assert result["ok"] is False, "the circle's future is no longer reachable" + types = sorted(e.dxftype() for e in undoable._doc.modelspace()) + assert types == ["ARC", "LINE"], f"the circle must not come back: {types}" + + +async def test_history_is_bounded_by_the_configured_depth(monkeypatch): + monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 2) + backend = EzdxfBackend() + await backend.connect() + await backend.drawing_new() + try: + for i in range(5): + await backend.entity_create_line(0, i, 10, i) + + undone = 0 + while (await backend.drawing_undo())["ok"]: + undone += 1 + assert undone == 2, f"depth 2 must buy exactly two steps back, got {undone}" + finally: + await backend.disconnect() + + +# ── off by default, and honest about it ───────────────────────────────────── + + +async def test_undo_is_off_by_default_and_refuses_with_the_setting_named(backend): + assert config.settings.ezdxf_undo_depth == 0 + + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await backend.drawing_undo() + + assert excinfo.value.capability == "undo_history" + assert "EZDXF_UNDO_DEPTH" in str(excinfo.value), "the refusal must name the way in" + + +async def test_redo_refuses_the_same_way_when_history_is_off(backend): + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await backend.drawing_redo() + assert excinfo.value.capability == "undo_history" + + +async def test_the_capability_is_declared_on_both_backends(): + from backends.com_backend import ComBackend + + ezdxf_feature = EzdxfBackend().capabilities().features["undo_history"] + assert ezdxf_feature.supported is False + assert "EZDXF_UNDO_DEPTH" in (ezdxf_feature.reason or "") + + # AutoCAD keeps its own undo journal, so the live backend needs no setting. + assert ComBackend().capabilities().features["undo_history"].supported is True + + +async def test_switching_it_on_flips_the_capability(monkeypatch): + monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 4) + assert EzdxfBackend().capabilities().features["undo_history"].supported is True diff --git a/tests/test_viewport_ops.py b/tests/test_viewport_ops.py new file mode 100644 index 0000000..1bdd527 --- /dev/null +++ b/tests/test_viewport_ops.py @@ -0,0 +1,393 @@ +"""M6 — inspecting and editing the viewports on a sheet. + +`viewport_create` shipped in v1.4 and there was no way to see, rescale, lock or +remove what it made. Four things in the DXF viewport model make these more than +attribute pokes, and all four fail silently if ignored: + +* **The main viewport is not a drafting viewport.** Every page-set-up layout + carries an `id == 1` entity representing the tab's own pan/zoom state. Its + `view_height` is not a scale, so writing one reports success and changes + nothing on the sheet; deleting it leaves `layout.dxf.viewport_handle` pointing + at a dead handle, which ezdxf's Auditor reports as zero errors — only AutoCAD + notices. It is reported with `is_main`, never hidden: "I deleted every + viewport and one is still there" has to be explainable. +* **R12 has no slot for either attribute.** `view_height` and `flags` are + dropped on export, and `get_scale()` then returns a fabricated number (150.0 + measured, for a viewport that stores no scale at all). So the writers refuse + on R12 and the reader reports `None` rather than a number it invented. +* **`flags` is a shared bitfield.** A bare `flags = VSF_LOCK_ZOOM` wipes the + UCS-icon and grid bits the main viewport carries (`0x88020`) and still saves + cleanly. Read-modify-write only. +* **A deleted entity stays in `entitydb`** with `dxftype()` still answering + `"VIEWPORT"`, so a resolver that type-checks before checking liveness passes + on a corpse. + +Viewports are addressed by handle. `dxf.id` is exposed as metadata only: ezdxf's +first user viewport gets id 3 rather than 2, and ids are never renumbered. +""" + +from __future__ import annotations + +import pytest + +pytestmark = pytest.mark.asyncio + +SHEET = "A3-Sheet" +LOCK_BIT = 0x4000 + + +async def _sheet(backend, name: str = SHEET): + """A page-set-up A3 layout, which is what gives it a main viewport.""" + await backend.layout_create(name) + layout = backend._doc.layouts.get(name) + layout.page_setup(size=(420, 297), margins=(0, 0, 0, 0), units="mm") + return layout + + +async def _r12_backend(tmp_path): + """An R12 document opened through the backend, with one viewport on a sheet.""" + import ezdxf + + from backends.ezdxf_backend import EzdxfBackend + + doc = ezdxf.new("R12") + doc.layouts.get("Layout1").add_viewport( + center=(210, 148.5), size=(200, 150), view_center_point=(50, 25), view_height=300 + ) + path = tmp_path / "r12.dxf" + doc.saveas(str(path)) + + backend = EzdxfBackend() + await backend.connect() + await backend.drawing_open(str(path)) + return backend + + +# ── viewport_list ─────────────────────────────────────────────────────────── + + +async def test_listing_reports_the_viewport_that_was_created(backend): + await _sheet(backend) + created = await backend.viewport_create(SHEET, 210, 148.5, 200, 150, 50, 25, scale=0.5) + + result = await backend.viewport_list(SHEET) + + assert result["ok"] is True + row = next(vp for vp in result["viewports"] if vp["handle"] == created["handle"]) + assert row["layout"] == SHEET + assert row["is_main"] is False + assert row["scale"] == pytest.approx(0.5) + assert row["view_height"] == pytest.approx(300.0) + assert row["width"] == pytest.approx(200.0) + assert row["locked"] is False + + +async def test_the_main_viewport_is_reported_and_labelled_not_hidden(backend): + """Hiding it makes "I deleted them all and one remains" unexplainable.""" + layout = await _sheet(backend) + main = layout.main_viewport() + assert main is not None, "fixture assumption: page_setup creates a main viewport" + + result = await backend.viewport_list(SHEET) + + rows = {vp["handle"]: vp for vp in result["viewports"]} + assert rows[main.dxf.handle]["is_main"] is True + assert sum(1 for vp in rows.values() if vp["is_main"]) == 1 + + +async def test_a_fresh_layout_has_no_phantom_viewport(backend): + """The main viewport appears at page_setup, not at layout_create.""" + await backend.layout_create(SHEET) + result = await backend.viewport_list(SHEET) + assert result["viewports"] == [] + assert result["count"] == 0 + + +async def test_listing_without_a_layout_covers_every_sheet(backend): + await _sheet(backend, "S1") + await _sheet(backend, "S2") + await backend.viewport_create("S1", 100, 100, 50, 50, 0, 0) + await backend.viewport_create("S2", 100, 100, 50, 50, 0, 0) + + result = await backend.viewport_list() + + assert {vp["layout"] for vp in result["viewports"]} >= {"S1", "S2"} + + +async def test_listing_a_missing_or_blank_layout_refuses(backend): + """ezdxf's `layouts.get("")` quietly returns the active paper space.""" + for name in ("NOSUCH", "", " "): + result = await backend.viewport_list(name) + assert result["ok"] is False, f"viewport_list({name!r}) must refuse" + + +async def test_listing_model_space_refuses(backend): + result = await backend.viewport_list("Model") + assert result["ok"] is False + + +async def test_r12_reports_no_scale_rather_than_inventing_one(tmp_path): + """`get_scale()` returns a fabricated number when view_height is absent.""" + backend = await _r12_backend(tmp_path) + try: + result = await backend.viewport_list("Layout1") + assert result["ok"] is True + row = result["viewports"][0] + assert row["scale"] is None + assert row["locked"] is None + assert "R12" in result["note"] + finally: + await backend.disconnect() + + +# ── viewport_set_scale ────────────────────────────────────────────────────── + + +async def test_setting_the_scale_changes_the_view_height_and_nothing_else(backend): + await _sheet(backend) + created = await backend.viewport_create(SHEET, 210, 148.5, 200, 150, 50, 25, scale=1.0) + before = backend._doc.entitydb.get(created["handle"]) + center, width, height = before.dxf.center, before.dxf.width, before.dxf.height + view_center = before.dxf.view_center_point + + result = await backend.viewport_set_scale(created["handle"], 0.5) + + assert result["ok"] is True + assert result["scale"] == pytest.approx(0.5) + assert result["view_height"] == pytest.approx(300.0) + after = backend._doc.entitydb.get(created["handle"]) + assert after.dxf.center == center + assert (after.dxf.width, after.dxf.height) == (width, height) + assert after.dxf.view_center_point == view_center + + +async def test_the_reported_scale_is_read_back_not_echoed(backend): + await _sheet(backend) + created = await backend.viewport_create(SHEET, 210, 148.5, 200, 150, 50, 25) + + result = await backend.viewport_set_scale(created["handle"], 0.25) + + stored = backend._doc.entitydb.get(created["handle"]).get_scale() + assert result["scale"] == pytest.approx(stored) + + +@pytest.mark.parametrize("bad", [0, -1, -0.5]) +async def test_a_non_positive_scale_is_refused(backend, bad): + await _sheet(backend) + created = await backend.viewport_create(SHEET, 210, 148.5, 200, 150, 50, 25) + result = await backend.viewport_set_scale(created["handle"], bad) + assert result["ok"] is False + + +async def test_setting_the_scale_of_the_main_viewport_is_refused(backend): + """Its view_height is the tab's pan/zoom state, not a drafting scale.""" + layout = await _sheet(backend) + result = await backend.viewport_set_scale(layout.main_viewport().dxf.handle, 0.5) + assert result["ok"] is False + assert "main" in result["error"].lower() + + +async def test_setting_the_scale_on_r12_is_refused_rather_than_lost(tmp_path): + backend = await _r12_backend(tmp_path) + try: + handle = (await backend.viewport_list("Layout1"))["viewports"][0]["handle"] + result = await backend.viewport_set_scale(handle, 0.5) + assert result["ok"] is False + assert "R12" in result["error"] + finally: + await backend.disconnect() + + +async def test_a_handle_that_is_not_a_viewport_is_refused(backend): + line = await backend.entity_create_line(0, 0, 10, 0) + result = await backend.viewport_set_scale(line.handle, 0.5) + assert result["ok"] is False + assert "LINE" in result["error"] + + +async def test_an_unknown_handle_is_refused(backend): + result = await backend.viewport_set_scale("DEADBEEF", 0.5) + assert result["ok"] is False + + +# ── viewport_lock ─────────────────────────────────────────────────────────── + + +async def test_locking_and_unlocking_round_trips(backend, tmp_path): + import ezdxf + + await _sheet(backend) + created = await backend.viewport_create(SHEET, 210, 148.5, 200, 150, 50, 25) + + assert (await backend.viewport_lock(created["handle"], True))["locked"] is True + path = tmp_path / "locked.dxf" + await backend.drawing_save_as(str(path)) + reloaded = ezdxf.readfile(str(path)).entitydb.get(created["handle"]) + assert reloaded.dxf.flags & LOCK_BIT + + assert (await backend.viewport_lock(created["handle"], False))["locked"] is False + assert not (backend._doc.entitydb.get(created["handle"]).dxf.flags & LOCK_BIT) + + +async def test_locking_preserves_the_other_flag_bits(backend): + """A bare assignment wipes the UCS-icon and grid bits and still saves.""" + await _sheet(backend) + created = await backend.viewport_create(SHEET, 210, 148.5, 200, 150, 50, 25) + entity = backend._doc.entitydb.get(created["handle"]) + entity.dxf.flags = 0x88020 + + await backend.viewport_lock(created["handle"], True) + + assert backend._doc.entitydb.get(created["handle"]).dxf.flags == 0x88020 | LOCK_BIT + + +async def test_the_reported_lock_state_is_read_back_from_the_flags(backend): + await _sheet(backend) + created = await backend.viewport_create(SHEET, 210, 148.5, 200, 150, 50, 25) + await backend.viewport_lock(created["handle"], True) + + row = next( + vp + for vp in (await backend.viewport_list(SHEET))["viewports"] + if vp["handle"] == created["handle"] + ) + assert row["locked"] is True + + +async def test_locking_on_r12_is_refused_rather_than_lost(tmp_path): + backend = await _r12_backend(tmp_path) + try: + handle = (await backend.viewport_list("Layout1"))["viewports"][0]["handle"] + result = await backend.viewport_lock(handle, True) + assert result["ok"] is False + assert "R12" in result["error"] + finally: + await backend.disconnect() + + +# ── viewport_delete ───────────────────────────────────────────────────────── + + +async def test_deleting_a_viewport_removes_it_from_the_layout(backend): + await _sheet(backend) + created = await backend.viewport_create(SHEET, 210, 148.5, 200, 150, 50, 25) + + result = await backend.viewport_delete(created["handle"]) + + assert result["ok"] is True + assert result["layout"] == SHEET + assert result["was_main"] is False + handles = {vp["handle"] for vp in (await backend.viewport_list(SHEET))["viewports"]} + assert created["handle"] not in handles + + +async def test_deleting_the_main_viewport_needs_force(backend): + layout = await _sheet(backend) + main = layout.main_viewport() + + result = await backend.viewport_delete(main.dxf.handle) + + assert result["ok"] is False + assert layout.main_viewport() is not None + + +async def test_forcing_the_main_viewport_away_does_not_leave_a_dangling_pointer(backend): + """No auditor reports this; only AutoCAD does.""" + layout = await _sheet(backend) + main = layout.main_viewport() + + result = await backend.viewport_delete(main.dxf.handle, force=True) + + assert result["ok"] is True + assert result["was_main"] is True + pointer = layout.dxf.get("viewport_handle", None) + if pointer is not None: + target = backend._doc.entitydb.get(pointer) + assert target is not None and target.is_alive + + +async def test_a_deleted_viewport_handle_is_refused_afterwards(backend): + """The corpse stays in entitydb and still answers dxftype() == VIEWPORT.""" + await _sheet(backend) + created = await backend.viewport_create(SHEET, 210, 148.5, 200, 150, 50, 25) + await backend.viewport_delete(created["handle"]) + + result = await backend.viewport_set_scale(created["handle"], 0.5) + assert result["ok"] is False + + +async def test_deleting_a_handle_that_is_not_a_viewport_is_refused(backend): + line = await backend.entity_create_line(0, 0, 10, 0) + result = await backend.viewport_delete(line.handle) + assert result["ok"] is False + assert line.handle in {e.dxf.handle for e in backend._doc.modelspace()} + + +# ── what the headless renderer can actually do ────────────────────────────── + + +async def _ink(backend, layout: str, path) -> int: + """Dark pixels in a rendered layout — a proxy for "something was drawn".""" + from PIL import Image + + result = await backend.drawing_export_pdf(str(path), layout=layout) + assert result["ok"] is True, result + image = Image.open(str(path)).convert("L") + pixels = image.load() + width, height = image.size + return sum(1 for y in range(height) for x in range(width) if pixels[x, y] < 200) + + +async def test_the_headless_renderer_does_project_model_content_through_viewports( + backend, tmp_path +): + """v1.4 declared this COM-only. It is not, and has not been since ezdxf 1.4. + + The claim appeared in three places at once — the `viewport_render` + capability, the note in every `drawing_export_pdf` response, and the README + table — and it sent callers to the live backend for something the headless + one already does. Understating a capability is the same class of untruth as + overstating one: the response says something about the server that is not + so. + + Proven by subtraction rather than by pixel-peeping: a sheet whose only + contents are viewports renders *less* ink once the model geometry it looks + at is gone. + """ + pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra") + pytest.importorskip("PIL", reason="rendering needs the [pdf] extra") + + circle = await backend.entity_create_circle(100, 50, 40) + await _sheet(backend) + await backend.viewport_create(SHEET, 210, 148.5, 260, 180, 100, 50, scale=1.0) + assert not [e for e in backend._doc.layouts.get(SHEET) if e.dxftype() != "VIEWPORT"], ( + "the sheet must hold nothing but viewports, or the ink could be its own" + ) + + with_model = await _ink(backend, SHEET, tmp_path / "with.png") + await backend.entity_delete(circle.handle) + without_model = await _ink(backend, SHEET, tmp_path / "without.png") + + assert with_model > without_model, ( + "the sheet renders the same with and without the model geometry its " + "viewport looks at — projection really is not happening" + ) + + +async def test_the_capability_map_no_longer_calls_viewport_rendering_com_only(backend): + features = backend.capabilities().to_dict()["features"] + assert features["viewport_render"]["supported"] is True + + +async def test_the_export_note_does_not_send_callers_to_com_for_this(backend, tmp_path): + pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra") + await _sheet(backend) + result = await backend.drawing_export_pdf(str(tmp_path / "sheet.png"), layout=SHEET) + assert "COM-only" not in (result.get("note") or "") + + +async def test_exporting_a_layout_finds_it_whatever_case_it_is_typed_in(backend, tmp_path): + pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra") + await _sheet(backend) + result = await backend.drawing_export_pdf(str(tmp_path / "sheet.png"), layout=SHEET.upper()) + assert result["ok"] is True diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..6df8801 --- /dev/null +++ b/uv.lock @@ -0,0 +1,2409 @@ +version = 1 +revision = 3 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32'", + "python_full_version < '3.12'", +] + +[[package]] +name = "aiofile" +version = "3.12.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "caio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/59/d9/eddc8a0ed487d7e635af0a8748b09cfac38e246cfe01ab245bfc159dce08/aiofile-3.12.1.tar.gz", hash = "sha256:d0949082c814bce07966ab34cdc497f96c27ce5e4305da86eeabf5e56eccea1e", size = 20346, upload-time = "2026-08-04T15:39:42.387Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/36/7cbf1ccd8d6ca2b708aaebdae743b6f92197fb09f10b5a2b5ce504793f81/aiofile-3.12.1-py3-none-any.whl", hash = "sha256:80e2a8c47f3aba490af27c39261e002cc4ec58de86c8b856affeb94ca397ed0d", size = 20924, upload-time = "2026-08-04T15:39:41.124Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + +[[package]] +name = "authlib" +version = "1.7.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "joserfc" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/98/7d93f30d029643c0275dbc0bd6d5a6f670661ee6c9a94d93af7ab4887600/authlib-1.7.2.tar.gz", hash = "sha256:2cea25fefcd4e7173bdf1372c0afc265c8034b23a8cd5dcb6a9164b826c64231", size = 176511, upload-time = "2026-05-06T08:10:23.116Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/95/adcb68e20c34162e9135f370d6e31737719c2b6f94bc953fe7ed1f10fe21/authlib-1.7.2-py2.py3-none-any.whl", hash = "sha256:3e1faedc9d87e7d56a164eca3ccb6ace0d61b94abe83e92242f8dc8bba9b4a9f", size = 259548, upload-time = "2026-05-06T08:10:21.436Z" }, +] + +[[package]] +name = "autocad-mcp-pro" +version = "1.4.0" +source = { editable = "." } +dependencies = [ + { name = "ezdxf" }, + { name = "fastmcp" }, + { name = "pydantic" }, +] + +[package.optional-dependencies] +com = [ + { name = "pillow" }, + { name = "pywin32" }, +] +full = [ + { name = "matplotlib" }, + { name = "pillow" }, + { name = "pywin32" }, +] +pdf = [ + { name = "matplotlib" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "pytest-cov" }, + { name = "ruff" }, +] + +[package.metadata] +requires-dist = [ + { name = "ezdxf", specifier = ">=1.4,<2" }, + { name = "fastmcp", specifier = ">=3.4.5,<4" }, + { name = "matplotlib", marker = "extra == 'full'", specifier = ">=3.8" }, + { name = "matplotlib", marker = "extra == 'pdf'", specifier = ">=3.8" }, + { name = "pillow", marker = "extra == 'com'", specifier = ">=10.0" }, + { name = "pillow", marker = "extra == 'full'", specifier = ">=10.0" }, + { name = "pydantic", specifier = ">=2.0,<3" }, + { name = "pywin32", marker = "extra == 'com'", specifier = ">=306" }, + { name = "pywin32", marker = "extra == 'full'", specifier = ">=306" }, +] +provides-extras = ["com", "pdf", "full"] + +[package.metadata.requires-dev] +dev = [ + { name = "pytest", specifier = ">=8.0" }, + { name = "pytest-asyncio", specifier = ">=0.23" }, + { name = "pytest-cov", specifier = ">=5.0" }, + { name = "ruff", specifier = "==0.16.1" }, +] + +[[package]] +name = "backports-tarfile" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/86/72/cd9b395f25e290e633655a100af28cb253e4393396264a98bd5f5951d50f/backports_tarfile-1.2.0.tar.gz", hash = "sha256:d75e02c268746e1b8144c278978b6e98e85de6ad16f8e4b0844a154557eca991", size = 86406, upload-time = "2024-05-28T17:01:54.731Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, +] + +[[package]] +name = "beartype" +version = "0.22.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/94/1009e248bbfbab11397abca7193bea6626806be9a327d399810d523a07cb/beartype-0.22.9.tar.gz", hash = "sha256:8f82b54aa723a2848a56008d18875f91c1db02c32ef6a62319a002e3e25a975f", size = 1608866, upload-time = "2025-12-13T06:50:30.72Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/cc/18245721fa7747065ab478316c7fea7c74777d07f37ae60db2e84f8172e8/beartype-0.22.9-py3-none-any.whl", hash = "sha256:d16c9bbc61ea14637596c5f6fbff2ee99cbe3573e46a716401734ef50c3060c2", size = 1333658, upload-time = "2025-12-13T06:50:28.266Z" }, +] + +[[package]] +name = "cachetools" +version = "7.1.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/d2/47e8bc06fe2a06d3f5bdf20f1126ab66c4e99dc48d940e7ba873f7ac7131/cachetools-7.1.7.tar.gz", hash = "sha256:a3e2a00b14d8f8a6b70c1dae7b4685e7ad3bc965c5b42124a2d6ce895da6cf50", size = 40680, upload-time = "2026-08-01T21:20:40.434Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/d8/767faeda872075724b95dd675466a645f1b92aadcdcf2d1429dcfd76c176/cachetools-7.1.7-py3-none-any.whl", hash = "sha256:ef98ef375ad188819ef2f9b3645e3987f4b8c5b7550e436ad998c2de78296df0", size = 16830, upload-time = "2026-08-01T21:20:38.977Z" }, +] + +[[package]] +name = "caio" +version = "0.12.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/75/c8/82b3c760141a1076408164b03e8789b51809add6aecd48aa9d7651cf6b59/caio-0.12.2.tar.gz", hash = "sha256:87a67c0dccc60e432888bd532ec504b66e124a5d8b391aab894583b55abd39ea", size = 80927, upload-time = "2026-08-04T14:43:33.726Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/e9/c346d656b2f3626ef726aaac1c5ea58a981757cbed62087a9e81eaa4b09f/caio-0.12.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a2a7f0fa3b49f219f09705717c73a618eecbb3d697701e20abb4771d17e2bbcf", size = 84555, upload-time = "2026-08-04T14:42:56.84Z" }, + { url = "https://files.pythonhosted.org/packages/84/60/0b99da0d1345c0c1f9bde58ae96527b54b4aae32c2b6e74dcef514f7fa9c/caio-0.12.2-cp311-cp311-manylinux_2_34_aarch64.whl", hash = "sha256:1b04358ef65bd03d9c34d7b028efb422593b07485da82d6c5439f8c5dea35668", size = 196508, upload-time = "2026-08-04T14:42:58.074Z" }, + { url = "https://files.pythonhosted.org/packages/92/7e/7eb5a9ab97cfa5500a2cff8444e16e183aab6473912ac37d8bf9de283337/caio-0.12.2-cp311-cp311-manylinux_2_34_x86_64.whl", hash = "sha256:e1253743841b0864bfd6827e496fea4591bb3999ec1c003e57de65370e2d9031", size = 192985, upload-time = "2026-08-04T14:42:59.404Z" }, + { url = "https://files.pythonhosted.org/packages/92/13/308773ae27f33bc141720ded63216c9a115f5838c1e341d2d37c2b051281/caio-0.12.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd94522714af3fd806093bdd10f3175f1c42c5ee72dda975b8b35b55c3400147", size = 194087, upload-time = "2026-08-04T14:43:00.645Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8b/08814adc89f2c7612da9c2f17d962626bf59eb3b4dd715ea8198ccecb66c/caio-0.12.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a857d06308dc4428b760f9430350b3847f4c62ad0e79e635215f7cc97f7adcc9", size = 192586, upload-time = "2026-08-04T14:43:02.388Z" }, + { url = "https://files.pythonhosted.org/packages/60/bc/b62bf048a6e11870291a24319ed027bdf658df9ba77d1ad762aa138e066b/caio-0.12.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2097cc0d19fa95e8d55aad770597bb0f76e4f70ed48278c965aa7c5b0b8c3bf5", size = 84702, upload-time = "2026-08-04T14:43:03.946Z" }, + { url = "https://files.pythonhosted.org/packages/f7/be/b40d55d793afcfa5bcdb32ade9289d9588e14e3026c2c87522e303cc6e8c/caio-0.12.2-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:2122dccbd1959b922543fc9f8a9d2af47bd5b59190d1ece2445d3d1b4d1be45f", size = 198292, upload-time = "2026-08-04T14:43:05.238Z" }, + { url = "https://files.pythonhosted.org/packages/f8/02/9bd2bca72bfa478337618eae88942c43c891ae225e11baeae275e5e5c6ab/caio-0.12.2-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:107e56554c179749de9440e1b5e5a19813572eebf3166e9dc3e5228b16966beb", size = 196207, upload-time = "2026-08-04T14:43:06.494Z" }, + { url = "https://files.pythonhosted.org/packages/48/9b/65f95efdd68b50b7a9f2555c93d9edc7da7aa5ae5e153163c41cf6fd5cd9/caio-0.12.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adc7785e61ff7cf372318f67ec65617eaa06975e20da177522665dca8be6ea5d", size = 195748, upload-time = "2026-08-04T14:43:07.893Z" }, + { url = "https://files.pythonhosted.org/packages/3c/16/6a5c010ca435a5184d11ca350874694ac19db249560126dc8df0f25791ce/caio-0.12.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:07942d3b5999127ecb96256c38d5dbf49ed2864c087ed2a80b783901d0aa3ba1", size = 195835, upload-time = "2026-08-04T14:43:09.19Z" }, + { url = "https://files.pythonhosted.org/packages/4f/9b/31f0b49a2542ffa2f9d6140267e2b568e722a1feeb05cfbffea97666c62b/caio-0.12.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:40ebea9ebe3a3a66ae85fa00d4112d163654a33c82dcf9b26a99f7d30de13317", size = 84656, upload-time = "2026-08-04T14:43:10.513Z" }, + { url = "https://files.pythonhosted.org/packages/99/bc/62568d688af9712a34fe3f958d7a98c53bb2017e263260cd5deae67a90e9/caio-0.12.2-cp313-cp313-manylinux_2_34_aarch64.whl", hash = "sha256:6003ec389a68d5ec8f089df82b2dc8915293dd630a4d11322d7e3455045981fd", size = 198443, upload-time = "2026-08-04T14:43:11.767Z" }, + { url = "https://files.pythonhosted.org/packages/a3/e4/5ed627860285612e5307f06c109913c5918c947fbc223b55599e484c64b0/caio-0.12.2-cp313-cp313-manylinux_2_34_x86_64.whl", hash = "sha256:eee9376d0e2af25b6defc5bce39f6efa90521c803aaf12eba931bd898a397cfc", size = 196356, upload-time = "2026-08-04T14:43:13.206Z" }, + { url = "https://files.pythonhosted.org/packages/81/e2/2a8cfc6ba3ef3f19e7c778e9fb6f98600f0971cca78bbdfc23a413a66349/caio-0.12.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:78e3ccafc98e009fcb00a97ad441585551e52c0ae7ecc50427a3ccd9b11502fd", size = 195893, upload-time = "2026-08-04T14:43:14.649Z" }, + { url = "https://files.pythonhosted.org/packages/d1/87/77c40fb2301d0b5bb27c2e79ae42fce718ed75396d5fe3e1c09d8e1400b1/caio-0.12.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f2355db8917f5a0f3638bf332fe0d87549c80e978fca01db84a8a14b9df56a05", size = 195969, upload-time = "2026-08-04T14:43:15.946Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b5/0ceca97eb546fe6bbace3399c8b11dfc503efcc7509d708a7a3f09ab50e9/caio-0.12.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8054cba5e7ee623bea34946e2b59eb7c7c2be8872d0a5d12215d6ff564938d5f", size = 78621, upload-time = "2026-08-04T14:43:17.316Z" }, + { url = "https://files.pythonhosted.org/packages/9f/43/54d01cf9b643ffd6678aedee5ad5d6e7a1063bd169a203bfb6fb471e6887/caio-0.12.2-cp314-cp314-macosx_26_0_arm64.whl", hash = "sha256:5d658212d585b8814b9caf766d8090acac05f01abfa2875d57fdc4a7e2af032e", size = 77834, upload-time = "2026-08-04T14:43:18.492Z" }, + { url = "https://files.pythonhosted.org/packages/90/00/a0ca7394a0c3a234811b0c38b39aa0db9373663981c1cf446e26e7a7c198/caio-0.12.2-cp314-cp314-manylinux_2_34_aarch64.whl", hash = "sha256:391a7cc1dbfc5885d7d54406c9a7a4ec19d514d526e67d240d32734eebde378e", size = 198993, upload-time = "2026-08-04T14:43:19.992Z" }, + { url = "https://files.pythonhosted.org/packages/f6/b2/f8f1a5e57c16c86825f1f0648e76f7760f84452a41efee0d04fa53ef3e2d/caio-0.12.2-cp314-cp314-manylinux_2_34_x86_64.whl", hash = "sha256:121f4de82e2a875aff468ef2af7491fbecfffe9e71b507f5073fe2a156bb78df", size = 196408, upload-time = "2026-08-04T14:43:21.3Z" }, + { url = "https://files.pythonhosted.org/packages/01/db/5d94d1d58ef6f0acb39ab1a802793413a8b1e17108c05cf98cb4dc9e4b22/caio-0.12.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e0ba5f8c0dc7035c05817ca1399dd7d6121ea55c363a079c334a151a75094322", size = 196480, upload-time = "2026-08-04T14:43:22.812Z" }, + { url = "https://files.pythonhosted.org/packages/8c/2a/366adf468d7654b895e232eeaa80d147df2ba293f708bd9cef78b95f92d0/caio-0.12.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:0ad8d8f9f5ea47aee81aead563fe3aca5bb54c3fc21b62bd830eaf369eb04060", size = 196071, upload-time = "2026-08-04T14:43:24.187Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b1/4c0c989d2a24b8f3ba2e13b9115a107d9413b36aab8b299674b66da21c75/caio-0.12.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0b85f94819058a8f21c3dca26c5f006a0f003b8700483a326ec86d569d2bd1a3", size = 78627, upload-time = "2026-08-04T14:43:25.522Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/107cfe84199b9fb5a7317e1230d808944205fd91c7869641ac4e2ef5d603/caio-0.12.2-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:a60459e42c680068a2a5286f15f54ccd887af34ad9ed1be1f0a7747ad6bd8820", size = 220217, upload-time = "2026-08-04T14:43:26.823Z" }, + { url = "https://files.pythonhosted.org/packages/9f/1c/d6f03d3226519cd8f362081370326846348c442078e005753c57522a4190/caio-0.12.2-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:51bb86c0abce55d0b3467ba6671e95cd96356044e5abd58651ad0645cf37084b", size = 216293, upload-time = "2026-08-04T14:43:28.177Z" }, + { url = "https://files.pythonhosted.org/packages/43/a4/d53ed7e639b778b6f41a5e7c664b37f75830e38afa62dad62ec913674548/caio-0.12.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e33295963f5a4787355b8bfd754b4f0e7ac5d535138860748c1eb833ca10d620", size = 218220, upload-time = "2026-08-04T14:43:29.656Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d6/c353fd1dda371262995bba1b2f9aa42cc6cf7fc82c0853238510aa655bb9/caio-0.12.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15af6eb10d7705a92ee8143d8a4d89c2886ecb6b65ce1161d3dad1adb9b3cbec", size = 216106, upload-time = "2026-08-04T14:43:31.011Z" }, + { url = "https://files.pythonhosted.org/packages/61/8a/71b0144f783468ba9f1bbf8a2f8e45c7d85ae31ec192f10650aa46f31702/caio-0.12.2-py3-none-any.whl", hash = "sha256:5233e797c9fe2b541914b1bc2e2df82677e2206b537e44e252188f3c2cbb0ea9", size = 62548, upload-time = "2026-08-04T14:43:32.394Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + +[[package]] +name = "click" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "contourpy" +version = "1.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/01/1253e6698a07380cd31a736d248a3f2a50a7c88779a1813da27503cadc2a/contourpy-1.3.3.tar.gz", hash = "sha256:083e12155b210502d0bca491432bb04d56dc3432f95a979b429f2848c3dbe880", size = 13466174, upload-time = "2025-07-26T12:03:12.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/91/2e/c4390a31919d8a78b90e8ecf87cd4b4c4f05a5b48d05ec17db8e5404c6f4/contourpy-1.3.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:709a48ef9a690e1343202916450bc48b9e51c049b089c7f79a267b46cffcdaa1", size = 288773, upload-time = "2025-07-26T12:01:02.277Z" }, + { url = "https://files.pythonhosted.org/packages/0d/44/c4b0b6095fef4dc9c420e041799591e3b63e9619e3044f7f4f6c21c0ab24/contourpy-1.3.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:23416f38bfd74d5d28ab8429cc4d63fa67d5068bd711a85edb1c3fb0c3e2f381", size = 270149, upload-time = "2025-07-26T12:01:04.072Z" }, + { url = "https://files.pythonhosted.org/packages/30/2e/dd4ced42fefac8470661d7cb7e264808425e6c5d56d175291e93890cce09/contourpy-1.3.3-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:929ddf8c4c7f348e4c0a5a3a714b5c8542ffaa8c22954862a46ca1813b667ee7", size = 329222, upload-time = "2025-07-26T12:01:05.688Z" }, + { url = "https://files.pythonhosted.org/packages/f2/74/cc6ec2548e3d276c71389ea4802a774b7aa3558223b7bade3f25787fafc2/contourpy-1.3.3-cp311-cp311-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e999574eddae35f1312c2b4b717b7885d4edd6cb46700e04f7f02db454e67c1", size = 377234, upload-time = "2025-07-26T12:01:07.054Z" }, + { url = "https://files.pythonhosted.org/packages/03/b3/64ef723029f917410f75c09da54254c5f9ea90ef89b143ccadb09df14c15/contourpy-1.3.3-cp311-cp311-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0bf67e0e3f482cb69779dd3061b534eb35ac9b17f163d851e2a547d56dba0a3a", size = 380555, upload-time = "2025-07-26T12:01:08.801Z" }, + { url = "https://files.pythonhosted.org/packages/5f/4b/6157f24ca425b89fe2eb7e7be642375711ab671135be21e6faa100f7448c/contourpy-1.3.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e79c1f7470158e838808d4a996fa9bac72c498e93d8ebe5119bc1e6becb0db", size = 355238, upload-time = "2025-07-26T12:01:10.319Z" }, + { url = "https://files.pythonhosted.org/packages/98/56/f914f0dd678480708a04cfd2206e7c382533249bc5001eb9f58aa693e200/contourpy-1.3.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:598c3aaece21c503615fd59c92a3598b428b2f01bfb4b8ca9c4edeecc2438620", size = 1326218, upload-time = "2025-07-26T12:01:12.659Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d7/4a972334a0c971acd5172389671113ae82aa7527073980c38d5868ff1161/contourpy-1.3.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:322ab1c99b008dad206d406bb61d014cf0174df491ae9d9d0fac6a6fda4f977f", size = 1392867, upload-time = "2025-07-26T12:01:15.533Z" }, + { url = "https://files.pythonhosted.org/packages/75/3e/f2cc6cd56dc8cff46b1a56232eabc6feea52720083ea71ab15523daab796/contourpy-1.3.3-cp311-cp311-win32.whl", hash = "sha256:fd907ae12cd483cd83e414b12941c632a969171bf90fc937d0c9f268a31cafff", size = 183677, upload-time = "2025-07-26T12:01:17.088Z" }, + { url = "https://files.pythonhosted.org/packages/98/4b/9bd370b004b5c9d8045c6c33cf65bae018b27aca550a3f657cdc99acdbd8/contourpy-1.3.3-cp311-cp311-win_amd64.whl", hash = "sha256:3519428f6be58431c56581f1694ba8e50626f2dd550af225f82fb5f5814d2a42", size = 225234, upload-time = "2025-07-26T12:01:18.256Z" }, + { url = "https://files.pythonhosted.org/packages/d9/b6/71771e02c2e004450c12b1120a5f488cad2e4d5b590b1af8bad060360fe4/contourpy-1.3.3-cp311-cp311-win_arm64.whl", hash = "sha256:15ff10bfada4bf92ec8b31c62bf7c1834c244019b4a33095a68000d7075df470", size = 193123, upload-time = "2025-07-26T12:01:19.848Z" }, + { url = "https://files.pythonhosted.org/packages/be/45/adfee365d9ea3d853550b2e735f9d66366701c65db7855cd07621732ccfc/contourpy-1.3.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b08a32ea2f8e42cf1d4be3169a98dd4be32bafe4f22b6c4cb4ba810fa9e5d2cb", size = 293419, upload-time = "2025-07-26T12:01:21.16Z" }, + { url = "https://files.pythonhosted.org/packages/53/3e/405b59cfa13021a56bba395a6b3aca8cec012b45bf177b0eaf7a202cde2c/contourpy-1.3.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:556dba8fb6f5d8742f2923fe9457dbdd51e1049c4a43fd3986a0b14a1d815fc6", size = 273979, upload-time = "2025-07-26T12:01:22.448Z" }, + { url = "https://files.pythonhosted.org/packages/d4/1c/a12359b9b2ca3a845e8f7f9ac08bdf776114eb931392fcad91743e2ea17b/contourpy-1.3.3-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92d9abc807cf7d0e047b95ca5d957cf4792fcd04e920ca70d48add15c1a90ea7", size = 332653, upload-time = "2025-07-26T12:01:24.155Z" }, + { url = "https://files.pythonhosted.org/packages/63/12/897aeebfb475b7748ea67b61e045accdfcf0d971f8a588b67108ed7f5512/contourpy-1.3.3-cp312-cp312-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b2e8faa0ed68cb29af51edd8e24798bb661eac3bd9f65420c1887b6ca89987c8", size = 379536, upload-time = "2025-07-26T12:01:25.91Z" }, + { url = "https://files.pythonhosted.org/packages/43/8a/a8c584b82deb248930ce069e71576fc09bd7174bbd35183b7943fb1064fd/contourpy-1.3.3-cp312-cp312-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:626d60935cf668e70a5ce6ff184fd713e9683fb458898e4249b63be9e28286ea", size = 384397, upload-time = "2025-07-26T12:01:27.152Z" }, + { url = "https://files.pythonhosted.org/packages/cc/8f/ec6289987824b29529d0dfda0d74a07cec60e54b9c92f3c9da4c0ac732de/contourpy-1.3.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d00e655fcef08aba35ec9610536bfe90267d7ab5ba944f7032549c55a146da1", size = 362601, upload-time = "2025-07-26T12:01:28.808Z" }, + { url = "https://files.pythonhosted.org/packages/05/0a/a3fe3be3ee2dceb3e615ebb4df97ae6f3828aa915d3e10549ce016302bd1/contourpy-1.3.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:451e71b5a7d597379ef572de31eeb909a87246974d960049a9848c3bc6c41bf7", size = 1331288, upload-time = "2025-07-26T12:01:31.198Z" }, + { url = "https://files.pythonhosted.org/packages/33/1d/acad9bd4e97f13f3e2b18a3977fe1b4a37ecf3d38d815333980c6c72e963/contourpy-1.3.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:459c1f020cd59fcfe6650180678a9993932d80d44ccde1fa1868977438f0b411", size = 1403386, upload-time = "2025-07-26T12:01:33.947Z" }, + { url = "https://files.pythonhosted.org/packages/cf/8f/5847f44a7fddf859704217a99a23a4f6417b10e5ab1256a179264561540e/contourpy-1.3.3-cp312-cp312-win32.whl", hash = "sha256:023b44101dfe49d7d53932be418477dba359649246075c996866106da069af69", size = 185018, upload-time = "2025-07-26T12:01:35.64Z" }, + { url = "https://files.pythonhosted.org/packages/19/e8/6026ed58a64563186a9ee3f29f41261fd1828f527dd93d33b60feca63352/contourpy-1.3.3-cp312-cp312-win_amd64.whl", hash = "sha256:8153b8bfc11e1e4d75bcb0bff1db232f9e10b274e0929de9d608027e0d34ff8b", size = 226567, upload-time = "2025-07-26T12:01:36.804Z" }, + { url = "https://files.pythonhosted.org/packages/d1/e2/f05240d2c39a1ed228d8328a78b6f44cd695f7ef47beb3e684cf93604f86/contourpy-1.3.3-cp312-cp312-win_arm64.whl", hash = "sha256:07ce5ed73ecdc4a03ffe3e1b3e3c1166db35ae7584be76f65dbbe28a7791b0cc", size = 193655, upload-time = "2025-07-26T12:01:37.999Z" }, + { url = "https://files.pythonhosted.org/packages/68/35/0167aad910bbdb9599272bd96d01a9ec6852f36b9455cf2ca67bd4cc2d23/contourpy-1.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:177fb367556747a686509d6fef71d221a4b198a3905fe824430e5ea0fda54eb5", size = 293257, upload-time = "2025-07-26T12:01:39.367Z" }, + { url = "https://files.pythonhosted.org/packages/96/e4/7adcd9c8362745b2210728f209bfbcf7d91ba868a2c5f40d8b58f54c509b/contourpy-1.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d002b6f00d73d69333dac9d0b8d5e84d9724ff9ef044fd63c5986e62b7c9e1b1", size = 274034, upload-time = "2025-07-26T12:01:40.645Z" }, + { url = "https://files.pythonhosted.org/packages/73/23/90e31ceeed1de63058a02cb04b12f2de4b40e3bef5e082a7c18d9c8ae281/contourpy-1.3.3-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:348ac1f5d4f1d66d3322420f01d42e43122f43616e0f194fc1c9f5d830c5b286", size = 334672, upload-time = "2025-07-26T12:01:41.942Z" }, + { url = "https://files.pythonhosted.org/packages/ed/93/b43d8acbe67392e659e1d984700e79eb67e2acb2bd7f62012b583a7f1b55/contourpy-1.3.3-cp313-cp313-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:655456777ff65c2c548b7c454af9c6f33f16c8884f11083244b5819cc214f1b5", size = 381234, upload-time = "2025-07-26T12:01:43.499Z" }, + { url = "https://files.pythonhosted.org/packages/46/3b/bec82a3ea06f66711520f75a40c8fc0b113b2a75edb36aa633eb11c4f50f/contourpy-1.3.3-cp313-cp313-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:644a6853d15b2512d67881586bd03f462c7ab755db95f16f14d7e238f2852c67", size = 385169, upload-time = "2025-07-26T12:01:45.219Z" }, + { url = "https://files.pythonhosted.org/packages/4b/32/e0f13a1c5b0f8572d0ec6ae2f6c677b7991fafd95da523159c19eff0696a/contourpy-1.3.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4debd64f124ca62069f313a9cb86656ff087786016d76927ae2cf37846b006c9", size = 362859, upload-time = "2025-07-26T12:01:46.519Z" }, + { url = "https://files.pythonhosted.org/packages/33/71/e2a7945b7de4e58af42d708a219f3b2f4cff7386e6b6ab0a0fa0033c49a9/contourpy-1.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a15459b0f4615b00bbd1e91f1b9e19b7e63aea7483d03d804186f278c0af2659", size = 1332062, upload-time = "2025-07-26T12:01:48.964Z" }, + { url = "https://files.pythonhosted.org/packages/12/fc/4e87ac754220ccc0e807284f88e943d6d43b43843614f0a8afa469801db0/contourpy-1.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca0fdcd73925568ca027e0b17ab07aad764be4706d0a925b89227e447d9737b7", size = 1403932, upload-time = "2025-07-26T12:01:51.979Z" }, + { url = "https://files.pythonhosted.org/packages/a6/2e/adc197a37443f934594112222ac1aa7dc9a98faf9c3842884df9a9d8751d/contourpy-1.3.3-cp313-cp313-win32.whl", hash = "sha256:b20c7c9a3bf701366556e1b1984ed2d0cedf999903c51311417cf5f591d8c78d", size = 185024, upload-time = "2025-07-26T12:01:53.245Z" }, + { url = "https://files.pythonhosted.org/packages/18/0b/0098c214843213759692cc638fce7de5c289200a830e5035d1791d7a2338/contourpy-1.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:1cadd8b8969f060ba45ed7c1b714fe69185812ab43bd6b86a9123fe8f99c3263", size = 226578, upload-time = "2025-07-26T12:01:54.422Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9a/2f6024a0c5995243cd63afdeb3651c984f0d2bc727fd98066d40e141ad73/contourpy-1.3.3-cp313-cp313-win_arm64.whl", hash = "sha256:fd914713266421b7536de2bfa8181aa8c699432b6763a0ea64195ebe28bff6a9", size = 193524, upload-time = "2025-07-26T12:01:55.73Z" }, + { url = "https://files.pythonhosted.org/packages/c0/b3/f8a1a86bd3298513f500e5b1f5fd92b69896449f6cab6a146a5d52715479/contourpy-1.3.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:88df9880d507169449d434c293467418b9f6cbe82edd19284aa0409e7fdb933d", size = 306730, upload-time = "2025-07-26T12:01:57.051Z" }, + { url = "https://files.pythonhosted.org/packages/3f/11/4780db94ae62fc0c2053909b65dc3246bd7cecfc4f8a20d957ad43aa4ad8/contourpy-1.3.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d06bb1f751ba5d417047db62bca3c8fde202b8c11fb50742ab3ab962c81e8216", size = 287897, upload-time = "2025-07-26T12:01:58.663Z" }, + { url = "https://files.pythonhosted.org/packages/ae/15/e59f5f3ffdd6f3d4daa3e47114c53daabcb18574a26c21f03dc9e4e42ff0/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4e6b05a45525357e382909a4c1600444e2a45b4795163d3b22669285591c1ae", size = 326751, upload-time = "2025-07-26T12:02:00.343Z" }, + { url = "https://files.pythonhosted.org/packages/0f/81/03b45cfad088e4770b1dcf72ea78d3802d04200009fb364d18a493857210/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ab3074b48c4e2cf1a960e6bbeb7f04566bf36b1861d5c9d4d8ac04b82e38ba20", size = 375486, upload-time = "2025-07-26T12:02:02.128Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ba/49923366492ffbdd4486e970d421b289a670ae8cf539c1ea9a09822b371a/contourpy-1.3.3-cp313-cp313t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c3d53c796f8647d6deb1abe867daeb66dcc8a97e8455efa729516b997b8ed99", size = 388106, upload-time = "2025-07-26T12:02:03.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/52/5b00ea89525f8f143651f9f03a0df371d3cbd2fccd21ca9b768c7a6500c2/contourpy-1.3.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50ed930df7289ff2a8d7afeb9603f8289e5704755c7e5c3bbd929c90c817164b", size = 352548, upload-time = "2025-07-26T12:02:05.165Z" }, + { url = "https://files.pythonhosted.org/packages/32/1d/a209ec1a3a3452d490f6b14dd92e72280c99ae3d1e73da74f8277d4ee08f/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4feffb6537d64b84877da813a5c30f1422ea5739566abf0bd18065ac040e120a", size = 1322297, upload-time = "2025-07-26T12:02:07.379Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9e/46f0e8ebdd884ca0e8877e46a3f4e633f6c9c8c4f3f6e72be3fe075994aa/contourpy-1.3.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2b7e9480ffe2b0cd2e787e4df64270e3a0440d9db8dc823312e2c940c167df7e", size = 1391023, upload-time = "2025-07-26T12:02:10.171Z" }, + { url = "https://files.pythonhosted.org/packages/b9/70/f308384a3ae9cd2209e0849f33c913f658d3326900d0ff5d378d6a1422d2/contourpy-1.3.3-cp313-cp313t-win32.whl", hash = "sha256:283edd842a01e3dcd435b1c5116798d661378d83d36d337b8dde1d16a5fc9ba3", size = 196157, upload-time = "2025-07-26T12:02:11.488Z" }, + { url = "https://files.pythonhosted.org/packages/b2/dd/880f890a6663b84d9e34a6f88cded89d78f0091e0045a284427cb6b18521/contourpy-1.3.3-cp313-cp313t-win_amd64.whl", hash = "sha256:87acf5963fc2b34825e5b6b048f40e3635dd547f590b04d2ab317c2619ef7ae8", size = 240570, upload-time = "2025-07-26T12:02:12.754Z" }, + { url = "https://files.pythonhosted.org/packages/80/99/2adc7d8ffead633234817ef8e9a87115c8a11927a94478f6bb3d3f4d4f7d/contourpy-1.3.3-cp313-cp313t-win_arm64.whl", hash = "sha256:3c30273eb2a55024ff31ba7d052dde990d7d8e5450f4bbb6e913558b3d6c2301", size = 199713, upload-time = "2025-07-26T12:02:14.4Z" }, + { url = "https://files.pythonhosted.org/packages/72/8b/4546f3ab60f78c514ffb7d01a0bd743f90de36f0019d1be84d0a708a580a/contourpy-1.3.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fde6c716d51c04b1c25d0b90364d0be954624a0ee9d60e23e850e8d48353d07a", size = 292189, upload-time = "2025-07-26T12:02:16.095Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e1/3542a9cb596cadd76fcef413f19c79216e002623158befe6daa03dbfa88c/contourpy-1.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cbedb772ed74ff5be440fa8eee9bd49f64f6e3fc09436d9c7d8f1c287b121d77", size = 273251, upload-time = "2025-07-26T12:02:17.524Z" }, + { url = "https://files.pythonhosted.org/packages/b1/71/f93e1e9471d189f79d0ce2497007731c1e6bf9ef6d1d61b911430c3db4e5/contourpy-1.3.3-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22e9b1bd7a9b1d652cd77388465dc358dafcd2e217d35552424aa4f996f524f5", size = 335810, upload-time = "2025-07-26T12:02:18.9Z" }, + { url = "https://files.pythonhosted.org/packages/91/f9/e35f4c1c93f9275d4e38681a80506b5510e9327350c51f8d4a5a724d178c/contourpy-1.3.3-cp314-cp314-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a22738912262aa3e254e4f3cb079a95a67132fc5a063890e224393596902f5a4", size = 382871, upload-time = "2025-07-26T12:02:20.418Z" }, + { url = "https://files.pythonhosted.org/packages/b5/71/47b512f936f66a0a900d81c396a7e60d73419868fba959c61efed7a8ab46/contourpy-1.3.3-cp314-cp314-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:afe5a512f31ee6bd7d0dda52ec9864c984ca3d66664444f2d72e0dc4eb832e36", size = 386264, upload-time = "2025-07-26T12:02:21.916Z" }, + { url = "https://files.pythonhosted.org/packages/04/5f/9ff93450ba96b09c7c2b3f81c94de31c89f92292f1380261bd7195bea4ea/contourpy-1.3.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f64836de09927cba6f79dcd00fdd7d5329f3fccc633468507079c829ca4db4e3", size = 363819, upload-time = "2025-07-26T12:02:23.759Z" }, + { url = "https://files.pythonhosted.org/packages/3e/a6/0b185d4cc480ee494945cde102cb0149ae830b5fa17bf855b95f2e70ad13/contourpy-1.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:1fd43c3be4c8e5fd6e4f2baeae35ae18176cf2e5cced681cca908addf1cdd53b", size = 1333650, upload-time = "2025-07-26T12:02:26.181Z" }, + { url = "https://files.pythonhosted.org/packages/43/d7/afdc95580ca56f30fbcd3060250f66cedbde69b4547028863abd8aa3b47e/contourpy-1.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6afc576f7b33cf00996e5c1102dc2a8f7cc89e39c0b55df93a0b78c1bd992b36", size = 1404833, upload-time = "2025-07-26T12:02:28.782Z" }, + { url = "https://files.pythonhosted.org/packages/e2/e2/366af18a6d386f41132a48f033cbd2102e9b0cf6345d35ff0826cd984566/contourpy-1.3.3-cp314-cp314-win32.whl", hash = "sha256:66c8a43a4f7b8df8b71ee1840e4211a3c8d93b214b213f590e18a1beca458f7d", size = 189692, upload-time = "2025-07-26T12:02:30.128Z" }, + { url = "https://files.pythonhosted.org/packages/7d/c2/57f54b03d0f22d4044b8afb9ca0e184f8b1afd57b4f735c2fa70883dc601/contourpy-1.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:cf9022ef053f2694e31d630feaacb21ea24224be1c3ad0520b13d844274614fd", size = 232424, upload-time = "2025-07-26T12:02:31.395Z" }, + { url = "https://files.pythonhosted.org/packages/18/79/a9416650df9b525737ab521aa181ccc42d56016d2123ddcb7b58e926a42c/contourpy-1.3.3-cp314-cp314-win_arm64.whl", hash = "sha256:95b181891b4c71de4bb404c6621e7e2390745f887f2a026b2d99e92c17892339", size = 198300, upload-time = "2025-07-26T12:02:32.956Z" }, + { url = "https://files.pythonhosted.org/packages/1f/42/38c159a7d0f2b7b9c04c64ab317042bb6952b713ba875c1681529a2932fe/contourpy-1.3.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:33c82d0138c0a062380332c861387650c82e4cf1747aaa6938b9b6516762e772", size = 306769, upload-time = "2025-07-26T12:02:34.2Z" }, + { url = "https://files.pythonhosted.org/packages/c3/6c/26a8205f24bca10974e77460de68d3d7c63e282e23782f1239f226fcae6f/contourpy-1.3.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ea37e7b45949df430fe649e5de8351c423430046a2af20b1c1961cae3afcda77", size = 287892, upload-time = "2025-07-26T12:02:35.807Z" }, + { url = "https://files.pythonhosted.org/packages/66/06/8a475c8ab718ebfd7925661747dbb3c3ee9c82ac834ccb3570be49d129f4/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d304906ecc71672e9c89e87c4675dc5c2645e1f4269a5063b99b0bb29f232d13", size = 326748, upload-time = "2025-07-26T12:02:37.193Z" }, + { url = "https://files.pythonhosted.org/packages/b4/a3/c5ca9f010a44c223f098fccd8b158bb1cb287378a31ac141f04730dc49be/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ca658cd1a680a5c9ea96dc61cdbae1e85c8f25849843aa799dfd3cb370ad4fbe", size = 375554, upload-time = "2025-07-26T12:02:38.894Z" }, + { url = "https://files.pythonhosted.org/packages/80/5b/68bd33ae63fac658a4145088c1e894405e07584a316738710b636c6d0333/contourpy-1.3.3-cp314-cp314t-manylinux_2_26_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ab2fd90904c503739a75b7c8c5c01160130ba67944a7b77bbf36ef8054576e7f", size = 388118, upload-time = "2025-07-26T12:02:40.642Z" }, + { url = "https://files.pythonhosted.org/packages/40/52/4c285a6435940ae25d7410a6c36bda5145839bc3f0beb20c707cda18b9d2/contourpy-1.3.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b7301b89040075c30e5768810bc96a8e8d78085b47d8be6e4c3f5a0b4ed478a0", size = 352555, upload-time = "2025-07-26T12:02:42.25Z" }, + { url = "https://files.pythonhosted.org/packages/24/ee/3e81e1dd174f5c7fefe50e85d0892de05ca4e26ef1c9a59c2a57e43b865a/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2a2a8b627d5cc6b7c41a4beff6c5ad5eb848c88255fda4a8745f7e901b32d8e4", size = 1322295, upload-time = "2025-07-26T12:02:44.668Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b2/6d913d4d04e14379de429057cd169e5e00f6c2af3bb13e1710bcbdb5da12/contourpy-1.3.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fd6ec6be509c787f1caf6b247f0b1ca598bef13f4ddeaa126b7658215529ba0f", size = 1391027, upload-time = "2025-07-26T12:02:47.09Z" }, + { url = "https://files.pythonhosted.org/packages/93/8a/68a4ec5c55a2971213d29a9374913f7e9f18581945a7a31d1a39b5d2dfe5/contourpy-1.3.3-cp314-cp314t-win32.whl", hash = "sha256:e74a9a0f5e3fff48fb5a7f2fd2b9b70a3fe014a67522f79b7cca4c0c7e43c9ae", size = 202428, upload-time = "2025-07-26T12:02:48.691Z" }, + { url = "https://files.pythonhosted.org/packages/fa/96/fd9f641ffedc4fa3ace923af73b9d07e869496c9cc7a459103e6e978992f/contourpy-1.3.3-cp314-cp314t-win_amd64.whl", hash = "sha256:13b68d6a62db8eafaebb8039218921399baf6e47bf85006fd8529f2a08ef33fc", size = 250331, upload-time = "2025-07-26T12:02:50.137Z" }, + { url = "https://files.pythonhosted.org/packages/ae/8c/469afb6465b853afff216f9528ffda78a915ff880ed58813ba4faf4ba0b6/contourpy-1.3.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b7448cb5a725bb1e35ce88771b86fba35ef418952474492cf7c764059933ff8b", size = 203831, upload-time = "2025-07-26T12:02:51.449Z" }, + { url = "https://files.pythonhosted.org/packages/a5/29/8dcfe16f0107943fa92388c23f6e05cff0ba58058c4c95b00280d4c75a14/contourpy-1.3.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cd5dfcaeb10f7b7f9dc8941717c6c2ade08f587be2226222c12b25f0483ed497", size = 278809, upload-time = "2025-07-26T12:02:52.74Z" }, + { url = "https://files.pythonhosted.org/packages/85/a9/8b37ef4f7dafeb335daee3c8254645ef5725be4d9c6aa70b50ec46ef2f7e/contourpy-1.3.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:0c1fc238306b35f246d61a1d416a627348b5cf0648648a031e14bb8705fcdfe8", size = 261593, upload-time = "2025-07-26T12:02:54.037Z" }, + { url = "https://files.pythonhosted.org/packages/0a/59/ebfb8c677c75605cc27f7122c90313fd2f375ff3c8d19a1694bda74aaa63/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:70f9aad7de812d6541d29d2bbf8feb22ff7e1c299523db288004e3157ff4674e", size = 302202, upload-time = "2025-07-26T12:02:55.947Z" }, + { url = "https://files.pythonhosted.org/packages/3c/37/21972a15834d90bfbfb009b9d004779bd5a07a0ec0234e5ba8f64d5736f4/contourpy-1.3.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5ed3657edf08512fc3fe81b510e35c2012fbd3081d2e26160f27ca28affec989", size = 329207, upload-time = "2025-07-26T12:02:57.468Z" }, + { url = "https://files.pythonhosted.org/packages/0c/58/bd257695f39d05594ca4ad60df5bcb7e32247f9951fd09a9b8edb82d1daa/contourpy-1.3.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:3d1a3799d62d45c18bafd41c5fa05120b96a28079f2393af559b843d1a966a77", size = 225315, upload-time = "2025-07-26T12:02:58.801Z" }, +] + +[[package]] +name = "coverage" +version = "7.15.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f4/45/78dbf9604ee5b3db24efbf26bed1cb58862fb40480cba821963c69348751/coverage-7.15.3.tar.gz", hash = "sha256:ae7ea5a4614acf399ef0483c4cb34f8f8f01df848d8fcbe7d3ce0865733f1c4d", size = 935592, upload-time = "2026-08-02T18:50:17.006Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/9c/c8a3a923c24f631695cea2d5e2f02e776bc0af6e03800626e13a6c05a615/coverage-7.15.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5f3f854ab4599d98f7799ac9b91e34e8ec9ebc9a6372ee8c1f3413a68cc8b5e9", size = 222328, upload-time = "2026-08-02T18:47:49.228Z" }, + { url = "https://files.pythonhosted.org/packages/92/51/dda77f34cbd2513d6ffb898c901d19e9ca55f48c0cbc4a1eb173a97d157a/coverage-7.15.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:75268348fee1f199653b8a846262aec5581c6bb008c4f58824959fb708cc688f", size = 222832, upload-time = "2026-08-02T18:47:51.219Z" }, + { url = "https://files.pythonhosted.org/packages/78/59/e0faafc4c6e23bd76c76148875ee9ec5781b8f1cd62cea2bc4ca0f0f0e5d/coverage-7.15.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:21081739f6264cc594cad2d42b62befbd17633824022866c68720eb0c4b8d6b4", size = 253250, upload-time = "2026-08-02T18:47:52.737Z" }, + { url = "https://files.pythonhosted.org/packages/14/e2/4b1e0eeb727ffb471e411c1bd3402184b5dd54a77a762b0e55e87cdf9ae3/coverage-7.15.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:718d366251b060c10731c7dd359de6caea72250036eb94576aa56dacbf830a11", size = 255160, upload-time = "2026-08-02T18:47:54.404Z" }, + { url = "https://files.pythonhosted.org/packages/e9/9e/a602d2d48f9db9f795e578a86aa914f7b20008e9330902defcfb73d17b3a/coverage-7.15.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fa1bbaa502a6e877f3ee67cbac3eba2bb637f623e454e6c37b81b38896dbd48f", size = 257269, upload-time = "2026-08-02T18:47:56.157Z" }, + { url = "https://files.pythonhosted.org/packages/22/fa/bf6db13df2fcee00d2671849fe58c99232ee79a01fec7478c2bf7839b9e1/coverage-7.15.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:494880c9e60782610683f4eb9b65cce4f886673596b8f3cb2dfa079fc551c743", size = 259231, upload-time = "2026-08-02T18:47:57.76Z" }, + { url = "https://files.pythonhosted.org/packages/89/37/8118f13b17fa7d9a3aa2c301d93f2d5ffeef70fa7e27e639a74bdacd3fea/coverage-7.15.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3db264ea689f9e8f9fa4fb9005fee4048c3bff4a547f4cfa27f5086cb0804ec0", size = 253357, upload-time = "2026-08-02T18:47:59.261Z" }, + { url = "https://files.pythonhosted.org/packages/97/6d/c7b94fb03962f4d6f0fe13d01c4eb9c4c6e2e714a20d074516ec7582b110/coverage-7.15.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4e869d4799674d67778e76ddbe2e26cf1673369262e231a8ec259421b1015fea", size = 254961, upload-time = "2026-08-02T18:48:00.901Z" }, + { url = "https://files.pythonhosted.org/packages/87/f9/fe0bd415fa56e36b62b649017c8fc98330858be4c7593789efb78cd24178/coverage-7.15.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:696fc7a28bbf717aba8d2c6963d26702945c7832cb313ba3b323aa5b1afb3156", size = 253024, upload-time = "2026-08-02T18:48:02.745Z" }, + { url = "https://files.pythonhosted.org/packages/c1/7c/ffa53506d63ba8a77f5b9557dd6f5a5a5ad85adc680d7857410138f82bd9/coverage-7.15.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:3fe9be1c527497d047f770d88a0110189714c36383bb88384508f750c302bffa", size = 256792, upload-time = "2026-08-02T18:48:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/1f/c6/df42458e72c18a49fe87e40ccd3fb0314210915256cf4a5593e1b3250e04/coverage-7.15.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2400591f4b2e33746c70846388f8bb4c7e33b820e31cb8c6cb2f25305310438b", size = 252744, upload-time = "2026-08-02T18:48:06.154Z" }, + { url = "https://files.pythonhosted.org/packages/f1/14/8bf18a4b10a44f8ba5f604b00e102f37daf49d581d66a37dc33fa267e1a6/coverage-7.15.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2e557178799282269412a672e5753f2179edfe1b3f0f19b0c98f8e72d482326a", size = 253652, upload-time = "2026-08-02T18:48:07.955Z" }, + { url = "https://files.pythonhosted.org/packages/27/e6/e530c9bb94e4155817cbd149034105b062a6913bc356ae08f454d155de53/coverage-7.15.3-cp311-cp311-win32.whl", hash = "sha256:68ea6c947375982ae907e19e9d2ef156bd6e68e11f3566dd568d7f4ec974e715", size = 224428, upload-time = "2026-08-02T18:48:09.845Z" }, + { url = "https://files.pythonhosted.org/packages/b4/98/0050c692d120988f1973a15196f52dee4ae221848b760281461a2005b613/coverage-7.15.3-cp311-cp311-win_amd64.whl", hash = "sha256:28743dad31622e8c474b17446118037361f5b1f4f2ecdf72d4f6fde246d64446", size = 224906, upload-time = "2026-08-02T18:48:11.611Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ae/c0ef3e2ba3f35fc1c6985811a40edd9331e5b8978c9ecf84699de3edacbe/coverage-7.15.3-cp311-cp311-win_arm64.whl", hash = "sha256:c4398918c4fda32718191239e451fd86ac5ad1e8979b592f1921ee2d1f038965", size = 224448, upload-time = "2026-08-02T18:48:13.304Z" }, + { url = "https://files.pythonhosted.org/packages/d1/6c/bac99d9d4c6abe856e93bf3f5212982ac0bfac126dd4a042753bd53bc5af/coverage-7.15.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:79a3e32e83227d83d9684459ed579769b56c369ac2d7313099b2d9e031d2e10f", size = 222499, upload-time = "2026-08-02T18:48:15.018Z" }, + { url = "https://files.pythonhosted.org/packages/aa/bc/cb9a39b083bc1aa70586482dab25c9be20bab0ec6c155340e50d9066bb1e/coverage-7.15.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:767feb87c5886d781d0a69fafd450a20826ddab7b79bce1665deb64d21441b60", size = 222866, upload-time = "2026-08-02T18:48:16.884Z" }, + { url = "https://files.pythonhosted.org/packages/58/fb/beaa453d62000a0a5b39838bee2a137afe609a50a71f55e83c73461e513b/coverage-7.15.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:50951e37033c40548d777b8a8454a2cd622dba1136780065678dccaec307c47f", size = 254367, upload-time = "2026-08-02T18:48:18.507Z" }, + { url = "https://files.pythonhosted.org/packages/66/64/43e72500ed6815cef189f9193f29d7af4b078830337c95ea976cd0c0d427/coverage-7.15.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:63a4ff67364afb2cac826b8bbd78a5c50ce656a7b7137436b44d7b96a9271088", size = 257103, upload-time = "2026-08-02T18:48:20.172Z" }, + { url = "https://files.pythonhosted.org/packages/66/3a/2893e2937adfe02f45fd38e4a8a0a0d8b7a02ff9e012ac3d009bee3c4f16/coverage-7.15.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95e42856509675fe26560310313a6117640e96f9a1e19bb3d220116a27c94c", size = 258220, upload-time = "2026-08-02T18:48:21.963Z" }, + { url = "https://files.pythonhosted.org/packages/30/b4/d5e6e2eb1a62961083734291304b1f85df72e2abe95c76eb88a7f472afd0/coverage-7.15.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:abad631cba27094b4631993f4c72e89ac0ca1b3a0236c7abaf8ca79aea619851", size = 260481, upload-time = "2026-08-02T18:48:23.682Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/9b72c5c6a9798a9a12cf65f66e077cc1fdd396e61915c862688f9afe1cae/coverage-7.15.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2b0807f1f051dd82a234ad6acdb6f1425baede60be1e84e862496c8cc9262ab9", size = 254749, upload-time = "2026-08-02T18:48:25.32Z" }, + { url = "https://files.pythonhosted.org/packages/92/20/e1c2f759e2dbce559ba85c40c0e4acfecc6cff4b740c294c88e41ccc6111/coverage-7.15.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d8d6df7aeb5bc464040bbc9ae173d875785d3677ebc4307817997d622d74225e", size = 256138, upload-time = "2026-08-02T18:48:27.064Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ab/48cc7e760f769e86ae290a125ea6e7209dfbdbbbb7ff4f5d9d1ee7a45d57/coverage-7.15.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:974471c506c9f5758808b47c1ebf7949ecd0848f5c1020e78675fefe5ff46866", size = 254283, upload-time = "2026-08-02T18:48:29.082Z" }, + { url = "https://files.pythonhosted.org/packages/15/26/39529a68154f99b3a1829debd8b25eac384effeec890a293b5bbdcb49186/coverage-7.15.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:5cba0c9c13e35c86df7998f1afaf6b1da224a3a39e4da59bdabf60c148046dcb", size = 258352, upload-time = "2026-08-02T18:48:30.892Z" }, + { url = "https://files.pythonhosted.org/packages/91/2f/55b82aa3d8d7dd8023a56e7c5c2a70e39a3c44b3353c6cf3faec9ad51566/coverage-7.15.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4d608dc36a364dce33acbf4fc3a50f9d2054c945f233bb0a2cdb4b90bfa17646", size = 253852, upload-time = "2026-08-02T18:48:32.934Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6d/839f4045124cd3518ecf2c58967e58a911202834e7c5a03cfdf2ab0b29f6/coverage-7.15.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2395869280554a1941da904423c12660c39f721315e1c02d076a7fe0971382f0", size = 255725, upload-time = "2026-08-02T18:48:34.848Z" }, + { url = "https://files.pythonhosted.org/packages/75/21/d25e3e2a9e327798078c877f469dfb6def860bf6e25036529046227d3e15/coverage-7.15.3-cp312-cp312-win32.whl", hash = "sha256:24f3b21840c3eb76cef3cc70b2bf6649010c64471a84a446538a39306e1ba04d", size = 224566, upload-time = "2026-08-02T18:48:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/b1/0f/df90cc1e8d095ce263968a93e04829821b2afb31ac2752c06a2e0a8e3c13/coverage-7.15.3-cp312-cp312-win_amd64.whl", hash = "sha256:fa7b17902c3c1dd8a7adb52679b7f6340bba08443d710c8838e04db8cf62be2a", size = 225098, upload-time = "2026-08-02T18:48:38.941Z" }, + { url = "https://files.pythonhosted.org/packages/65/c7/ec49e43c58967a07163e2d1c6bbd58112b825b2772ab66784afd6a5400ba/coverage-7.15.3-cp312-cp312-win_arm64.whl", hash = "sha256:fcbe83fb7258eacd293bf5322d88807acb35ed12a5cfa99dd8215c083e3b0235", size = 224485, upload-time = "2026-08-02T18:48:40.682Z" }, + { url = "https://files.pythonhosted.org/packages/68/6e/62ae61e1fc434956bec38ed1d5b1c494f58cf579dbd998e77abffe7b3e6b/coverage-7.15.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1182eed05674c63d40951fae27c43e822749f04d25f75df64c2e4fa3168678de", size = 222522, upload-time = "2026-08-02T18:48:42.476Z" }, + { url = "https://files.pythonhosted.org/packages/13/ff/c74c673d81e0e77b6608c3d21331e3db42e30daeb3c8a0a8860d4c9e2e14/coverage-7.15.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c0c4b0d7c4cd56e470d0c9d8441f42e8a96cdfd95050fec027f1d4dd9f11006c", size = 222894, upload-time = "2026-08-02T18:48:44.274Z" }, + { url = "https://files.pythonhosted.org/packages/a1/91/ccb30f5ffafd7d69d0b18e5162f9b711a5654e807b7b0c13497f0826b33f/coverage-7.15.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5c9fce9f4998b0d50a753da765b9215a14decc7863822c89d72da7a89ca625b3", size = 253890, upload-time = "2026-08-02T18:48:46.097Z" }, + { url = "https://files.pythonhosted.org/packages/29/c6/e92a66cda49a2751b09826d51258f199b92aa0cb005bc5f34e9729a52a9c/coverage-7.15.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7a47e2a0a0ace9241e70ee00e44520f88b843094603dd54303f1bafecd929c30", size = 256484, upload-time = "2026-08-02T18:48:47.846Z" }, + { url = "https://files.pythonhosted.org/packages/96/7a/730929164b457cf25cf76c23898b90f9039a104a647890801b6586797b14/coverage-7.15.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:95bad94f83807ae60ed76f3ac012f69b2605ac9ea81bee959a5a483f7fa09c10", size = 257723, upload-time = "2026-08-02T18:48:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/9e/be/04cb5672cb19f5c389eda81ba22d89807699a949653d3625b0e0fda169da/coverage-7.15.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:228e172a76c428bb17d1ab78a2ff188990b0597e5dbd291f52a4edf7412de049", size = 259854, upload-time = "2026-08-02T18:48:51.413Z" }, + { url = "https://files.pythonhosted.org/packages/96/25/5e7fd6af39f6507071455944b8906dd1fe5b7b6bffb6a163ceb20afa0d13/coverage-7.15.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cea9fb33887c99349996266f1fd60abe5af3577a90633392001d27ef46b4b66e", size = 254085, upload-time = "2026-08-02T18:48:53.158Z" }, + { url = "https://files.pythonhosted.org/packages/23/c8/55e58a853f1e61163a6e755897bd14a059d78411e86560f39d9951c019b5/coverage-7.15.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:81760de3155d7f52c21860c4046628dc6bed182f72e3c028e2b4fd46f65aa040", size = 255850, upload-time = "2026-08-02T18:48:55.031Z" }, + { url = "https://files.pythonhosted.org/packages/be/74/8bcec66dbcf3d22bea2a0b2b77ee2fa6f766a647d0023d4eabbc4f2b2756/coverage-7.15.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b47ea0a1d3a3d089826c6cbfad8429d7d8872e28e86baa95ddef330f6875da21", size = 253818, upload-time = "2026-08-02T18:48:57.163Z" }, + { url = "https://files.pythonhosted.org/packages/ce/06/450b673fdfece0997b4e16a31d6bde6b18889c578f1013ddd34c962ac6f9/coverage-7.15.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:5459ba486b2a5d58a6c05254779ecdf525e7f20174d0210ceda75ba40fdb8f2c", size = 257973, upload-time = "2026-08-02T18:48:59.098Z" }, + { url = "https://files.pythonhosted.org/packages/56/fd/3ec7409aec0ddc943132452b65672f065f043b844f1830e1fe173c98b3ab/coverage-7.15.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c59209f80a08dbfcdd5109a80dc623cd3b9d22895c85757d34f57a6e6e95570f", size = 253638, upload-time = "2026-08-02T18:49:01.199Z" }, + { url = "https://files.pythonhosted.org/packages/75/20/30a8dabb194123631c93f860fdd86401ad405d56cfb1841873afbfe4e92b/coverage-7.15.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f863856c1779d4a5bb6a94698a2f9073e09c6706501f76f3e7780e72df97d21c", size = 255407, upload-time = "2026-08-02T18:49:03.143Z" }, + { url = "https://files.pythonhosted.org/packages/13/4d/e14365b1953b43653341412f9088b0d752614c626a73a705ff9af400f3a3/coverage-7.15.3-cp313-cp313-win32.whl", hash = "sha256:00cbdc5e322927dc30c5e42b863819b1bb867cc66f26ab5372c585850876ab93", size = 224575, upload-time = "2026-08-02T18:49:05.011Z" }, + { url = "https://files.pythonhosted.org/packages/1c/64/88f762ea80de2070207246faef514513be874486b2773528f2cc2b4b515c/coverage-7.15.3-cp313-cp313-win_amd64.whl", hash = "sha256:835528518a1d823cf336740324b2f335f7c01e609e74abcb5d5163b3e66661e3", size = 225116, upload-time = "2026-08-02T18:49:06.894Z" }, + { url = "https://files.pythonhosted.org/packages/ab/66/03c34c53a319f522554cd29d4f2e16c5eab61aa4cdcf55753129fd7d926c/coverage-7.15.3-cp313-cp313-win_arm64.whl", hash = "sha256:0d2e1f2cbbf36b842f3e2aff8d118c60d677adb498bc6c7fa9c6838738f82767", size = 224509, upload-time = "2026-08-02T18:49:09.129Z" }, + { url = "https://files.pythonhosted.org/packages/35/6f/8c2dc014357618b3226c90f731b8282766c3685786f422558991dc49fbf2/coverage-7.15.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1e3bb08ad574bd9fb6a991f645728f70d333c1c1958dd5fcde65e24cb862813d", size = 222571, upload-time = "2026-08-02T18:49:11.242Z" }, + { url = "https://files.pythonhosted.org/packages/07/50/d867c7ceae9d56b7e74ee61ea834f1aa4f9a1e1c7f0ce39393ba573b1c12/coverage-7.15.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9e5860eaff02a0b7f1b73304bdf846596ee62ab3a78d25c68044ebf684cb1fef", size = 222902, upload-time = "2026-08-02T18:49:13.448Z" }, + { url = "https://files.pythonhosted.org/packages/62/77/4f6dfc490c5f2bcacb2d296d9aa4d1e128c43b48e94ad313fec7f49f09ad/coverage-7.15.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:60874e5bd67f0b1bdbe42ab42c7bafa66a6fb8de88721af6df3f7a02713960cd", size = 253947, upload-time = "2026-08-02T18:49:15.304Z" }, + { url = "https://files.pythonhosted.org/packages/16/8a/6777f192af264165103e2a3d3768dbadb9894a0a2359a16877141d9ae8f5/coverage-7.15.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9147be876e9d83765e0b82176674dc248a6b9283e25e01e7462611b97e9b731", size = 256452, upload-time = "2026-08-02T18:49:17.801Z" }, + { url = "https://files.pythonhosted.org/packages/7d/7b/3d7ac46a0234bc684f41ee42be95e29b2b6525695adb04083609d5ac2149/coverage-7.15.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61a01f8c3804760fcc5a3d31c4f3cab792d660d44e17bf7adeaf0ea51e07821e", size = 257798, upload-time = "2026-08-02T18:49:19.878Z" }, + { url = "https://files.pythonhosted.org/packages/ff/1e/c6ee59c29afcb5fdb35f936381340d1a06429a07c48f20e809646647acbe/coverage-7.15.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:95bf3e7f26f792e25eb185f85a5a659d48479265176dcfe22b6f334fd0081b5c", size = 260112, upload-time = "2026-08-02T18:49:21.858Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e1/e8ea39a46e89e3a143312ee5f80336e992e3ae8fe44bf9c76b83fefeed42/coverage-7.15.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44c41eff9e413fed8740eca75d5438ebeb9d3e45e7cd37c67329213e7a72c764", size = 253944, upload-time = "2026-08-02T18:49:23.926Z" }, + { url = "https://files.pythonhosted.org/packages/95/67/31ab5f6a37fd887d1386f81f0da9306851ad2264e9baaa9c7f606e0b3e17/coverage-7.15.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:54146bafb61f3ba9895b43af0dd17eba01561d586d44ce84ea221b0cbbee5a9e", size = 255805, upload-time = "2026-08-02T18:49:25.973Z" }, + { url = "https://files.pythonhosted.org/packages/fb/6a/ee505a80c8fd89620fb337c0596daecff87f33171fbb4ee3015fc3d7331f/coverage-7.15.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:af000dd1bb859ff8066fda4c79512ff938c798116540307226b373099c7b151f", size = 253769, upload-time = "2026-08-02T18:49:27.883Z" }, + { url = "https://files.pythonhosted.org/packages/b0/41/6ab0f81c9e89660230d8f3f581d4732e5ddb75a885b0a5dfc73d315dc94f/coverage-7.15.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a1b82490577f3889950b5a04f18712aef0207243e0749d60fe28c3c73ebfd5fd", size = 258045, upload-time = "2026-08-02T18:49:30.201Z" }, + { url = "https://files.pythonhosted.org/packages/bc/62/c995e91cae28cf31d6defab3bfb553dda5ac83ac7381b0f2b121264c307a/coverage-7.15.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:c4fc90a60154c3e4b8a2dc206d6dbe852f1c235c249e0dc0cef909d032c9591a", size = 253587, upload-time = "2026-08-02T18:49:32.349Z" }, + { url = "https://files.pythonhosted.org/packages/84/df/f2049980f82d6890321f2065f9e66216eabbf4b2001815db958bc543f40a/coverage-7.15.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f25bb884814a892948b4c20394db3f2364dd452d9492736479e7a493e63b0eb6", size = 255243, upload-time = "2026-08-02T18:49:34.324Z" }, + { url = "https://files.pythonhosted.org/packages/1d/82/2c841b67a978c0eb9c3707630b68f93f9e7585d78bb906bc8823ec6b07a5/coverage-7.15.3-cp314-cp314-win32.whl", hash = "sha256:722dbf8e7828fbcfe0dc8586167dc0a5ce85ad6ea171dbb21ed3f8d6581d3cb8", size = 224759, upload-time = "2026-08-02T18:49:36.326Z" }, + { url = "https://files.pythonhosted.org/packages/b3/78/5c93ec43784fd3e404ca23cd0584ae24bc1732de4a3fc194b68c3be88db0/coverage-7.15.3-cp314-cp314-win_amd64.whl", hash = "sha256:64d0845f9c3ed47302bed265c15ab4dbb64aa4ec1490839b8e328f4e7fa914d2", size = 225246, upload-time = "2026-08-02T18:49:38.366Z" }, + { url = "https://files.pythonhosted.org/packages/9d/77/813a054371f3b018cc63c6bdb46a3c35d5e95d4e3ed4f1449d4196106db5/coverage-7.15.3-cp314-cp314-win_arm64.whl", hash = "sha256:69bc14684f8fbbee9f9dbaa4fe79719b0da9725fc37956785c06ec365acf6926", size = 224673, upload-time = "2026-08-02T18:49:40.552Z" }, + { url = "https://files.pythonhosted.org/packages/8f/63/8c9f36cc71178d26db930baa03a4494abcc516d8d41bf820d0d85ef1d80b/coverage-7.15.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f92df943c24b96cb215ca26b4f6a2283e63c5db80f1635aceea7fff11311917b", size = 223298, upload-time = "2026-08-02T18:49:42.634Z" }, + { url = "https://files.pythonhosted.org/packages/54/66/211f24d058ce9f56ebf1420d55b7574fdae924f6da3836f83c8bd4793e38/coverage-7.15.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:66591c46bdd2971d3ae2bc503a5f0459c2edcaf6b7e045b292000cc95bc6cb95", size = 223568, upload-time = "2026-08-02T18:49:44.706Z" }, + { url = "https://files.pythonhosted.org/packages/dd/bb/9c2ad5574a0d6420a96c6cade4f8a683931b9e79fe609f8924d7b6964616/coverage-7.15.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa64458b81b18bfc67cdf1f6dc02b23e3edc672f2f8e11771fad75865415a43", size = 264932, upload-time = "2026-08-02T18:49:47.153Z" }, + { url = "https://files.pythonhosted.org/packages/ba/91/938c39e77bdd5a0a440412f975609ce3702dabbda6ac715719d93ca45a7b/coverage-7.15.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:447f5421ccf5475956cf516d4ca1d575f487947b6f4e11f9d80c6aefe24b3dc8", size = 267052, upload-time = "2026-08-02T18:49:49.324Z" }, + { url = "https://files.pythonhosted.org/packages/b0/a3/7b431a98af35d9cc6394e54cde9435b33b8591672fbece6a4931267d7a8e/coverage-7.15.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a0c77ef8cd483a4987a5d12d1d9d5f7ee598dfdc6c0844417d847e5768dc779", size = 269473, upload-time = "2026-08-02T18:49:51.599Z" }, + { url = "https://files.pythonhosted.org/packages/32/58/dbc9951dce46be47a732823a1c571f62bcabdd54a68d8c281489a1a55cfb/coverage-7.15.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0b273f4ff657446a06c2d85bf80e134fa869a92852ba5f87854a70e1fb44da77", size = 270591, upload-time = "2026-08-02T18:49:53.865Z" }, + { url = "https://files.pythonhosted.org/packages/71/bd/1d610772c7c0889bfe477a59c46ee66ea53e271f3f06951e9d55b317f7c6/coverage-7.15.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daea8c4fafa22488600405be2c2be525a9406fba3fc0a83acc726db3e14e2005", size = 264007, upload-time = "2026-08-02T18:49:55.875Z" }, + { url = "https://files.pythonhosted.org/packages/69/97/852eb3dcdba156b1a9078503f098499916bf889f964b61ad4a08223ac169/coverage-7.15.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:93ff57c530f3fa7aa69f92fb9b8892b8aa82712aa970842f4abf28657f42fb57", size = 266926, upload-time = "2026-08-02T18:49:57.944Z" }, + { url = "https://files.pythonhosted.org/packages/52/f8/b72cd238757fba2b587fc7dee047efe6e10b0c18343509faaaf502dd4680/coverage-7.15.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4df21bef8b800eebda9018f53d49c9ace3aeb0090c850139b27923aafcb83e91", size = 264529, upload-time = "2026-08-02T18:50:00.035Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0a/6c52ec4b7fb007cb6433d1fcfda4080cb15d75ad37ef9c31025f3427293e/coverage-7.15.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:db567b02685f26034adcbd85055f80d12cdf02111b8ed00886093d98b2874ce2", size = 268263, upload-time = "2026-08-02T18:50:02.161Z" }, + { url = "https://files.pythonhosted.org/packages/c0/4e/f1f9aa3efd109a04353563a43fb5155340c1fdcdeaa6296ebed3b6f510ea/coverage-7.15.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5318dd51b8600b947e058cf5a4fe54d183d9d13c49b97b64ca7be05a34df9bef", size = 263377, upload-time = "2026-08-02T18:50:04.243Z" }, + { url = "https://files.pythonhosted.org/packages/dd/fb/6b268a0b2728ef1c379ad656b899274477a5f6bed1bf6765b4b387fb0601/coverage-7.15.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c995bfa383c54704839b6c4c2627a1c00895597ada0e5e8190c81d8bd620555c", size = 265688, upload-time = "2026-08-02T18:50:06.428Z" }, + { url = "https://files.pythonhosted.org/packages/29/54/1a3ea96e5d5e7cd41dc432597bfc60692910e635d05e1cc25a8ccc243581/coverage-7.15.3-cp314-cp314t-win32.whl", hash = "sha256:6433fafb8da0e1d02eb53411e0ecdadb6b88f0224fdc23317e703c0e88937d42", size = 225066, upload-time = "2026-08-02T18:50:08.533Z" }, + { url = "https://files.pythonhosted.org/packages/31/9d/a7b0d9afd18ed5274dd00651a78e7810a931c70d94b79996f150bec1a30f/coverage-7.15.3-cp314-cp314t-win_amd64.whl", hash = "sha256:fe578952b1b29fe8c777f43f241d49efac4b56724a3434f5d22ebe3c208df429", size = 225897, upload-time = "2026-08-02T18:50:10.572Z" }, + { url = "https://files.pythonhosted.org/packages/ca/11/34c5ae40b945e69aa72b87dc268135b7049905f3824af573b7073acbb946/coverage-7.15.3-cp314-cp314t-win_arm64.whl", hash = "sha256:d2e1acb7aee29dfa8f3e48c23f36670898baca1209d9bdd3985a50c7f982165e", size = 225212, upload-time = "2026-08-02T18:50:12.63Z" }, + { url = "https://files.pythonhosted.org/packages/37/e7/7069b3d6c018917f49ba2e1c5fb910e498c7fefa3a1b78cb1b79e61ff45d/coverage-7.15.3-py3-none-any.whl", hash = "sha256:da78fa6fc7dafe4212839173133ee85afcf42c5cd5f3e47fa7c1c210453b445e", size = 214297, upload-time = "2026-08-02T18:50:14.709Z" }, +] + +[package.optional-dependencies] +toml = [ + { name = "tomli", marker = "python_full_version <= '3.11'" }, +] + +[[package]] +name = "cryptography" +version = "50.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" }, + { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" }, + { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" }, + { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" }, +] + +[[package]] +name = "cycler" +version = "0.12.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a9/95/a3dbbb5028f35eafb79008e7522a75244477d2838f38cbb722248dabc2a8/cycler-0.12.1.tar.gz", hash = "sha256:88bb128f02ba341da8ef447245a9e138fae777f6a23943da4540077d3601eb1c", size = 7615, upload-time = "2023-10-07T05:32:18.335Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, +] + +[[package]] +name = "cyclopts" +version = "4.22.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "docstring-parser" }, + { name = "rich" }, + { name = "rich-rst" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/be/05/689617b7e86503417c172f577d791524cb13b9697303d5d44409a971ba10/cyclopts-4.22.5.tar.gz", hash = "sha256:94044506317462cad90fb01a917dadce1f48a0915ba3605dc8d178dea1229e24", size = 195144, upload-time = "2026-08-04T13:53:00.303Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/83/58/bcab9c33fb7a25a1f5970f357c5b19729bc81d50615d2f737b20c4255909/cyclopts-4.22.5-py3-none-any.whl", hash = "sha256:cf9ce285836053d156730ea4ea0ad0c75cf63beb3f3d8edf222a795bc57666ab", size = 234557, upload-time = "2026-08-04T13:52:58.509Z" }, +] + +[[package]] +name = "dnspython" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8c/8b/57666417c0f90f08bcafa776861060426765fdb422eb10212086fb811d26/dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f", size = 368251, upload-time = "2025-09-07T18:58:00.022Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/5a/18ad964b0086c6e62e2e7500f7edc89e3faa45033c71c1893d34eed2b2de/dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af", size = 331094, upload-time = "2025-09-07T18:57:58.071Z" }, +] + +[[package]] +name = "docstring-parser" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/4d/f332313098c1de1b2d2ff91cf2674415cc7cddab2ca1b01ae29774bd5fdf/docstring_parser-0.18.0.tar.gz", hash = "sha256:292510982205c12b1248696f44959db3cdd1740237a968ea1e2e7a900eeb2015", size = 29341, upload-time = "2026-04-14T04:09:19.867Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/5f/ed01f9a3cdffbd5a008556fc7b2a08ddb1cc6ace7effa7340604b1d16699/docstring_parser-0.18.0-py3-none-any.whl", hash = "sha256:b3fcbed555c47d8479be0796ef7e19c2670d428d72e96da63f3a40122860374b", size = 22484, upload-time = "2026-04-14T04:09:18.638Z" }, +] + +[[package]] +name = "email-validator" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/15/545e2b6cf2e3be84bc1ed85613edd75b8aea69807a71c26f4ca6a9258e82/email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4", size = 35604, upload-time = "2025-08-26T13:09:05.858Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "ezdxf" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fonttools" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "pyparsing" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5e/d7/1b7be8db364f1c4838dfc1a40ca96577aba405deabf896a4eb3aaeb15a62/ezdxf-1.4.4.tar.gz", hash = "sha256:da5a5e0e6bdbb6656f9c017b47edc7eafceb419d61a2b5de64ffb344c168e593", size = 1866886, upload-time = "2026-05-14T09:19:19.511Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8f/d0/bc515b7f5596bc3d55b93fc9f2c2b4926e2587af6bfae0486ef7cc7ddf43/ezdxf-1.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:39aadab5bc7a05deddbd0db9b984c8a4bd28bd4c1ac2c5ee03f0d1b096272a88", size = 3553590, upload-time = "2026-05-14T09:25:28.042Z" }, + { url = "https://files.pythonhosted.org/packages/a7/73/207132f13a5280d186f2441d9125639fdc7ddeabf56c2d5da4d691e81ce6/ezdxf-1.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2ed65b191113a82133ba47daa6ebdd540b487b17394b1e327e9511dde87ad924", size = 3008276, upload-time = "2026-05-14T09:25:29.424Z" }, + { url = "https://files.pythonhosted.org/packages/82/73/b01af87269726e3719abc5ff694b843b710ef8fc345d21013e90d70e44d8/ezdxf-1.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7c3c8f2c2b62fe5b6c9b62ab8257b697535d62b0c79acf3a5040bd95edfab451", size = 3001750, upload-time = "2026-05-14T09:25:31.146Z" }, + { url = "https://files.pythonhosted.org/packages/dd/92/284d7ca6a9da107966c7bd9d81c92245985235bc432fe0837982ef80630a/ezdxf-1.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d68d5ab9e1071319030da3af27590f34c38c8544a5ea638d9a4a800a96abb3c2", size = 5802382, upload-time = "2026-05-14T09:27:28.462Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/e4f37730ca5b5bf7de3a968a26126b404f60324d0b8ae9d59d11d6e55740/ezdxf-1.4.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f75a4f2924ebdda0f5b2779ff2135ba92de2596c95a8fa9b1d9ebcabea1be41", size = 5794671, upload-time = "2026-05-14T09:27:09.867Z" }, + { url = "https://files.pythonhosted.org/packages/fe/3f/c3e7ab74f58cb69ef985fb71eda859b5c287cc506c65cbffe7e55c7cf2d4/ezdxf-1.4.4-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0eec8a2095b645420a67560e99041c0d25afefd39eab5bff8c736fdb32211a75", size = 5767366, upload-time = "2026-05-14T09:27:30.108Z" }, + { url = "https://files.pythonhosted.org/packages/66/7a/b7070bf05e75b3e709a6fe5ae5bcd3bd899e987ef84ff25cf28327519c03/ezdxf-1.4.4-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d87a85052e92b9c28e54605dbad65f779a3b61e6288e992d6ef00df7bde4bed3", size = 5830221, upload-time = "2026-05-14T09:27:12.074Z" }, + { url = "https://files.pythonhosted.org/packages/78/cf/a990289808c261c52f5b219484d136a8748773c99a6d25c84f55a0353fea/ezdxf-1.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:473d68c8f2b04ace513efa290a1cd0d3a4a589cdff48124f015f0e51034668b2", size = 2957599, upload-time = "2026-05-14T09:23:46.571Z" }, + { url = "https://files.pythonhosted.org/packages/18/3d/98648d221ffa047b5ba898c8e152010861dc71a66b531dcf15053075010e/ezdxf-1.4.4-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d723d35d08223318c17080a32a05192a1449e544dee867c9d9c6f25998fac303", size = 3563916, upload-time = "2026-05-14T09:25:32.879Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f1/84bbaa571412f840c22deeae185e54d4a67aa52f03e1728fd8425748c548/ezdxf-1.4.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6b0c73b46085521bdbd4a3e7d88998da7bec5713ba89bbb502fdeffe6b42fc7e", size = 3016857, upload-time = "2026-05-14T09:25:34.607Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ff/f2eb77d22d26aa98abbe0961470871d8b85ef4e73e5527f544d25f958d6e/ezdxf-1.4.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ac3e83a70c1f866dd5114192a6fab6e75cc83b75e907bc08c5d94b98cce14cc0", size = 3003336, upload-time = "2026-05-14T09:25:35.947Z" }, + { url = "https://files.pythonhosted.org/packages/65/7d/2a6ddd6beef5d9a8593078619f3920a0cd76b1db4c6a974ac37045543219/ezdxf-1.4.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8ebe09717ab6636bf48780eabdab51e3ab2135f7963bb52452444b6d95e03d12", size = 5788485, upload-time = "2026-05-14T09:27:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/e7/29/2094fd7719146ca7bb236be9751b5929d431f643cbab669aed89cf121883/ezdxf-1.4.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:44af248af0860fe8b1a39ca40e1cd2fa8ac37b22142b14b20c6a0caf1b50f932", size = 5816781, upload-time = "2026-05-14T09:27:13.96Z" }, + { url = "https://files.pythonhosted.org/packages/19/8d/669b8047cca162204c53b874183d73b5f6c4b1d5d48fa1e515a32d56f5ac/ezdxf-1.4.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02a097794625e9fc948e637dfe07057e98167b4fe215357a59645759fef6db3f", size = 5754006, upload-time = "2026-05-14T09:27:33.858Z" }, + { url = "https://files.pythonhosted.org/packages/5b/44/b753997173327250e43ae50f11f0c69016b1e78482e0bd3857c3495236e7/ezdxf-1.4.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f093bd2228f5782d483aed3bd89cdb74b672b5f90c677c4125cd236ee5649151", size = 5861860, upload-time = "2026-05-14T09:27:15.758Z" }, + { url = "https://files.pythonhosted.org/packages/0b/99/2e81cb168e0f09adfb05fb40e5f890ff87ac2c00e98079cf09837dd76330/ezdxf-1.4.4-cp312-cp312-win_amd64.whl", hash = "sha256:3e518d9ed4dcbe525d57f14c25c5e592f82660a690fdec39b03b092e6cdc4771", size = 2963238, upload-time = "2026-05-14T09:23:47.924Z" }, + { url = "https://files.pythonhosted.org/packages/be/ed/97aa3ba1ba923e4098169de5bca380ee1430e2b34d0dd85b73007e34df16/ezdxf-1.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:278a09a845d67a4f893aebe9ef8dcd8894ece9f255d7d8bb21719ffd902109b8", size = 3555283, upload-time = "2026-05-14T09:25:37.346Z" }, + { url = "https://files.pythonhosted.org/packages/4e/b1/8ca9408a2b382a806837e72d35f10931fdd9f53fe66cf208a6340358fbc5/ezdxf-1.4.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b0a787f8ebcbdcb9798c6fcfe90c23afd1a0993c8d4302b6cce5d8f19ae052ae", size = 3012646, upload-time = "2026-05-14T09:25:39.01Z" }, + { url = "https://files.pythonhosted.org/packages/8b/aa/1e8d3130eaabb780c5e8adfdc1ead023bb04468779b18169d60f7ce5b291/ezdxf-1.4.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c451d1c20f08b30c735ca4d650fee5b44147402f4b5662f8550053ed8a3009c2", size = 2999048, upload-time = "2026-05-14T09:25:40.348Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ca/1dd390b79df9c104c2d4b5964e80db25362edf704ac742c9db9b279658a5/ezdxf-1.4.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d3d9a6663993fc644751647fd7843057b51742ffac27c48ce28ea9f81239613", size = 5747890, upload-time = "2026-05-14T09:27:36.028Z" }, + { url = "https://files.pythonhosted.org/packages/8f/1e/226a6e636ae533e0bc0afe4435d1058fac173564e4af4102862f055a46d9/ezdxf-1.4.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82c022cf094d21ad3db68557aa83628e4152355e8a8bef0fffd4f71a9ea325df", size = 5774707, upload-time = "2026-05-14T09:27:17.625Z" }, + { url = "https://files.pythonhosted.org/packages/24/a3/7e33c9036de944b5446982bb629d698356e48b00c4d173768f52bdeeff4b/ezdxf-1.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3be59c2a3a93585412a2a37af29b3ad7574fa3e429e624924c3285d0573388b3", size = 5713753, upload-time = "2026-05-14T09:27:37.55Z" }, + { url = "https://files.pythonhosted.org/packages/36/9e/ac0cdc3a8623fc38aa16670f6537aa810bc1381217a51ac3481299bbc986/ezdxf-1.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:44a9716860b59dddd49a3708c04ff4580ad1e7de92ac93bb3213d85fba5ac93f", size = 5813895, upload-time = "2026-05-14T09:27:19.514Z" }, + { url = "https://files.pythonhosted.org/packages/dd/22/b511a8d9ea8f23447a67fe8977213fac5e4e69df30130a3ef31043613eaf/ezdxf-1.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:207eed544417464ffaf2570880d58a792ecd2534c9c2dede45e895f3200b77e6", size = 2960937, upload-time = "2026-05-14T09:23:49.361Z" }, + { url = "https://files.pythonhosted.org/packages/a0/09/5ecb6f82d35a2f4a4334d0a608fcf764827fa69dccdf5987ce309c16b6c1/ezdxf-1.4.4-py3-none-any.whl", hash = "sha256:666edda631ba717270293b734f5d58dd97a1d1aba4787187f09d0cc584645865", size = 1331217, upload-time = "2026-05-14T09:19:26.601Z" }, +] + +[[package]] +name = "fastmcp" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "fastmcp-slim", extra = ["client", "server"] }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/14/c1ffb91b7d1fece86c81e1f9df5474f30fd97e4cdaa398814bbbeee88568/fastmcp-3.4.5.tar.gz", hash = "sha256:a95f2bc876bef42e8b50f7872f24f3f2fe3b1d37408c734e8b9d9e03014b72d3", size = 28800521, upload-time = "2026-07-27T19:20:01.231Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/4f/73450a436c963c0382d15a882fc5d08f15aadc329194df1b54495a7c8383/fastmcp-3.4.5-py3-none-any.whl", hash = "sha256:5d3d438eb2917e63e6faf53e8cb8fe26d887ec3232f848093a4eecad7fa34861", size = 8017, upload-time = "2026-07-27T19:19:57.942Z" }, +] + +[[package]] +name = "fastmcp-slim" +version = "3.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "platformdirs" }, + { name = "pydantic", extra = ["email"] }, + { name = "pydantic-settings" }, + { name = "python-dotenv" }, + { name = "rich" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/81/1d/f3e271fbcd01ce01a4cf623b336d8e1305c192aa5d5e8e0223b7167462e9/fastmcp_slim-3.4.5.tar.gz", hash = "sha256:5badc3bceee61f61297eeb9494f499325f3ce1cafabf4611b31f6c3e9d7dff59", size = 591622, upload-time = "2026-07-27T19:15:19.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/43/3b/16d8aa8224094519f30b078138e725b8a731bf0a13f1f850e58b5f9b3cc4/fastmcp_slim-3.4.5-py3-none-any.whl", hash = "sha256:bc31217827c4999812543c83ee95ed9a47f3ed1e3fd0bd4f64371e375b748eca", size = 766478, upload-time = "2026-07-27T19:15:18.015Z" }, +] + +[package.optional-dependencies] +client = [ + { name = "authlib" }, + { name = "exceptiongroup" }, + { name = "httpx" }, + { name = "mcp" }, + { name = "opentelemetry-api" }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, + { name = "starlette" }, +] +server = [ + { name = "authlib" }, + { name = "cyclopts" }, + { name = "exceptiongroup" }, + { name = "griffelib" }, + { name = "httpx" }, + { name = "joserfc" }, + { name = "jsonref" }, + { name = "jsonschema-path" }, + { name = "mcp" }, + { name = "openapi-pydantic" }, + { name = "opentelemetry-api" }, + { name = "packaging" }, + { name = "py-key-value-aio", extra = ["filetree", "keyring", "memory"] }, + { name = "pyperclip" }, + { name = "python-multipart" }, + { name = "pyyaml" }, + { name = "starlette" }, + { name = "uncalled-for" }, + { name = "uvicorn" }, + { name = "watchfiles" }, + { name = "websockets" }, +] + +[[package]] +name = "fonttools" +version = "4.63.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/84/69/c97f2c18e0db87d2c7b15da1974dace76ae938f1cfa22e2727a648b7ed43/fonttools-4.63.0.tar.gz", hash = "sha256:caeb583deeb5168e694b65cda8b4ee62abedfa66cf88488734466f2366b9c4e0", size = 3597189, upload-time = "2026-05-14T12:04:30.958Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/75/2b/a7f1545bdf5da69c4bda0cea2a5781f0ad2a6623e0277267672db43c5fe6/fonttools-4.63.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2b8ae05d9eacf6081414d759c0a352769ac28ce31280d6bb8e77b03f9e3c449f", size = 2881793, upload-time = "2026-05-14T12:02:56.645Z" }, + { url = "https://files.pythonhosted.org/packages/49/50/965308c703f085f225db2886813b27e015b8b3438c350b22dd65b52c2a2c/fonttools-4.63.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:79cdc9f567aec74a72918fd060283911406750cbc9fd28c1316023deb6ce31a9", size = 2428130, upload-time = "2026-05-14T12:02:58.891Z" }, + { url = "https://files.pythonhosted.org/packages/d8/38/6937fbd7f2dc3a6b48725851bc2c15ec949b9af14d9bbcb5fe83cdf9bdf9/fonttools-4.63.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c14b4fd138c4bafcca294765c547914e1aa431ae1ca94ab99d8db08c958bd3b", size = 5111952, upload-time = "2026-05-14T12:03:01.263Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/a81f20050a3115b57d62c8e781446949512eac36690dc384ccea65ff4cc1/fonttools-4.63.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d76ac49f929aecaf82d83250b8347e099d7aecba0f4726c1d9b6df3b8bb5fe18", size = 5082308, upload-time = "2026-05-14T12:03:03.211Z" }, + { url = "https://files.pythonhosted.org/packages/67/00/cdd9d4944ca6ae280d01e69cc37bde3bf663630b837a6fc6d2cd65d80e0e/fonttools-4.63.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:dcf076a4474fe0d7367e5bbf5b052c7284fa1feca729c04176ce513521afd8a0", size = 5087932, upload-time = "2026-05-14T12:03:05.147Z" }, + { url = "https://files.pythonhosted.org/packages/f5/f1/0aa0dbea778c75adbef223c42019fd47d22262b905974d62d829545d485f/fonttools-4.63.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7dd683fef0663e9f0f45cf541d788d24caa3ec9db50796b588e1757d8b3bc007", size = 5213271, upload-time = "2026-05-14T12:03:07.238Z" }, + { url = "https://files.pythonhosted.org/packages/a8/99/253e4056e1f0e67b9390125a154b73b5eb73ad521bece95c004858fdeec2/fonttools-4.63.0-cp311-cp311-win32.whl", hash = "sha256:afefc1ed0a59785a7fb06ea7e1678e849c193e1e387db783579bc7b3056fcfcb", size = 2304473, upload-time = "2026-05-14T12:03:09.271Z" }, + { url = "https://files.pythonhosted.org/packages/08/60/defa5e69641db890a63be281f41345f4c33b157824eaf0b9fad3e08b0dcb/fonttools-4.63.0-cp311-cp311-win_amd64.whl", hash = "sha256:063e08bd17bd5a90127a14123de0d6a952dbc847695fd98b63c043d58057f90c", size = 2356389, upload-time = "2026-05-14T12:03:11.53Z" }, + { url = "https://files.pythonhosted.org/packages/08/ef/b3c6b9b5be2f82416d73fe2ed2e96e2793cd80e7510bd6a17ca79cdd88ec/fonttools-4.63.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:37dd23e621e3b0aef1baa70a303b80aaf38449632cfc8fd2a55fb285bbccfc02", size = 2881131, upload-time = "2026-05-14T12:03:13.386Z" }, + { url = "https://files.pythonhosted.org/packages/44/a0/c815bea63117fa63e4e1c01f8a1110d2112fa003f838e6467094ec2432ce/fonttools-4.63.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:a9faff9e0c1f76f9fd55899d2ce785832efebab37eb8ae13995853aef178bef0", size = 2426704, upload-time = "2026-05-14T12:03:15.801Z" }, + { url = "https://files.pythonhosted.org/packages/44/04/0b91d8e916e92ad1fac9e4624760baf0fd5ff2ead614c2f68fb21373f03f/fonttools-4.63.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ef3048ef05dbb552b89817713d9cac912e00d0fde4a3105c00d29e52e10c89af", size = 5044298, upload-time = "2026-05-14T12:03:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/77/c7/2342da9830e3e9d4870305ca5d2091d2a83284f2953079b7bdd3b5e029d8/fonttools-4.63.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:58dc6bb86a78d782f00f9190ca02c119cf5bbe2807536e361e18d42019f877d8", size = 4999800, upload-time = "2026-05-14T12:03:20.161Z" }, + { url = "https://files.pythonhosted.org/packages/e6/6d/67fe16c48d7ce050979b33f47e0d28a318f02da030602e944c34f7a16ef3/fonttools-4.63.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ee08ebfa58f6e1aeff5697ab9582105bb620008c1caafb681e4c557e7483027b", size = 4982666, upload-time = "2026-05-14T12:03:22.87Z" }, + { url = "https://files.pythonhosted.org/packages/f2/00/3bbab338c07c71fa56269953845e92c951a61457bbbb0f1022551ea266d9/fonttools-4.63.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:27fdc65af8da6f88b9c6121c47a464cbe359fcfff7ff6fc2d37a1f395d755b78", size = 5133598, upload-time = "2026-05-14T12:03:25.168Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/aa27c7f98db5b064883dadcc5283947e81e034de42e22a33675878d98b54/fonttools-4.63.0-cp312-cp312-win32.whl", hash = "sha256:af2fd1664d00a397d75f806985ddb36282091c2131a73a6485c23b4a34722263", size = 2292575, upload-time = "2026-05-14T12:03:27.496Z" }, + { url = "https://files.pythonhosted.org/packages/87/36/cccb9bc2a6ab63d1b2980374f0dca72ce95ae267c9b4cfe77455bb70d0d4/fonttools-4.63.0-cp312-cp312-win_amd64.whl", hash = "sha256:59ac449f8cca9b4ffa08d2e7bbadad87ce710d69d1eda5c3c1ce579baa987272", size = 2343211, upload-time = "2026-05-14T12:03:30.057Z" }, + { url = "https://files.pythonhosted.org/packages/0f/8d/d8fec3dcde2963f8c908fb315e5ff2cd0ac34f82394bbbf73a2aa5145ce3/fonttools-4.63.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:cd7e9857e5e63738b9d9fd707bc1f59c8b09e5177726d23664db393c59bb08bd", size = 2876062, upload-time = "2026-05-14T12:03:32.554Z" }, + { url = "https://files.pythonhosted.org/packages/ef/71/d935dc54e4ff121bfdd11e08702db63a7e6f25af21d8a3d7b7212df53641/fonttools-4.63.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c2a2a42198b696a6f48fad91709afb55176e66a5e566131219dba372fb7f8c59", size = 2424594, upload-time = "2026-05-14T12:03:34.86Z" }, + { url = "https://files.pythonhosted.org/packages/8e/40/e76320afa1df918e146155ef239b1719ee266092e96f5423bfd075affba1/fonttools-4.63.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e874792a8212b44583ea02189d9e693906b2f78b261f372f95d6c563210ac1d", size = 5024840, upload-time = "2026-05-14T12:03:36.745Z" }, + { url = "https://files.pythonhosted.org/packages/ce/36/0b805d8c485f872f65a509cbe3b58a5d0d17bee855333b54a150c79d3061/fonttools-4.63.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:22135da48a348785c5e2d5d2d9d6bec5ed44adacbaeb9db12d9493bf6c6bfa68", size = 4975801, upload-time = "2026-05-14T12:03:38.833Z" }, + { url = "https://files.pythonhosted.org/packages/c8/26/2cee03d0aa083ab022da5c07aff9ed3f689da1defb81ad6917c9627896da/fonttools-4.63.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ccf41f2efdf56994d22d73bef4ced1052161958169428d06ba9724ea9e9a64be", size = 4965009, upload-time = "2026-05-14T12:03:41.494Z" }, + { url = "https://files.pythonhosted.org/packages/7e/48/cc4b66d9058c0d0982c833fad10127c4b0e9324606aafa41382295ca4102/fonttools-4.63.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9ced0bd02ac751dd6319b0da88aaef24414e3b0dbc32bb4f24944821a3741a27", size = 5105892, upload-time = "2026-05-14T12:03:43.525Z" }, + { url = "https://files.pythonhosted.org/packages/d8/1f/a98a30a814b9ddef3a2e706025f90b9e0bc94890e6cb15254bc86547d11a/fonttools-4.63.0-cp313-cp313-win32.whl", hash = "sha256:85be818f5506e8a7753153def2c9550178f0ecae6a47b5e0e8dbb23f7cc90380", size = 2291313, upload-time = "2026-05-14T12:03:45.594Z" }, + { url = "https://files.pythonhosted.org/packages/92/46/5177b01f3b4abfdd4409f31cca4ab279c9343a26efbe9ec78c97fc612e02/fonttools-4.63.0-cp313-cp313-win_amd64.whl", hash = "sha256:ba04cb5891d4c0c21b6da95eda8d7b090021508a294fff33464fc7d241e0856b", size = 2342299, upload-time = "2026-05-14T12:03:47.414Z" }, + { url = "https://files.pythonhosted.org/packages/27/d2/23d25e3f247b328be58d04a4c9f894178a0d1eda7d42867cfb388adaf416/fonttools-4.63.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fd1e3094f42d806d3d7c79162fc59e5910fcbe3a7360c385b8da969bc4493745", size = 2875338, upload-time = "2026-05-14T12:03:50.052Z" }, + { url = "https://files.pythonhosted.org/packages/cd/58/7dfa0c761cb3b2964e2a84c4dc986c926a87de0cb9fb60d5b28ded3f2914/fonttools-4.63.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:6e528da43bc3791085f8cb6141b1d13e459226790240340fcbb4625649238b03", size = 2422661, upload-time = "2026-05-14T12:03:52.154Z" }, + { url = "https://files.pythonhosted.org/packages/dd/87/64cfa18a7a1621d17b7f4502b2b0ed8a135a90c3db51ea590ee99043e76b/fonttools-4.63.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b2248c5decb223562f7902ff6325077a073f608ee8e33e88ad88db734eb9f49", size = 5010526, upload-time = "2026-05-14T12:03:54.647Z" }, + { url = "https://files.pythonhosted.org/packages/36/e1/a8933a72c45a87177fbde2696e0d0755c8c9062f8c077a961c6215fa27b1/fonttools-4.63.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:308f957cdeaf8abe4e5f2f124902ef405448af92c90f80e302a3b771c2e6116b", size = 4923946, upload-time = "2026-05-14T12:03:56.984Z" }, + { url = "https://files.pythonhosted.org/packages/27/60/872e6e233b8c5e8b41413796ff18b7fe479661bd40147e071b450dfad7a1/fonttools-4.63.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bf00f21eb5fb721dbaf73d1e9da6d02a1af7768f2ebcf9798be98beab8ba90f6", size = 4962489, upload-time = "2026-05-14T12:03:59.443Z" }, + { url = "https://files.pythonhosted.org/packages/30/c4/83c24f2ec38b90cfda84bf4b1a1f49df80e84a1db4e7ac6e0d41bf23bc39/fonttools-4.63.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c1aaa4b9c75798400ac043ce04d74e7830376c85095a5a6ed7cba2f17a266bf4", size = 5071870, upload-time = "2026-05-14T12:04:02.122Z" }, + { url = "https://files.pythonhosted.org/packages/de/40/3ae22b60ff1d41ce0bd044b31238cdc72cef99f28b976f1e128ebd618c9b/fonttools-4.63.0-cp314-cp314-win32.whl", hash = "sha256:22693918177bd9ceabec4736d338045f357769416fc6b0b2508eefef75b08616", size = 2295026, upload-time = "2026-05-14T12:04:04.47Z" }, + { url = "https://files.pythonhosted.org/packages/c3/d4/98078064ccc76b45cb0f6c002452011e93c4bd26f6850344f0951cc1fe89/fonttools-4.63.0-cp314-cp314-win_amd64.whl", hash = "sha256:7d782fac32985914c351556f68ac0855391572bcd87de50e05970d3cd4c96fc5", size = 2347454, upload-time = "2026-05-14T12:04:06.752Z" }, + { url = "https://files.pythonhosted.org/packages/49/4e/652d1580c5f4e39f7d103b0c793e4773129ad633dce4addd0cf4dfebde02/fonttools-4.63.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:6db5140a60a5d731d21ec076745b40a310607731b0a565b50776393188649001", size = 2958152, upload-time = "2026-05-14T12:04:08.706Z" }, + { url = "https://files.pythonhosted.org/packages/0e/55/ad864c9a9b219f552eb46b32cd7906c466e5a578ba0c3abfcc0fe7413eb6/fonttools-4.63.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d76edbff9014094dbf03bd2d074709dfa6ec7aba13d838c937a2b33d2d6a86e", size = 2460809, upload-time = "2026-05-14T12:04:10.783Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/0aa8db70f18cf52e49b4ed5ecec68547f981160bf5ded3b5aed6faa0a6f9/fonttools-4.63.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0eac00b9118c3c2f87d272e45341871c5b3066baa3c86897fa634a7c3fb59096", size = 5148649, upload-time = "2026-05-14T12:04:12.747Z" }, + { url = "https://files.pythonhosted.org/packages/7f/63/18e4369c25043096f1048e0c9915951adc4f842bd81c6b18155824d6fa99/fonttools-4.63.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51394295f1a51de8b5f30bdb1e1b9a4231536c7064ef5c6e211eec19fa36036f", size = 4932147, upload-time = "2026-05-14T12:04:14.806Z" }, + { url = "https://files.pythonhosted.org/packages/a1/3f/67f3eac2ffd8a98446c5022f8ed3864eac878a5ff7af8df4c8286dba16cc/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9e12f105d2b6342c559c298afb674006bb2893afc7102dcf8a1b55b0486b4e40", size = 5027237, upload-time = "2026-05-14T12:04:17.675Z" }, + { url = "https://files.pythonhosted.org/packages/1a/ba/4e6214cb38a7b04779e97bb7636de9a5c7f20af7018d03dee0b64c08510a/fonttools-4.63.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:796f27556dbe094c4824f75ca85267e4df776c79036c8441469a4df37038c196", size = 5053933, upload-time = "2026-05-14T12:04:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/34/3b/214dcc19ee31d3d38fb5ad2755c11ef0514e5dc300bbaf41c0b69f393799/fonttools-4.63.0-cp314-cp314t-win32.whl", hash = "sha256:948428a275741f0b64b113c955425a953314f4b9ab9997f73a72c83e68e569c8", size = 2359326, upload-time = "2026-05-14T12:04:24.22Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/3ff1a9b523058c2eeb6a9d50f5574e2a738200d0d94107d5bc4105e8da3f/fonttools-4.63.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6d4741eb179121cab9eea4cb2393d24492373a260d7945006358c08cfbf45419", size = 2425829, upload-time = "2026-05-14T12:04:26.829Z" }, + { url = "https://files.pythonhosted.org/packages/2c/47/c99d5268f354002ce80f8d029cd9d7d872969da1de8b93d32de4dc56d6f4/fonttools-4.63.0-py3-none-any.whl", hash = "sha256:445af2eab030a16b9171ea8bdda7ebf7d96bda2df88ee182a464252f6e05e20d", size = 1164562, upload-time = "2026-05-14T12:04:29.092Z" }, +] + +[[package]] +name = "griffelib" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/e4/8d187ea29c2e30b3a09505c567513077d6117861bde1fbd997a167f262ec/griffelib-2.1.0.tar.gz", hash = "sha256:762a186d2c6fd6794d4ea20d428d597ffb857cb56b66421651cbba15bdd5e813", size = 216234, upload-time = "2026-06-19T12:05:42.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e4/d3/5268aeabf2ad82658c4e2ff3a060648d0f02f3926cb53247c0e4d0dab49e/griffelib-2.1.0-py3-none-any.whl", hash = "sha256:cc7b3d2d2865ad0b909fcc38086e3f554b5ea7acbaa7bbb7ecaa3f5dfb7d9f00", size = 142560, upload-time = "2026-06-19T12:05:38.742Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "httpx-sse" +version = "0.4.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/0f/4c/751061ffa58615a32c31b2d82e8482be8dd4a89154f003147acee90f2be9/httpx_sse-0.4.3.tar.gz", hash = "sha256:9b1ed0127459a66014aec3c56bebd93da3c1bc8bb6618c8082039a44889a755d", size = 15943, upload-time = "2025-10-10T21:48:22.271Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/fd/6668e5aec43ab844de6fc74927e155a3b37bf40d7c3790e49fc0406b6578/httpx_sse-0.4.3-py3-none-any.whl", hash = "sha256:0ac1c9fe3c0afad2e0ebb25a934a59f4c7823b60792691f779fad2c5568830fc", size = 8960, upload-time = "2025-10-10T21:48:21.158Z" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "importlib-metadata" +version = "9.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/3d/2d244233ac4f76e38533cfcb2991c9eb4c7bf688ae0a036d30725b8faafe/importlib_metadata-9.0.0-py3-none-any.whl", hash = "sha256:2d21d1cc5a017bd0559e36150c21c830ab1dc304dedd1b7ea85d20f45ef3edd7", size = 27789, upload-time = "2026-03-20T06:42:55.665Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "jaraco-classes" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/c0/ed4a27bc5571b99e3cff68f8a9fa5b56ff7df1c2251cc715a652ddd26402/jaraco.classes-3.4.0.tar.gz", hash = "sha256:47a024b51d0239c0dd8c8540c6c7f484be3b8fcf0b2d85c13825780d3b3f3acd", size = 11780, upload-time = "2024-03-31T07:27:36.643Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/66/b15ce62552d84bbfcec9a4873ab79d993a1dd4edb922cbfccae192bd5b5f/jaraco.classes-3.4.0-py3-none-any.whl", hash = "sha256:f662826b6bed8cace05e7ff873ce0f9283b5c924470fe664fff1c2f00f581790", size = 6777, upload-time = "2024-03-31T07:27:34.792Z" }, +] + +[[package]] +name = "jaraco-context" +version = "6.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "backports-tarfile", marker = "python_full_version < '3.12'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/50/4763cd07e722bb6285316d390a164bc7e479db9d90daa769f22578f698b4/jaraco_context-6.1.2.tar.gz", hash = "sha256:f1a6c9d391e661cc5b8d39861ff077a7dc24dc23833ccee564b234b81c82dfe3", size = 16801, upload-time = "2026-03-20T22:13:33.922Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/58/bc8954bda5fcda97bd7c19be11b85f91973d67a706ed4a3aec33e7de22db/jaraco_context-6.1.2-py3-none-any.whl", hash = "sha256:bf8150b79a2d5d91ae48629d8b427a8f7ba0e1097dd6202a9059f29a36379535", size = 7871, upload-time = "2026-03-20T22:13:32.808Z" }, +] + +[[package]] +name = "jaraco-functools" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "more-itertools" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/1f/c23395957d41ccf27c4e535c3d334c4051e5395b3752057ba4cbaec35c56/jaraco_functools-4.6.0.tar.gz", hash = "sha256:880c577ec9720b3a052d5bc611fb9f2269b3d87902ef42440df443b88e443280", size = 20837, upload-time = "2026-07-14T01:28:02.544Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl", hash = "sha256:99e3dc0060c5cbe8fcd1cdb36258e2a65ca40f1566b2033b12abb1bb44dd3c30", size = 11677, upload-time = "2026-07-14T01:28:01.59Z" }, +] + +[[package]] +name = "jeepney" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7b/6f/357efd7602486741aa73ffc0617fb310a29b588ed0fd69c2399acbb85b0c/jeepney-0.9.0.tar.gz", hash = "sha256:cf0e9e845622b81e4a28df94c40345400256ec608d0e55bb8a3feaa9163f5732", size = 106758, upload-time = "2025-02-27T18:51:01.684Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/a3/e137168c9c44d18eff0376253da9f1e9234d0239e0ee230d2fee6cea8e55/jeepney-0.9.0-py3-none-any.whl", hash = "sha256:97e5714520c16fc0a45695e5365a2e11b81ea79bba796e26f9f1d178cb182683", size = 49010, upload-time = "2025-02-27T18:51:00.104Z" }, +] + +[[package]] +name = "joserfc" +version = "1.7.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/e0/27a6a081ae25420eda6768ceae05d7022a7f2447f420588843f2a44e4298/joserfc-1.7.4.tar.gz", hash = "sha256:b3bc561672ae541b17a9237053b48a03dacddd92d68047b3ecdfb4b5714a88ed", size = 234027, upload-time = "2026-07-19T15:43:02.739Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f9/bf/249dcd99b3376375910b7fa922383b57792975c8758f50d44612e749226c/joserfc-1.7.4-py3-none-any.whl", hash = "sha256:32d46c2cd5e3203c13e87a6c61333cab310b1ba80cd54b4c4f386a848a122463", size = 71000, upload-time = "2026-07-19T15:43:01.299Z" }, +] + +[[package]] +name = "jsonref" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/0d/c1f3277e90ccdb50d33ed5ba1ec5b3f0a242ed8c1b1a85d3afeb68464dca/jsonref-1.1.0.tar.gz", hash = "sha256:32fe8e1d85af0fdefbebce950af85590b22b60f9e95443176adbde4e1ecea552", size = 8814, upload-time = "2023-01-16T16:10:04.455Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/ec/e1db9922bceb168197a558a2b8c03a7963f1afe93517ddd3cf99f202f996/jsonref-1.1.0-py3-none-any.whl", hash = "sha256:590dc7773df6c21cbf948b5dac07a72a251db28b0238ceecce0a2abfa8ec30a9", size = 9425, upload-time = "2023-01-16T16:10:02.255Z" }, +] + +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" }, +] + +[[package]] +name = "jsonschema-path" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "pathable" }, + { name = "pyyaml" }, + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/79/cd02a4df6d9270efdc7d3feefe6edd730b0820c39eeaa107a2faee8322d5/jsonschema_path-0.5.0.tar.gz", hash = "sha256:493b156ba895c97602655b620a8456caa2ce08c1aa389f5a7addec065e6e855c", size = 19597, upload-time = "2026-05-19T20:45:00.971Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/2c/9e69d73c4297508be9e3b64a970ea3971b3eb8db64ffc5802d40bd25981f/jsonschema_path-0.5.0-py3-none-any.whl", hash = "sha256:2790a070bc7abb08ea3dbe4d340ece4efadf639223001f020c7503229ba068e2", size = 24077, upload-time = "2026-05-19T20:44:59.225Z" }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, +] + +[[package]] +name = "keyring" +version = "25.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "importlib-metadata", marker = "python_full_version < '3.12'" }, + { name = "jaraco-classes" }, + { name = "jaraco-context" }, + { name = "jaraco-functools" }, + { name = "jeepney", marker = "sys_platform == 'linux'" }, + { name = "pywin32-ctypes", marker = "sys_platform == 'win32'" }, + { name = "secretstorage", marker = "sys_platform == 'linux'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/4b/674af6ef2f97d56f0ab5153bf0bfa28ccb6c3ed4d1babf4305449668807b/keyring-25.7.0.tar.gz", hash = "sha256:fe01bd85eb3f8fb3dd0405defdeac9a5b4f6f0439edbb3149577f244a2e8245b", size = 63516, upload-time = "2025-11-16T16:26:09.482Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, +] + +[[package]] +name = "kiwisolver" +version = "1.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/67/9c61eccb13f0bdca9307614e782fec49ffdde0f7a2314935d489fa93cd9c/kiwisolver-1.5.0.tar.gz", hash = "sha256:d4193f3d9dc3f6f79aaed0e5637f45d98850ebf01f7ca20e69457f3e8946b66a", size = 103482, upload-time = "2026-03-09T13:15:53.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/dd/a495a9c104be1c476f0386e714252caf2b7eca883915422a64c50b88c6f5/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9eed0f7edbb274413b6ee781cca50541c8c0facd3d6fd289779e494340a2b85c", size = 122798, upload-time = "2026-03-09T13:12:58.963Z" }, + { url = "https://files.pythonhosted.org/packages/11/60/37b4047a2af0cf5ef6d8b4b26e91829ae6fc6a2d1f74524bcb0e7cd28a32/kiwisolver-1.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c4923e404d6bcd91b6779c009542e5647fef32e4a5d75e115e3bbac6f2335eb", size = 66216, upload-time = "2026-03-09T13:13:00.155Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/510dc933d87767584abfe03efa445889996c70c2990f6f87c3ebaa0a18c5/kiwisolver-1.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0df54df7e686afa55e6f21fb86195224a6d9beb71d637e8d7920c95cf0f89aac", size = 63911, upload-time = "2026-03-09T13:13:01.671Z" }, + { url = "https://files.pythonhosted.org/packages/80/46/bddc13df6c2a40741e0cc7865bb1c9ed4796b6760bd04ce5fae3928ef917/kiwisolver-1.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2517e24d7315eb51c10664cdb865195df38ab74456c677df67bb47f12d088a27", size = 1438209, upload-time = "2026-03-09T13:13:03.385Z" }, + { url = "https://files.pythonhosted.org/packages/fd/d6/76621246f5165e5372f02f5e6f3f48ea336a8f9e96e43997d45b240ed8cd/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff710414307fefa903e0d9bdf300972f892c23477829f49504e59834f4195398", size = 1248888, upload-time = "2026-03-09T13:13:05.231Z" }, + { url = "https://files.pythonhosted.org/packages/b2/c1/31559ec6fb39a5b48035ce29bb63ade628f321785f38c384dee3e2c08bc1/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6176c1811d9d5a04fa391c490cc44f451e240697a16977f11c6f722efb9041db", size = 1266304, upload-time = "2026-03-09T13:13:06.743Z" }, + { url = "https://files.pythonhosted.org/packages/5e/ef/1cb8276f2d29cc6a41e0a042f27946ca347d3a4a75acf85d0a16aa6dcc82/kiwisolver-1.5.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50847dca5d197fcbd389c805aa1a1cf32f25d2e7273dc47ab181a517666b68cc", size = 1319650, upload-time = "2026-03-09T13:13:08.607Z" }, + { url = "https://files.pythonhosted.org/packages/4c/e4/5ba3cecd7ce6236ae4a80f67e5d5531287337d0e1f076ca87a5abe4cd5d0/kiwisolver-1.5.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:01808c6d15f4c3e8559595d6d1fe6411c68e4a3822b4b9972b44473b24f4e679", size = 970949, upload-time = "2026-03-09T13:13:10.299Z" }, + { url = "https://files.pythonhosted.org/packages/5a/69/dc61f7ae9a2f071f26004ced87f078235b5507ab6e5acd78f40365655034/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:f1f9f4121ec58628c96baa3de1a55a4e3a333c5102c8e94b64e23bf7b2083309", size = 2199125, upload-time = "2026-03-09T13:13:11.841Z" }, + { url = "https://files.pythonhosted.org/packages/e5/7b/abbe0f1b5afa85f8d084b73e90e5f801c0939eba16ac2e49af7c61a6c28d/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b7d335370ae48a780c6e6a6bbfa97342f563744c39c35562f3f367665f5c1de2", size = 2293783, upload-time = "2026-03-09T13:13:14.399Z" }, + { url = "https://files.pythonhosted.org/packages/8a/80/5908ae149d96d81580d604c7f8aefd0e98f4fd728cf172f477e9f2a81744/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:800ee55980c18545af444d93fdd60c56b580db5cc54867d8cbf8a1dc0829938c", size = 1960726, upload-time = "2026-03-09T13:13:16.047Z" }, + { url = "https://files.pythonhosted.org/packages/84/08/a78cb776f8c085b7143142ce479859cfec086bd09ee638a317040b6ef420/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c438f6ca858697c9ab67eb28246c92508af972e114cac34e57a6d4ba17a3ac08", size = 2464738, upload-time = "2026-03-09T13:13:17.897Z" }, + { url = "https://files.pythonhosted.org/packages/b1/e1/65584da5356ed6cb12c63791a10b208860ac40a83de165cb6a6751a686e3/kiwisolver-1.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8c63c91f95173f9c2a67c7c526b2cea976828a0e7fced9cdcead2802dc10f8a4", size = 2270718, upload-time = "2026-03-09T13:13:19.421Z" }, + { url = "https://files.pythonhosted.org/packages/be/6c/28f17390b62b8f2f520e2915095b3c94d88681ecf0041e75389d9667f202/kiwisolver-1.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:beb7f344487cdcb9e1efe4b7a29681b74d34c08f0043a327a74da852a6749e7b", size = 73480, upload-time = "2026-03-09T13:13:20.818Z" }, + { url = "https://files.pythonhosted.org/packages/d8/0e/2ee5debc4f77a625778fec5501ff3e8036fe361b7ee28ae402a485bb9694/kiwisolver-1.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:ad4ae4ffd1ee9cd11357b4c66b612da9888f4f4daf2f36995eda64bd45370cac", size = 64930, upload-time = "2026-03-09T13:13:21.997Z" }, + { url = "https://files.pythonhosted.org/packages/4d/b2/818b74ebea34dabe6d0c51cb1c572e046730e64844da6ed646d5298c40ce/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:4e9750bc21b886308024f8a54ccb9a2cc38ac9fa813bf4348434e3d54f337ff9", size = 123158, upload-time = "2026-03-09T13:13:23.127Z" }, + { url = "https://files.pythonhosted.org/packages/bf/d9/405320f8077e8e1c5c4bd6adc45e1e6edf6d727b6da7f2e2533cf58bff71/kiwisolver-1.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:72ec46b7eba5b395e0a7b63025490d3214c11013f4aacb4f5e8d6c3041829588", size = 66388, upload-time = "2026-03-09T13:13:24.765Z" }, + { url = "https://files.pythonhosted.org/packages/99/9f/795fedf35634f746151ca8839d05681ceb6287fbed6cc1c9bf235f7887c2/kiwisolver-1.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ed3a984b31da7481b103f68776f7128a89ef26ed40f4dc41a2223cda7fb24819", size = 64068, upload-time = "2026-03-09T13:13:25.878Z" }, + { url = "https://files.pythonhosted.org/packages/c4/13/680c54afe3e65767bed7ec1a15571e1a2f1257128733851ade24abcefbcc/kiwisolver-1.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:bb5136fb5352d3f422df33f0c879a1b0c204004324150cc3b5e3c4f310c9049f", size = 1477934, upload-time = "2026-03-09T13:13:27.166Z" }, + { url = "https://files.pythonhosted.org/packages/c8/2f/cebfcdb60fd6a9b0f6b47a9337198bcbad6fbe15e68189b7011fd914911f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b2af221f268f5af85e776a73d62b0845fc8baf8ef0abfae79d29c77d0e776aaf", size = 1278537, upload-time = "2026-03-09T13:13:28.707Z" }, + { url = "https://files.pythonhosted.org/packages/f2/0d/9b782923aada3fafb1d6b84e13121954515c669b18af0c26e7d21f579855/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b0f172dc8ffaccb8522d7c5d899de00133f2f1ca7b0a49b7da98e901de87bf2d", size = 1296685, upload-time = "2026-03-09T13:13:30.528Z" }, + { url = "https://files.pythonhosted.org/packages/27/70/83241b6634b04fe44e892688d5208332bde130f38e610c0418f9ede47ded/kiwisolver-1.5.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6ab8ba9152203feec73758dad83af9a0bbe05001eb4639e547207c40cfb52083", size = 1346024, upload-time = "2026-03-09T13:13:32.818Z" }, + { url = "https://files.pythonhosted.org/packages/e4/db/30ed226fb271ae1a6431fc0fe0edffb2efe23cadb01e798caeb9f2ceae8f/kiwisolver-1.5.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:cdee07c4d7f6d72008d3f73b9bf027f4e11550224c7c50d8df1ae4a37c1402a6", size = 987241, upload-time = "2026-03-09T13:13:34.435Z" }, + { url = "https://files.pythonhosted.org/packages/ec/bd/c314595208e4c9587652d50959ead9e461995389664e490f4dce7ff0f782/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c60d3c9b06fb23bd9c6139281ccbdc384297579ae037f08ae90c69f6845c0b1", size = 2227742, upload-time = "2026-03-09T13:13:36.4Z" }, + { url = "https://files.pythonhosted.org/packages/c1/43/0499cec932d935229b5543d073c2b87c9c22846aab48881e9d8d6e742a2d/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e315e5ec90d88e140f57696ff85b484ff68bb311e36f2c414aa4286293e6dee0", size = 2323966, upload-time = "2026-03-09T13:13:38.204Z" }, + { url = "https://files.pythonhosted.org/packages/3d/6f/79b0d760907965acfd9d61826a3d41f8f093c538f55cd2633d3f0db269f6/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:1465387ac63576c3e125e5337a6892b9e99e0627d52317f3ca79e6930d889d15", size = 1977417, upload-time = "2026-03-09T13:13:39.966Z" }, + { url = "https://files.pythonhosted.org/packages/ab/31/01d0537c41cb75a551a438c3c7a80d0c60d60b81f694dac83dd436aec0d0/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:530a3fd64c87cffa844d4b6b9768774763d9caa299e9b75d8eca6a4423b31314", size = 2491238, upload-time = "2026-03-09T13:13:41.698Z" }, + { url = "https://files.pythonhosted.org/packages/e4/34/8aefdd0be9cfd00a44509251ba864f5caf2991e36772e61c408007e7f417/kiwisolver-1.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1d9daea4ea6b9be74fe2f01f7fbade8d6ffab263e781274cffca0dba9be9eec9", size = 2294947, upload-time = "2026-03-09T13:13:43.343Z" }, + { url = "https://files.pythonhosted.org/packages/ad/cf/0348374369ca588f8fe9c338fae49fa4e16eeb10ffb3d012f23a54578a9e/kiwisolver-1.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:f18c2d9782259a6dc132fdc7a63c168cbc74b35284b6d75c673958982a378384", size = 73569, upload-time = "2026-03-09T13:13:45.792Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/192b26196e2316e2bd29deef67e37cdf9870d9af8e085e521afff0fed526/kiwisolver-1.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7c7553b13f69c1b29a5bde08ddc6d9d0c8bfb84f9ed01c30db25944aeb852a7", size = 64997, upload-time = "2026-03-09T13:13:46.878Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/024d6711d5ba575aa65d5538042e99964104e97fa153a9f10bc369182bc2/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:fd40bb9cd0891c4c3cb1ddf83f8bbfa15731a248fdc8162669405451e2724b09", size = 123166, upload-time = "2026-03-09T13:13:48.032Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/adbb40df306f587054a348831220812b9b1d787aff714cfbc8556e38fccd/kiwisolver-1.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c0e1403fd7c26d77c1f03e096dc58a5c726503fa0db0456678b8668f76f521e3", size = 66395, upload-time = "2026-03-09T13:13:49.365Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3a/d0a972b34e1c63e2409413104216cd1caa02c5a37cb668d1687d466c1c45/kiwisolver-1.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dda366d548e89a90d88a86c692377d18d8bd64b39c1fb2b92cb31370e2896bbd", size = 64065, upload-time = "2026-03-09T13:13:50.562Z" }, + { url = "https://files.pythonhosted.org/packages/2b/0a/7b98e1e119878a27ba8618ca1e18b14f992ff1eda40f47bccccf4de44121/kiwisolver-1.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:332b4f0145c30b5f5ad9374881133e5aa64320428a57c2c2b61e9d891a51c2f3", size = 1477903, upload-time = "2026-03-09T13:13:52.084Z" }, + { url = "https://files.pythonhosted.org/packages/18/d8/55638d89ffd27799d5cc3d8aa28e12f4ce7a64d67b285114dbedc8ea4136/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c50b89ffd3e1a911c69a1dd3de7173c0cd10b130f56222e57898683841e4f96", size = 1278751, upload-time = "2026-03-09T13:13:54.673Z" }, + { url = "https://files.pythonhosted.org/packages/b8/97/b4c8d0d18421ecceba20ad8701358453b88e32414e6f6950b5a4bad54e65/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4db576bb8c3ef9365f8b40fe0f671644de6736ae2c27a2c62d7d8a1b4329f099", size = 1296793, upload-time = "2026-03-09T13:13:56.287Z" }, + { url = "https://files.pythonhosted.org/packages/c4/10/f862f94b6389d8957448ec9df59450b81bec4abb318805375c401a1e6892/kiwisolver-1.5.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0b85aad90cea8ac6797a53b5d5f2e967334fa4d1149f031c4537569972596cb8", size = 1346041, upload-time = "2026-03-09T13:13:58.269Z" }, + { url = "https://files.pythonhosted.org/packages/a3/6a/f1650af35821eaf09de398ec0bc2aefc8f211f0cda50204c9f1673741ba9/kiwisolver-1.5.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:d36ca54cb4c6c4686f7cbb7b817f66f5911c12ddb519450bbe86707155028f87", size = 987292, upload-time = "2026-03-09T13:13:59.871Z" }, + { url = "https://files.pythonhosted.org/packages/de/19/d7fb82984b9238115fe629c915007be608ebd23dc8629703d917dbfaffd4/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:38f4a703656f493b0ad185211ccfca7f0386120f022066b018eb5296d8613e23", size = 2227865, upload-time = "2026-03-09T13:14:01.401Z" }, + { url = "https://files.pythonhosted.org/packages/7f/b9/46b7f386589fd222dac9e9de9c956ce5bcefe2ee73b4e79891381dda8654/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3ac2360e93cb41be81121755c6462cff3beaa9967188c866e5fce5cf13170859", size = 2324369, upload-time = "2026-03-09T13:14:02.972Z" }, + { url = "https://files.pythonhosted.org/packages/92/8b/95e237cf3d9c642960153c769ddcbe278f182c8affb20cecc1cc983e7cc5/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c95cab08d1965db3d84a121f1c7ce7479bdd4072c9b3dafd8fecce48a2e6b902", size = 1977989, upload-time = "2026-03-09T13:14:04.503Z" }, + { url = "https://files.pythonhosted.org/packages/1b/95/980c9df53501892784997820136c01f62bc1865e31b82b9560f980c0e649/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:fc20894c3d21194d8041a28b65622d5b86db786da6e3cfe73f0c762951a61167", size = 2491645, upload-time = "2026-03-09T13:14:06.106Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/900647fd0840abebe1561792c6b31e6a7c0e278fc3973d30572a965ca14c/kiwisolver-1.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a32f72973f0f950c1920475d5c5ea3d971b81b6f0ec53b8d0a956cc965f22e0", size = 2295237, upload-time = "2026-03-09T13:14:08.891Z" }, + { url = "https://files.pythonhosted.org/packages/be/8a/be60e3bbcf513cc5a50f4a3e88e1dcecebb79c1ad607a7222877becaa101/kiwisolver-1.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bf3acf1419fa93064a4c2189ac0b58e3be7872bf6ee6177b0d4c63dc4cea276", size = 73573, upload-time = "2026-03-09T13:14:12.327Z" }, + { url = "https://files.pythonhosted.org/packages/4d/d2/64be2e429eb4fca7f7e1c52a91b12663aeaf25de3895e5cca0f47ef2a8d0/kiwisolver-1.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:fa8eb9ecdb7efb0b226acec134e0d709e87a909fa4971a54c0c4f6e88635484c", size = 64998, upload-time = "2026-03-09T13:14:13.469Z" }, + { url = "https://files.pythonhosted.org/packages/b0/69/ce68dd0c85755ae2de490bf015b62f2cea5f6b14ff00a463f9d0774449ff/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db485b3847d182b908b483b2ed133c66d88d49cacf98fd278fadafe11b4478d1", size = 125700, upload-time = "2026-03-09T13:14:14.636Z" }, + { url = "https://files.pythonhosted.org/packages/74/aa/937aac021cf9d4349990d47eb319309a51355ed1dbdc9c077cdc9224cb11/kiwisolver-1.5.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:be12f931839a3bdfe28b584db0e640a65a8bcbc24560ae3fdb025a449b3d754e", size = 67537, upload-time = "2026-03-09T13:14:15.808Z" }, + { url = "https://files.pythonhosted.org/packages/ee/20/3a87fbece2c40ad0f6f0aefa93542559159c5f99831d596050e8afae7a9f/kiwisolver-1.5.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:16b85d37c2cbb3253226d26e64663f755d88a03439a9c47df6246b35defbdfb7", size = 65514, upload-time = "2026-03-09T13:14:18.035Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7f/f943879cda9007c45e1f7dba216d705c3a18d6b35830e488b6c6a4e7cdf0/kiwisolver-1.5.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4432b835675f0ea7414aab3d37d119f7226d24869b7a829caeab49ebda407b0c", size = 1584848, upload-time = "2026-03-09T13:14:19.745Z" }, + { url = "https://files.pythonhosted.org/packages/37/f8/4d4f85cc1870c127c88d950913370dd76138482161cd07eabbc450deff01/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b0feb50971481a2cc44d94e88bdb02cdd497618252ae226b8eb1201b957e368", size = 1391542, upload-time = "2026-03-09T13:14:21.54Z" }, + { url = "https://files.pythonhosted.org/packages/04/0b/65dd2916c84d252b244bd405303220f729e7c17c9d7d33dca6feeff9ffc4/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56fa888f10d0f367155e76ce849fa1166fc9730d13bd2d65a2aa13b6f5424489", size = 1404447, upload-time = "2026-03-09T13:14:23.205Z" }, + { url = "https://files.pythonhosted.org/packages/39/5c/2606a373247babce9b1d056c03a04b65f3cf5290a8eac5d7bdead0a17e21/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:940dda65d5e764406b9fb92761cbf462e4e63f712ab60ed98f70552e496f3bf1", size = 1455918, upload-time = "2026-03-09T13:14:24.74Z" }, + { url = "https://files.pythonhosted.org/packages/d5/d1/c6078b5756670658e9192a2ef11e939c92918833d2745f85cd14a6004bdf/kiwisolver-1.5.0-cp313-cp313t-manylinux_2_39_riscv64.whl", hash = "sha256:89fc958c702ee9a745e4700378f5d23fddbc46ff89e8fdbf5395c24d5c1452a3", size = 1072856, upload-time = "2026-03-09T13:14:26.597Z" }, + { url = "https://files.pythonhosted.org/packages/cb/c8/7def6ddf16eb2b3741d8b172bdaa9af882b03c78e9b0772975408801fa63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9027d773c4ff81487181a925945743413f6069634d0b122d0b37684ccf4f1e18", size = 2333580, upload-time = "2026-03-09T13:14:28.237Z" }, + { url = "https://files.pythonhosted.org/packages/9e/87/2ac1fce0eb1e616fcd3c35caa23e665e9b1948bb984f4764790924594128/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:5b233ea3e165e43e35dba1d2b8ecc21cf070b45b65ae17dd2747d2713d942021", size = 2423018, upload-time = "2026-03-09T13:14:30.018Z" }, + { url = "https://files.pythonhosted.org/packages/67/13/c6700ccc6cc218716bfcda4935e4b2997039869b4ad8a94f364c5a3b8e63/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ce9bf03dad3b46408c08649c6fbd6ca28a9fce0eb32fdfffa6775a13103b5310", size = 2062804, upload-time = "2026-03-09T13:14:32.888Z" }, + { url = "https://files.pythonhosted.org/packages/1b/bd/877056304626943ff0f1f44c08f584300c199b887cb3176cd7e34f1515f1/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:fc4d3f1fb9ca0ae9f97b095963bc6326f1dbfd3779d6679a1e016b9baaa153d3", size = 2597482, upload-time = "2026-03-09T13:14:34.971Z" }, + { url = "https://files.pythonhosted.org/packages/75/19/c60626c47bf0f8ac5dcf72c6c98e266d714f2fbbfd50cf6dab5ede3aaa50/kiwisolver-1.5.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:f443b4825c50a51ee68585522ab4a1d1257fac65896f282b4c6763337ac9f5d2", size = 2394328, upload-time = "2026-03-09T13:14:36.816Z" }, + { url = "https://files.pythonhosted.org/packages/47/84/6a6d5e5bb8273756c27b7d810d47f7ef2f1f9b9fd23c9ee9a3f8c75c9cef/kiwisolver-1.5.0-cp313-cp313t-win_arm64.whl", hash = "sha256:893ff3a711d1b515ba9da14ee090519bad4610ed1962fbe298a434e8c5f8db53", size = 68410, upload-time = "2026-03-09T13:14:38.695Z" }, + { url = "https://files.pythonhosted.org/packages/e4/d7/060f45052f2a01ad5762c8fdecd6d7a752b43400dc29ff75cd47225a40fd/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8df31fe574b8b3993cc61764f40941111b25c2d9fea13d3ce24a49907cd2d615", size = 123231, upload-time = "2026-03-09T13:14:41.323Z" }, + { url = "https://files.pythonhosted.org/packages/c2/a7/78da680eadd06ff35edef6ef68a1ad273bad3e2a0936c9a885103230aece/kiwisolver-1.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1d49a49ac4cbfb7c1375301cd1ec90169dfeae55ff84710d782260ce77a75a02", size = 66489, upload-time = "2026-03-09T13:14:42.534Z" }, + { url = "https://files.pythonhosted.org/packages/49/b2/97980f3ad4fae37dd7fe31626e2bf75fbf8bdf5d303950ec1fab39a12da8/kiwisolver-1.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0cbe94b69b819209a62cb27bdfa5dc2a8977d8de2f89dfd97ba4f53ed3af754e", size = 64063, upload-time = "2026-03-09T13:14:44.759Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f9/b06c934a6aa8bc91f566bd2a214fd04c30506c2d9e2b6b171953216a65b6/kiwisolver-1.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80aa065ffd378ff784822a6d7c3212f2d5f5e9c3589614b5c228b311fd3063ac", size = 1475913, upload-time = "2026-03-09T13:14:46.247Z" }, + { url = "https://files.pythonhosted.org/packages/6b/f0/f768ae564a710135630672981231320bc403cf9152b5596ec5289de0f106/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e7f886f47ab881692f278ae901039a234e4025a68e6dfab514263a0b1c4ae05", size = 1282782, upload-time = "2026-03-09T13:14:48.458Z" }, + { url = "https://files.pythonhosted.org/packages/e2/9f/1de7aad00697325f05238a5f2eafbd487fb637cc27a558b5367a5f37fb7f/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5060731cc3ed12ca3a8b57acd4aeca5bbc2f49216dd0bec1650a1acd89486bcd", size = 1300815, upload-time = "2026-03-09T13:14:50.721Z" }, + { url = "https://files.pythonhosted.org/packages/5a/c2/297f25141d2e468e0ce7f7a7b92e0cf8918143a0cbd3422c1ad627e85a06/kiwisolver-1.5.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a4aa69609f40fce3cbc3f87b2061f042eee32f94b8f11db707b66a26461591a", size = 1347925, upload-time = "2026-03-09T13:14:52.304Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d3/f4c73a02eb41520c47610207b21afa8cdd18fdbf64ffd94674ae21c4812d/kiwisolver-1.5.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:d168fda2dbff7b9b5f38e693182d792a938c31db4dac3a80a4888de603c99554", size = 991322, upload-time = "2026-03-09T13:14:54.637Z" }, + { url = "https://files.pythonhosted.org/packages/7b/46/d3f2efef7732fcda98d22bf4ad5d3d71d545167a852ca710a494f4c15343/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:413b820229730d358efd838ecbab79902fe97094565fdc80ddb6b0a18c18a581", size = 2232857, upload-time = "2026-03-09T13:14:56.471Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ec/2d9756bf2b6d26ae4349b8d3662fb3993f16d80c1f971c179ce862b9dbae/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5124d1ea754509b09e53738ec185584cc609aae4a3b510aaf4ed6aa047ef9303", size = 2329376, upload-time = "2026-03-09T13:14:58.072Z" }, + { url = "https://files.pythonhosted.org/packages/8f/9f/876a0a0f2260f1bde92e002b3019a5fabc35e0939c7d945e0fa66185eb20/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e4415a8db000bf49a6dd1c478bf70062eaacff0f462b92b0ba68791a905861f9", size = 1982549, upload-time = "2026-03-09T13:14:59.668Z" }, + { url = "https://files.pythonhosted.org/packages/6c/4f/ba3624dfac23a64d54ac4179832860cb537c1b0af06024936e82ca4154a0/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d618fd27420381a4f6044faa71f46d8bfd911bd077c555f7138ed88729bfbe79", size = 2494680, upload-time = "2026-03-09T13:15:01.364Z" }, + { url = "https://files.pythonhosted.org/packages/39/b7/97716b190ab98911b20d10bf92eca469121ec483b8ce0edd314f51bc85af/kiwisolver-1.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5092eb5b1172947f57d6ea7d89b2f29650414e4293c47707eb499ec07a0ac796", size = 2297905, upload-time = "2026-03-09T13:15:03.925Z" }, + { url = "https://files.pythonhosted.org/packages/a3/36/4e551e8aa55c9188bca9abb5096805edbf7431072b76e2298e34fd3a3008/kiwisolver-1.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:d76e2d8c75051d58177e762164d2e9ab92886534e3a12e795f103524f221dd8e", size = 75086, upload-time = "2026-03-09T13:15:07.775Z" }, + { url = "https://files.pythonhosted.org/packages/70/15/9b90f7df0e31a003c71649cf66ef61c3c1b862f48c81007fa2383c8bd8d7/kiwisolver-1.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:fa6248cd194edff41d7ea9425ced8ca3a6f838bfb295f6f1d6e6bb694a8518df", size = 66577, upload-time = "2026-03-09T13:15:09.139Z" }, + { url = "https://files.pythonhosted.org/packages/17/01/7dc8c5443ff42b38e72731643ed7cf1ed9bf01691ae5cdca98501999ed83/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:d1ffeb80b5676463d7a7d56acbe8e37a20ce725570e09549fe738e02ca6b7e1e", size = 125794, upload-time = "2026-03-09T13:15:10.525Z" }, + { url = "https://files.pythonhosted.org/packages/46/8a/b4ebe46ebaac6a303417fab10c2e165c557ddaff558f9699d302b256bc53/kiwisolver-1.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bc4d8e252f532ab46a1de9349e2d27b91fce46736a9eedaa37beaca66f574ed4", size = 67646, upload-time = "2026-03-09T13:15:12.016Z" }, + { url = "https://files.pythonhosted.org/packages/60/35/10a844afc5f19d6f567359bf4789e26661755a2f36200d5d1ed8ad0126e5/kiwisolver-1.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6783e069732715ad0c3ce96dbf21dbc2235ab0593f2baf6338101f70371f4028", size = 65511, upload-time = "2026-03-09T13:15:13.311Z" }, + { url = "https://files.pythonhosted.org/packages/f8/8a/685b297052dd041dcebce8e8787b58923b6e78acc6115a0dc9189011c44b/kiwisolver-1.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e7c4c09a490dc4d4a7f8cbee56c606a320f9dc28cf92a7157a39d1ce7676a657", size = 1584858, upload-time = "2026-03-09T13:15:15.103Z" }, + { url = "https://files.pythonhosted.org/packages/9e/80/04865e3d4638ac5bddec28908916df4a3075b8c6cc101786a96803188b96/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a075bd7bd19c70cf67c8badfa36cf7c5d8de3c9ddb8420c51e10d9c50e94920", size = 1392539, upload-time = "2026-03-09T13:15:16.661Z" }, + { url = "https://files.pythonhosted.org/packages/ba/01/77a19cacc0893fa13fafa46d1bba06fb4dc2360b3292baf4b56d8e067b24/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:bdd3e53429ff02aa319ba59dfe4ceeec345bf46cf180ec2cf6fd5b942e7975e9", size = 1405310, upload-time = "2026-03-09T13:15:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/53/39/bcaf5d0cca50e604cfa9b4e3ae1d64b50ca1ae5b754122396084599ef903/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3cdcb35dc9d807259c981a85531048ede628eabcffb3239adf3d17463518992d", size = 1456244, upload-time = "2026-03-09T13:15:20.444Z" }, + { url = "https://files.pythonhosted.org/packages/d0/7a/72c187abc6975f6978c3e39b7cf67aeb8b3c0a8f9790aa7fd412855e9e1f/kiwisolver-1.5.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:70d593af6a6ca332d1df73d519fddb5148edb15cd90d5f0155e3746a6d4fcc65", size = 1073154, upload-time = "2026-03-09T13:15:22.039Z" }, + { url = "https://files.pythonhosted.org/packages/c7/ca/cf5b25783ebbd59143b4371ed0c8428a278abe68d6d0104b01865b1bbd0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:377815a8616074cabbf3f53354e1d040c35815a134e01d7614b7692e4bf8acfa", size = 2334377, upload-time = "2026-03-09T13:15:23.741Z" }, + { url = "https://files.pythonhosted.org/packages/4a/e5/b1f492adc516796e88751282276745340e2a72dcd0d36cf7173e0daf3210/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0255a027391d52944eae1dbb5d4cc5903f57092f3674e8e544cdd2622826b3f0", size = 2425288, upload-time = "2026-03-09T13:15:25.789Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e5/9b21fbe91a61b8f409d74a26498706e97a48008bfcd1864373d32a6ba31c/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:012b1eb16e28718fa782b5e61dc6f2da1f0792ca73bd05d54de6cb9561665fc9", size = 2063158, upload-time = "2026-03-09T13:15:27.63Z" }, + { url = "https://files.pythonhosted.org/packages/b1/02/83f47986138310f95ea95531f851b2a62227c11cbc3e690ae1374fe49f0f/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e3aafb33aed7479377e5e9a82e9d4bf87063741fc99fc7ae48b0f16e32bdd6f", size = 2597260, upload-time = "2026-03-09T13:15:29.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/18/43a5f24608d8c313dd189cf838c8e68d75b115567c6279de7796197cfb6a/kiwisolver-1.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e7a116ae737f0000343218c4edf5bd45893bfeaff0993c0b215d7124c9f77646", size = 2394403, upload-time = "2026-03-09T13:15:31.517Z" }, + { url = "https://files.pythonhosted.org/packages/3b/b5/98222136d839b8afabcaa943b09bd05888c2d36355b7e448550211d1fca4/kiwisolver-1.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1dd9b0b119a350976a6d781e7278ec7aca0b201e1a9e2d23d9804afecb6ca681", size = 79687, upload-time = "2026-03-09T13:15:33.204Z" }, + { url = "https://files.pythonhosted.org/packages/99/a2/ca7dc962848040befed12732dff6acae7fb3c4f6fc4272b3f6c9a30b8713/kiwisolver-1.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:58f812017cd2985c21fbffb4864d59174d4903dd66fa23815e74bbc7a0e2dd57", size = 70032, upload-time = "2026-03-09T13:15:34.411Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fa/2910df836372d8761bb6eff7d8bdcb1613b5c2e03f260efe7abe34d388a7/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_10_13_x86_64.whl", hash = "sha256:5ae8e62c147495b01a0f4765c878e9bfdf843412446a247e28df59936e99e797", size = 130262, upload-time = "2026-03-09T13:15:35.629Z" }, + { url = "https://files.pythonhosted.org/packages/0f/41/c5f71f9f00aabcc71fee8b7475e3f64747282580c2fe748961ba29b18385/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:f6764a4ccab3078db14a632420930f6186058750df066b8ea2a7106df91d3203", size = 138036, upload-time = "2026-03-09T13:15:36.894Z" }, + { url = "https://files.pythonhosted.org/packages/fa/06/7399a607f434119c6e1fdc8ec89a8d51ccccadf3341dee4ead6bd14caaf5/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c31c13da98624f957b0fb1b5bae5383b2333c2c3f6793d9825dd5ce79b525cb7", size = 194295, upload-time = "2026-03-09T13:15:38.22Z" }, + { url = "https://files.pythonhosted.org/packages/b5/91/53255615acd2a1eaca307ede3c90eb550bae9c94581f8c00081b6b1c8f44/kiwisolver-1.5.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:1f1489f769582498610e015a8ef2d36f28f505ab3096d0e16b4858a9ec214f57", size = 75987, upload-time = "2026-03-09T13:15:39.65Z" }, + { url = "https://files.pythonhosted.org/packages/e9/eb/5fcbbbf9a0e2c3a35effb88831a483345326bbc3a030a3b5b69aee647f84/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:ec4c85dc4b687c7f7f15f553ff26a98bfe8c58f5f7f0ac8905f0ba4c7be60232", size = 59532, upload-time = "2026-03-09T13:15:47.047Z" }, + { url = "https://files.pythonhosted.org/packages/c3/9b/e17104555bb4db148fd52327feea1e96be4b88e8e008b029002c281a21ab/kiwisolver-1.5.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:12e91c215a96e39f57989c8912ae761286ac5a9584d04030ceb3368a357f017a", size = 57420, upload-time = "2026-03-09T13:15:48.199Z" }, + { url = "https://files.pythonhosted.org/packages/48/44/2b5b95b7aa39fb2d8d9d956e0f3d5d45aef2ae1d942d4c3ffac2f9cfed1a/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:be4a51a55833dc29ab5d7503e7bcb3b3af3402d266018137127450005cdfe737", size = 79892, upload-time = "2026-03-09T13:15:49.694Z" }, + { url = "https://files.pythonhosted.org/packages/52/7d/7157f9bba6b455cfb4632ed411e199fc8b8977642c2b12082e1bd9e6d173/kiwisolver-1.5.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daae526907e262de627d8f70058a0f64acc9e2641c164c99c8f594b34a799a16", size = 77603, upload-time = "2026-03-09T13:15:50.945Z" }, + { url = "https://files.pythonhosted.org/packages/0a/dd/8050c947d435c8d4bc94e3252f4d8bb8a76cfb424f043a8680be637a57f1/kiwisolver-1.5.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:59cd8683f575d96df5bb48f6add94afc055012c29e28124fcae2b63661b9efb1", size = 73558, upload-time = "2026-03-09T13:15:52.112Z" }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, +] + +[[package]] +name = "matplotlib" +version = "3.11.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "contourpy" }, + { name = "cycler" }, + { name = "fonttools" }, + { name = "kiwisolver" }, + { name = "numpy", version = "2.4.6", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.12'" }, + { name = "numpy", version = "2.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, + { name = "pillow" }, + { name = "pyparsing" }, + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/49/64/f9a391af28f518b11ad45a8a712353c94a0aefce09d3703200e5c54b610a/matplotlib-3.11.1.tar.gz", hash = "sha256:69647db5746941c793d6e445a4cd349323ffb87d9cc958c2ad84a659b4832d30", size = 32612045, upload-time = "2026-07-18T03:39:46.63Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6e/d0/791aa183dd88491555cf7d4be0b52b0bcf6c3c2a2c22c815a2e819bf53e2/matplotlib-3.11.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:b7cf158e7add54a8d51ac9b5a84abd6d4e13ed4951b4f25f1c5139f41c2addb2", size = 9440302, upload-time = "2026-07-18T03:38:03.844Z" }, + { url = "https://files.pythonhosted.org/packages/35/74/82bbdf683a301f4478384c8aaba6903631a2ca18294b2d7655c9a542bffb/matplotlib-3.11.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d2ace7273b9a5061a3b420918a16fae1f2dc5dfee1abcc13aba71b5d94b1820c", size = 9268549, upload-time = "2026-07-18T03:38:06.144Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f0/9b4298911303f74e6d83e64a81d996c0616405ec95046fac7f17e4258b9e/matplotlib-3.11.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aee55e9041211bf84302ab55ec3965df18dd90ae19f8b58332a7feaf208bfe83", size = 10024922, upload-time = "2026-07-18T03:38:08.236Z" }, + { url = "https://files.pythonhosted.org/packages/84/6f/0bc3c3d05b021db44c14bc379a7c0df7d57302aa15380c16fd4e63fd6a9b/matplotlib-3.11.1-cp311-cp311-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f4bdeea33a8d15a071dbfe6d119451b1d719c733ac666d65357082901a9099", size = 10832170, upload-time = "2026-07-18T03:38:10.276Z" }, + { url = "https://files.pythonhosted.org/packages/db/4d/e375f39acdb2af5a9342730618608e39790ec842e6f1b392863028781459/matplotlib-3.11.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:b4c78ceb2f11bcac7389d305cda17aeb1f4586a857854ab5780bd3dd8dbfc407", size = 10916701, upload-time = "2026-07-18T03:38:12.512Z" }, + { url = "https://files.pythonhosted.org/packages/bc/be/fa26ed085b41298f64a8f9b7592c671bbf1acc8b0df124c1c5de96b859f8/matplotlib-3.11.1-cp311-cp311-win_amd64.whl", hash = "sha256:7f33a781e12b1e53b278deb2f5373c2e55ec4f10727be3440c0cfb5cda9f944f", size = 9315331, upload-time = "2026-07-18T03:38:14.949Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f3/eb5bdf3b6e191b200db298b08bbc1638b7f3c82cdc8680f9d88bf72559ae/matplotlib-3.11.1-cp311-cp311-win_arm64.whl", hash = "sha256:67e4c3cd578c65ebd81bdc09a1b6592ceafee6dfafe116dc85dfcb647b5bbb18", size = 9003475, upload-time = "2026-07-18T03:38:17.205Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6c/7ef7ebcb2bd9739b2b66b18b076e077f44bb46fdbe28ca0506edb3c62c79/matplotlib-3.11.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e15ef41507f3d525f46154ac9e3ae785dacde9f20e593a25de8986267892ef74", size = 9453849, upload-time = "2026-07-18T03:38:19.593Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f8/6d0c312c8d9738e7d9677f09fe5c986b3239e651a7b73a2deb38b65e4a71/matplotlib-3.11.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:21a67b961a6d597bca54fae826cd20695ba4a6e4d05424a08da6e13e3176fd6b", size = 9283113, upload-time = "2026-07-18T03:38:21.95Z" }, + { url = "https://files.pythonhosted.org/packages/c9/cf/b4ad2cc81b6672ea29ea04e64e350a9f9b493b0908ccd884c67eeff8f7b2/matplotlib-3.11.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ba8f811b8ddfac493734d6af0b2dff96919d0c28ca0d641858dab4262777c6ea", size = 10035615, upload-time = "2026-07-18T03:38:24.315Z" }, + { url = "https://files.pythonhosted.org/packages/88/90/4e10e033d9b66589d8ed98b84c95cdbb57033d57c1f41339d7393dbd2f2e/matplotlib-3.11.1-cp312-cp312-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c52f7ad20ef476806ed212380b1d54d20310c8b86bdc2c9a68b51f0024a44472", size = 10842559, upload-time = "2026-07-18T03:38:26.285Z" }, + { url = "https://files.pythonhosted.org/packages/88/eb/799612d0f8cd3e816a10fec59329fca52cd2353264df80378dfc541ae855/matplotlib-3.11.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8b14eb22961fe865efb0e4ff167e333e428908b00115a8d800ccb65ee108e481", size = 10927532, upload-time = "2026-07-18T03:38:28.532Z" }, + { url = "https://files.pythonhosted.org/packages/88/89/56649bbaa2fd12e20f3be03dbcc135b0c8676d88bac17977599e3eb442a0/matplotlib-3.11.1-cp312-cp312-win_amd64.whl", hash = "sha256:88a2a27dd9691ae448dfae4b26f59036be90c3c28757edd3553a29559d00859f", size = 9333886, upload-time = "2026-07-18T03:38:30.477Z" }, + { url = "https://files.pythonhosted.org/packages/c1/11/4d124efbbad677b7b7552f6f85a3bd432d4232f95400cea98fcd2ae36ef3/matplotlib-3.11.1-cp312-cp312-win_arm64.whl", hash = "sha256:480194afceca4df2f137c2721227d3cba67121fbf4397b69cee7f83714b0a58a", size = 9007545, upload-time = "2026-07-18T03:38:32.833Z" }, + { url = "https://files.pythonhosted.org/packages/04/6c/4798363b7fb5644e309fe1fac30216e9146c9f70859d80d588c18caf5317/matplotlib-3.11.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:6771b0cd7838c6a857a7209814158c0ad09bfef878db3033dd82d70ad101f191", size = 9454341, upload-time = "2026-07-18T03:38:35.001Z" }, + { url = "https://files.pythonhosted.org/packages/59/98/6acadbe7f98df19d274bc107ac58bb439fa75df82c33dc110d71a4a8501f/matplotlib-3.11.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2abdee5ffa2fe11b2d19f7a5c63b785fb7c28cc46c7bc1814156341d9d1a33e1", size = 9283627, upload-time = "2026-07-18T03:38:37.061Z" }, + { url = "https://files.pythonhosted.org/packages/24/ea/65cec46fe241390ccea1b1754207ee28eb71c5ab866bd5f22fe47e538fa4/matplotlib-3.11.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0a19dcf73406d3746d25a5ed42d713604c9a3e024d129b102852b0d941cb9f3", size = 10035860, upload-time = "2026-07-18T03:38:39.663Z" }, + { url = "https://files.pythonhosted.org/packages/c7/10/63fdccccbabe002fb0960876baabc5e3f24d9c1bb4cfb25651457f74b3a0/matplotlib-3.11.1-cp313-cp313-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7389b77ed2ab0552f46d9a90b81b7b8e6dfcdc42adc36c37a0865799843e0e3e", size = 10843594, upload-time = "2026-07-18T03:38:42.144Z" }, + { url = "https://files.pythonhosted.org/packages/98/51/a1155945bff7b91381875022ac1522c5dfdac0d006be8e7df389b3134eae/matplotlib-3.11.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c90be0b73568da4f662afac580956a76e308437e641b4a45aa08925eeb67d95f", size = 10927962, upload-time = "2026-07-18T03:38:44.302Z" }, + { url = "https://files.pythonhosted.org/packages/0d/3a/3d5e1f42dc761bf53401a62a83ff93389b37de9d2c093b2a3aa49ac34f1b/matplotlib-3.11.1-cp313-cp313-win_amd64.whl", hash = "sha256:68408341f2312836fbbdf6b3c78047f65b2d8752f5fd221c3e72d348f5b34f8b", size = 9334074, upload-time = "2026-07-18T03:38:46.616Z" }, + { url = "https://files.pythonhosted.org/packages/e2/db/3f5ea5a5b64060ef5e1ff60a19170423e41ce21b8497a6fe15a36e0b43e3/matplotlib-3.11.1-cp313-cp313-win_arm64.whl", hash = "sha256:0c1f44890d435c1b4ef52f701ad5828cb450ea97bcc83918fda6be74965d6cd2", size = 9007662, upload-time = "2026-07-18T03:38:49.112Z" }, + { url = "https://files.pythonhosted.org/packages/98/6e/c7ae5e0531425b69c0826b00ebbc264c85cab853f1cd6e096c9983c2cdc1/matplotlib-3.11.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:5e510088c27a89d53580a752f959146893563e63c330e161d159b0fee652af6f", size = 9503790, upload-time = "2026-07-18T03:38:51.527Z" }, + { url = "https://files.pythonhosted.org/packages/92/79/15be162e0a2ed546939674e2e97d0e33ec2447d86d4d4e611fa295bb178c/matplotlib-3.11.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:1524e2bdd48a93557aa47ddcfe9c225dfdd57d5a01a5c49128c20f0632980ee1", size = 9336148, upload-time = "2026-07-18T03:38:53.564Z" }, + { url = "https://files.pythonhosted.org/packages/6a/7f/36ffe144fc4aacfe0e3ed2318f72b6755d1e73b041d619b4d393e60f5a66/matplotlib-3.11.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:11664c551345553db92e61cae6cf1376f138f8c47cafdf13b64b18f3e3e9e464", size = 10049244, upload-time = "2026-07-18T03:38:55.911Z" }, + { url = "https://files.pythonhosted.org/packages/ab/5f/55812d68c0a840d3a463638f48c00ab1fe338518ec49a640cb6473b444af/matplotlib-3.11.1-cp313-cp313t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e1f8922ba31959cf6a9dfb51be64b7f7bc582801a3957dc0c2f3afcd3537adf", size = 10860798, upload-time = "2026-07-18T03:38:58.282Z" }, + { url = "https://files.pythonhosted.org/packages/7a/64/cca444b4eb5e6c768c44fc5e1f0b5211f20ca2b282778051996e996a2bdf/matplotlib-3.11.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83235693abde86e5e0129998f80ee39fc7f58e6d56a88fafb28a9278833e9d5f", size = 10943282, upload-time = "2026-07-18T03:39:00.465Z" }, + { url = "https://files.pythonhosted.org/packages/e5/0f/a49c329d394f2e9ef38506982107e8b04ecf94dd41a9d8423ff82cc737c7/matplotlib-3.11.1-cp313-cp313t-win_amd64.whl", hash = "sha256:9a076f4fc5cdc43fdf510f5981418d25c2db4973418d9f22d8bb3dc8045ada78", size = 9383532, upload-time = "2026-07-18T03:39:02.468Z" }, + { url = "https://files.pythonhosted.org/packages/e4/50/103e86afb806d8f64d04ede14e4cfc09dbfc25f512421ff85fdd6ebd59cf/matplotlib-3.11.1-cp313-cp313t-win_arm64.whl", hash = "sha256:216fbb93a74add02ddb4cb38ef5348f59ac00b3e84567eaf16598772d40e150a", size = 9059665, upload-time = "2026-07-18T03:39:04.607Z" }, + { url = "https://files.pythonhosted.org/packages/35/04/3079499fa8cb661ea66d13d6439d5a3ae6710a7afd5c7f72e08914f275f8/matplotlib-3.11.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:30c492d4ba9448595b6fd8708c6725963f8148e25c0d8842948da5b05f0ee8d3", size = 9456022, upload-time = "2026-07-18T03:39:07.041Z" }, + { url = "https://files.pythonhosted.org/packages/53/a2/69acfe84ec1f32930e801a5782a07fc5c79c8c6599a507b806d859d5da8e/matplotlib-3.11.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ac104be2768ffdd8655db9e71b768cbb45f2b9aa7b450cf1595e8f65d3822319", size = 9285475, upload-time = "2026-07-18T03:39:09.562Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b3/31b15a2ca56d4ddd6aaa1c884c2f51cf9a61cfaf5ca6f6fbd6343d38e6df/matplotlib-3.11.1-cp314-cp314-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6be943cb68bc6660ead58c55b3aa6366cba2ef7feb06460fbcce32360376f19f", size = 10847102, upload-time = "2026-07-18T03:39:11.532Z" }, + { url = "https://files.pythonhosted.org/packages/64/0d/a17e966e620545c1548125af0b29ac812dd17b197a18a7462ac12fa859ee/matplotlib-3.11.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5af0dcda57d471440a7b5b623e70e0a61003518443d9098f211a96ecfbbc25be", size = 11131087, upload-time = "2026-07-18T03:39:13.764Z" }, + { url = "https://files.pythonhosted.org/packages/97/c5/5e100efdd67abb7de20befaa333612ef9bfc63417fb71398f904f25d083c/matplotlib-3.11.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3d3fd84082b1afbd9398466c81309e20045be20d48fe0fb18c43504d164cbbb2", size = 10929036, upload-time = "2026-07-18T03:39:16.888Z" }, + { url = "https://files.pythonhosted.org/packages/ce/04/d719a0a36930ecc8dfc801ff340f9dcfc4223f8ca5d39d06b4020032fff8/matplotlib-3.11.1-cp314-cp314-win_amd64.whl", hash = "sha256:9601a1e90be21e4884c53b4f3dc3ee0544654946f9975258d691f1c2e2f119c6", size = 9489571, upload-time = "2026-07-18T03:39:19.449Z" }, + { url = "https://files.pythonhosted.org/packages/48/65/facabdc2f1f6caba7e856db64dfedddca25f7608df07d96a1c8fd114fd3b/matplotlib-3.11.1-cp314-cp314-win_arm64.whl", hash = "sha256:ae30c6109848ac0f9fa36c5d6270938487614c47ba31860bd5361266dabc5685", size = 9164486, upload-time = "2026-07-18T03:39:21.424Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/18da6cd01cf96354534f98c468a25380c68ce582a2c9dd0cae12b04af4f2/matplotlib-3.11.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:dadfe80797174e2984aae3be0b77594a3c72d2c0a40fbd4a0de48d2728caf3ae", size = 9504876, upload-time = "2026-07-18T03:39:23.633Z" }, + { url = "https://files.pythonhosted.org/packages/79/b0/f0b63555a18b79d038c81fd6126f35fc4dfce0eaff48d96103348c7cf935/matplotlib-3.11.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:89b193b255f4f6f7948dbcee3691f4f341ab05d9a8874a67b45ddb4182922eda", size = 9336120, upload-time = "2026-07-18T03:39:25.797Z" }, + { url = "https://files.pythonhosted.org/packages/c6/dd/f210ec7c4a6f198d5567237048a93d0811fb5a1f1691f13320e592f95b41/matplotlib-3.11.1-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:191163532cdefcb1571ca38a6d7e6474baccde64495783e6ba47aa07ec4b9bbb", size = 10858033, upload-time = "2026-07-18T03:39:27.999Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d2/d6d5324507c5fbb316db48e258c09c2807f3de03d9af47017e120070926f/matplotlib-3.11.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9fdf1c818ab05d0e74002091ddaf414478a3a449ec9d51c8976d45be7e3a01e2", size = 11141827, upload-time = "2026-07-18T03:39:30.092Z" }, + { url = "https://files.pythonhosted.org/packages/0f/68/3c22e9320bdce2c4d2f1320643ef706db7a24cb7420eea28b97a2d67f5a8/matplotlib-3.11.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b937b9dba5f5f6c1e31c47abe2186c865c0914fd18f2ce0dfc39c9adcef5951d", size = 10943061, upload-time = "2026-07-18T03:39:32.356Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/907ed190ee81a9df581e0ed5456134fc0f7cb55ffcfda2f9e54ca900761c/matplotlib-3.11.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f2912f647f3fbe1ccf085f91e213936f9101bead81a5e670565b1f1b3712f4fb", size = 9540074, upload-time = "2026-07-18T03:39:34.789Z" }, + { url = "https://files.pythonhosted.org/packages/23/d4/97c19b77e0a6e3b48581185bb65088f431cd20186076cc0f650a1757ea46/matplotlib-3.11.1-cp314-cp314t-win_arm64.whl", hash = "sha256:54d47b8ae8b579633a3902ca5b4ad6c1e132a5626d64447b2e22a66394e79987", size = 9213472, upload-time = "2026-07-18T03:39:37.141Z" }, + { url = "https://files.pythonhosted.org/packages/ee/38/ceb1d637c4db6d06141f3739e93af3321e7caaabe69b57ae48ffe3ee95b1/matplotlib-3.11.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:427258425f9a3fc4ed79a91f9e9b9aaf5a82cb6571e85dc14063cc6fbb993741", size = 9438045, upload-time = "2026-07-18T03:39:39.491Z" }, + { url = "https://files.pythonhosted.org/packages/89/25/72ad8b58602d3a6ef1dfc4b65ecd01634ab65a2bdf494c9fe0e966dbf081/matplotlib-3.11.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:1ac697e591c11b6ad04679a73c2d2f9980fe9d9f0311fb414a2e329706343dfb", size = 9266127, upload-time = "2026-07-18T03:39:41.597Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6d/69552382fcc8e93d1f2763ef2665980a900a48b7f3a4c57ed290726d1cbc/matplotlib-3.11.1-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e4b9ac2f1f607ecda2af90a5232beee2af7582fce1cc30c4b6a1b012dc21ee99", size = 10019439, upload-time = "2026-07-18T03:39:43.78Z" }, +] + +[[package]] +name = "mcp" +version = "1.29.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "httpx" }, + { name = "httpx-sse" }, + { name = "jsonschema" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "pyjwt", extra = ["crypto"] }, + { name = "python-multipart" }, + { name = "pywin32", marker = "sys_platform == 'win32'" }, + { name = "sse-starlette" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, + { name = "uvicorn", marker = "sys_platform != 'emscripten'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/30/d3/f9acc21dfc886e4f78e2add1a47db46ce16884346afde53f8a064c02c891/mcp-1.29.0.tar.gz", hash = "sha256:52d01f334de1868cc3bb2d6604931126a67631f99a6c5d3b82ba47290315ec36", size = 643148, upload-time = "2026-07-28T13:41:41.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/c8/248b201f6d753d69fd5d6506011abbb35a946d9142b2ae311a948fd0be3d/mcp-1.29.0-py3-none-any.whl", hash = "sha256:f5a075bb611f23d6f4d080c6a1699fa62772eebc562ba9e66b306ddde1c755f7", size = 223436, upload-time = "2026-07-28T13:41:40.337Z" }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, +] + +[[package]] +name = "more-itertools" +version = "11.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/de/1d/f4da6f02cdffe04d6362210b807146a26044c88d839208aec273bb0d9184/more_itertools-11.1.0.tar.gz", hash = "sha256:48e8f4d9e7e5878571ecf6f2b4e57634f93cd474cc8cfbd2376f2d11b396e30d", size = 145772, upload-time = "2026-05-22T14:14:29.909Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/3d/1087453384dbde46a8c7f9356eead2c58be8a7bf156bca40243377c85715/more_itertools-11.1.0-py3-none-any.whl", hash = "sha256:4b65538ae22f6fed0ce4874efd317463a7489796a0939fa66824dd542125a192", size = 72226, upload-time = "2026-05-22T14:14:28.824Z" }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.12'", +] +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807, upload-time = "2026-05-18T23:37:14.07Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194, upload-time = "2026-05-18T23:33:13.503Z" }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111, upload-time = "2026-05-18T23:33:17.795Z" }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159, upload-time = "2026-05-18T23:33:20.654Z" }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936, upload-time = "2026-05-18T23:33:22.987Z" }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692, upload-time = "2026-05-18T23:33:26.62Z" }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164, upload-time = "2026-05-18T23:33:29.955Z" }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877, upload-time = "2026-05-18T23:33:34.724Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487, upload-time = "2026-05-18T23:33:38.217Z" }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945, upload-time = "2026-05-18T23:33:41.331Z" }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406, upload-time = "2026-05-18T23:33:44.131Z" }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528, upload-time = "2026-05-18T23:33:50.725Z" }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119, upload-time = "2026-05-18T23:33:54.065Z" }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246, upload-time = "2026-05-18T23:33:57.621Z" }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410, upload-time = "2026-05-18T23:34:00.302Z" }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240, upload-time = "2026-05-18T23:34:02.852Z" }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012, upload-time = "2026-05-18T23:34:05.485Z" }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538, upload-time = "2026-05-18T23:34:09.265Z" }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706, upload-time = "2026-05-18T23:34:13.053Z" }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541, upload-time = "2026-05-18T23:34:17.024Z" }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825, upload-time = "2026-05-18T23:34:20.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687, upload-time = "2026-05-18T23:34:23.095Z" }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482, upload-time = "2026-05-18T23:34:25.876Z" }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648, upload-time = "2026-05-18T23:34:29.41Z" }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902, upload-time = "2026-05-18T23:34:33.013Z" }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992, upload-time = "2026-05-18T23:34:36.132Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944, upload-time = "2026-05-18T23:34:38.484Z" }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392, upload-time = "2026-05-18T23:34:41.257Z" }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220, upload-time = "2026-05-18T23:34:45.075Z" }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800, upload-time = "2026-05-18T23:34:49.065Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600, upload-time = "2026-05-18T23:34:52.709Z" }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134, upload-time = "2026-05-18T23:34:55.618Z" }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598, upload-time = "2026-05-18T23:34:58.928Z" }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272, upload-time = "2026-05-18T23:35:02.167Z" }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197, upload-time = "2026-05-18T23:35:05.468Z" }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287, upload-time = "2026-05-18T23:35:08.693Z" }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763, upload-time = "2026-05-18T23:35:11.459Z" }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070, upload-time = "2026-05-18T23:35:14.79Z" }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752, upload-time = "2026-05-18T23:35:18.836Z" }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024, upload-time = "2026-05-18T23:35:22.52Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398, upload-time = "2026-05-18T23:35:26.398Z" }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971, upload-time = "2026-05-18T23:35:29.387Z" }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532, upload-time = "2026-05-18T23:35:32.175Z" }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881, upload-time = "2026-05-18T23:35:35.465Z" }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458, upload-time = "2026-05-18T23:35:38.353Z" }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559, upload-time = "2026-05-18T23:35:42.14Z" }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716, upload-time = "2026-05-18T23:35:45.377Z" }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947, upload-time = "2026-05-18T23:35:47.926Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197, upload-time = "2026-05-18T23:35:50.863Z" }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245, upload-time = "2026-05-18T23:35:54.752Z" }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587, upload-time = "2026-05-18T23:35:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226, upload-time = "2026-05-18T23:36:02.845Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196, upload-time = "2026-05-18T23:36:05.92Z" }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334, upload-time = "2026-05-18T23:36:09.107Z" }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678, upload-time = "2026-05-18T23:36:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672, upload-time = "2026-05-18T23:36:16.473Z" }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731, upload-time = "2026-05-18T23:36:19.767Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805, upload-time = "2026-05-18T23:36:22.266Z" }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496, upload-time = "2026-05-18T23:36:25.713Z" }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616, upload-time = "2026-05-18T23:36:29.652Z" }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145, upload-time = "2026-05-18T23:36:33.449Z" }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813, upload-time = "2026-05-18T23:36:37.369Z" }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982, upload-time = "2026-05-18T23:36:40.817Z" }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908, upload-time = "2026-05-18T23:36:43.996Z" }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867, upload-time = "2026-05-18T23:36:47.114Z" }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511, upload-time = "2026-05-18T23:36:50.673Z" }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064, upload-time = "2026-05-18T23:36:53.879Z" }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157, upload-time = "2026-05-18T23:36:57.194Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728, upload-time = "2026-05-18T23:36:59.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374, upload-time = "2026-05-18T23:37:02.674Z" }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286, upload-time = "2026-05-18T23:37:06.327Z" }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263, upload-time = "2026-05-18T23:37:09.715Z" }, +] + +[[package]] +name = "numpy" +version = "2.5.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform != 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'win32'", +] +sdist = { url = "https://files.pythonhosted.org/packages/22/fd/89965aa4ac08c74998539fcbf24fa3540f3e15237fbeb6bcf9c908f4aade/numpy-2.5.1.tar.gz", hash = "sha256:a48a113e6afea91f5608793bafa7ef2ad481fefbda87ec5069f483de61cb9fa3", size = 20755553, upload-time = "2026-07-04T17:08:00.933Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/7b/14687aa674250e5e546f616f486b0d56d3631cd5b2415739141ce40bdcea/numpy-2.5.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2c889b56fe48b1018f764b0eec8df59ab654e9148aa91faa12596043500de277", size = 16801574, upload-time = "2026-07-04T17:06:12.423Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/cc5bb2a3f2913d27d6dbb2c78d25921fabaedc6741d4a5a615a11f3c5bf3/numpy-2.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ab451b59c5643c570974c43aef780703ef1d3b4965d2be07afd530615a9358d1", size = 11772250, upload-time = "2026-07-04T17:06:15.726Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/fdf34a71dd30f54979b18603bee915e0aaf825b07afe79acd60b04b691e2/numpy-2.5.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:78798bd5b9ad744056af8efa90e3b9ddaa53272a0848a483084a1cc0a13b2dc0", size = 5331516, upload-time = "2026-07-04T17:06:17.913Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e2/eb7efa015b4cce41e2517bf182a7fce0d7d5b9d9ed76a29bfa0f4fe4505c/numpy-2.5.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:2ae0ca40bcb22d6ba59c1dfd5446f49940b0f2d821fde133f10dda11f816b84e", size = 6664863, upload-time = "2026-07-04T17:06:20.02Z" }, + { url = "https://files.pythonhosted.org/packages/a9/4b/a2b32dd94ee9ffbeecb28152240042a3949db33b1c834d44090b80e1b3b8/numpy-2.5.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:61ac47e772e6b8ea489e1d2f441a34c5c3ac17327e7ce294cbdf535795ad4e75", size = 15167977, upload-time = "2026-07-04T17:06:21.621Z" }, + { url = "https://files.pythonhosted.org/packages/b8/a9/6e73d68500f80773f65f0654ea932019d6694329a0eb0ed0533de38df376/numpy-2.5.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:59fda5e192b570217ec2580c96f00e9a7e12ef6866a900eb089b62c1a32545ca", size = 16672469, upload-time = "2026-07-04T17:06:24.064Z" }, + { url = "https://files.pythonhosted.org/packages/24/7d/ad3e59015135f5261c95fd4cafeff159c955febd83a99a1d9250c4233815/numpy-2.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f7119ebff1a9829e9f431a4f9d28e703023bb6b9fe7c8f724467dbfc27c94ab3", size = 16527531, upload-time = "2026-07-04T17:06:26.69Z" }, + { url = "https://files.pythonhosted.org/packages/83/d0/a39b2fbcde9cb17a1dac678f254b33a6336298af9df338824c685425d5e8/numpy-2.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e824c2acf8862052246be5a44c15da1777940c60d010dd2aab897824d9c430f9", size = 18431940, upload-time = "2026-07-04T17:06:29.521Z" }, + { url = "https://files.pythonhosted.org/packages/04/12/cff070947791c1ed425ff76413189adbdc2fbe215eba7ce7fa454a03c7f8/numpy-2.5.1-cp312-cp312-win32.whl", hash = "sha256:08d60c810432eb83360958dea0999ac4cfb94531ea8efcbf0b7f277c2068aeb2", size = 6066764, upload-time = "2026-07-04T17:06:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/65/66/53f31807a48a750f9d748da273bc3fcedd12b27ff1f3e373bfec55ef2dc0/numpy-2.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:f7d60026c0bdb1380e83bfa7a0419c4577ee4b9a08880afcb6dadeb74c649fa2", size = 12430966, upload-time = "2026-07-04T17:06:34.926Z" }, + { url = "https://files.pythonhosted.org/packages/2b/2a/d1a88066b1c14186f5d3c0d18c94f17b064511982bab0578d49ee9d43c29/numpy-2.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:17a25e09640602e10bc8de0e6fa2b3fd68eedd84ba6d7842dc8f32f9ab87bd0b", size = 10350488, upload-time = "2026-07-04T17:06:37.785Z" }, + { url = "https://files.pythonhosted.org/packages/eb/07/ec2a3f0c91761581d4b7104a740791800025983f9a4dc4e73f91a99aeac4/numpy-2.5.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0bfebd8695f9863592fe744be833a258120b14a9f39da255e8aa8fade2c0ddd1", size = 16796419, upload-time = "2026-07-04T17:06:40.37Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ab/ddb499fc4f8780354395face5b65c7fd107bcd6e1d667a5f07d046956f6f/numpy-2.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:30b44a6b53a7ae63c54c089a8726e5563ed302716c5b7ccc85afade40b0e7ff6", size = 11765832, upload-time = "2026-07-04T17:06:42.768Z" }, + { url = "https://files.pythonhosted.org/packages/88/b3/3c28c558a09fc72100c646dac6d2fce8e834c471b0edca01a29996706117/numpy-2.5.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:6165343f81b56ef8f514f396989e529b61d9dc709b99421b07e9f3e698e2287d", size = 5325143, upload-time = "2026-07-04T17:06:45.466Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0e/ce19b985bb15c596f4f05954e76cccc77c845083b3b8f938a6c68e523128/numpy-2.5.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4939237038ada79308dda3204ac6462df056b5672b2e25db1149cf873668b3e1", size = 6659749, upload-time = "2026-07-04T17:06:47.288Z" }, + { url = "https://files.pythonhosted.org/packages/2e/20/1ee6614d64332a1bba6411f38e68cb79eec1b2459e20a623777c5c5492a2/numpy-2.5.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c6759f538fb912fc46de0a6b1758ccf7b57bc7c7ebebc23974fdac3de8db0cd", size = 15164716, upload-time = "2026-07-04T17:06:49.494Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a7/2bcd3fdbb87804755c35b729bf8709d62025c5f4cfd7d5b2415997097515/numpy-2.5.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9726558e8db4a5bf7929a70ae50f63abda4daf0efe810e3bfbab95976f75fc1a", size = 16661440, upload-time = "2026-07-04T17:06:52.061Z" }, + { url = "https://files.pythonhosted.org/packages/fc/d7/a41e3310c886fe457d36e670bbf24fae411aca8a7b6ad92a32afd924077c/numpy-2.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3935f3b419b244a02732676fa5317a9193cc596a4c0646db07e5b421229ac9f7", size = 16526305, upload-time = "2026-07-04T17:06:54.605Z" }, + { url = "https://files.pythonhosted.org/packages/53/75/4333a9a707c1edd3a4e1a0c58eca52c0f31e55089fa80db02b5565b24df7/numpy-2.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dc932a65ded7ce9013d120845a2514dcccb1a67bfc8deb8d37633762951904a6", size = 18423008, upload-time = "2026-07-04T17:06:57.54Z" }, + { url = "https://files.pythonhosted.org/packages/ee/90/e314a32b1c11a2ffe818ddad3a57b50b4b6e1b6c487192eb50cdef0415d0/numpy-2.5.1-cp313-cp313-win32.whl", hash = "sha256:4b4ff1608417eb7a59da7b967bbb798cacfe071d2caf526a24281cd562072ed9", size = 6063885, upload-time = "2026-07-04T17:07:00.14Z" }, + { url = "https://files.pythonhosted.org/packages/10/70/800b3fca480af32df9e8ea9f3d4a0c8feb4b32d7f195d174eabbda4829ad/numpy-2.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:6c3fe51bc6a16453d452997053454f309e8e0ed7b42d6b361ce4ac8c32913d74", size = 12425674, upload-time = "2026-07-04T17:07:02.387Z" }, + { url = "https://files.pythonhosted.org/packages/8b/0b/196350c122f50f6ca56846f2d71efd5e0d24b7b2e07355e019b2e2c7a11e/numpy-2.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f7feb014281029e628ba2d5a007407443b06e418b6fe451d1e2adcbc8eba0107", size = 10350256, upload-time = "2026-07-04T17:07:04.878Z" }, + { url = "https://files.pythonhosted.org/packages/db/f4/731b6085a83faf6ca843394cbd5e217280c214399f7e8b21b9f552af0ae2/numpy-2.5.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7c786fe9a5bbe360022e584c5a34cf6b54265c71bd7ec8ac3d8fec38968071f8", size = 16795063, upload-time = "2026-07-04T17:07:07.374Z" }, + { url = "https://files.pythonhosted.org/packages/bf/64/0e215f2048dd11a55bb989ed41b3585ef57452404e638d703a211a3e4157/numpy-2.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32985c896d897419ef8da6917872d80b78ad0ea26d85b23245c7366ffde76d75", size = 11776652, upload-time = "2026-07-04T17:07:09.907Z" }, + { url = "https://files.pythonhosted.org/packages/b5/59/2b844c7a6e9deff69b404a66221e1542937734f65d5e6e39411876053862/numpy-2.5.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:efd736408cc97c79b9e6917338dfc8f06013b2274f992e96b1d9a81a71e2a2c2", size = 5335944, upload-time = "2026-07-04T17:07:12.227Z" }, + { url = "https://files.pythonhosted.org/packages/86/51/9bf7cb2cabcebc9e017e4ec7e6322b378317a542c08b4cb68479c1efc716/numpy-2.5.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:ab84dc6b074fa881cae55bea94cc4f68e285181ba7f32497bf7dee6b1496165b", size = 6656266, upload-time = "2026-07-04T17:07:14.368Z" }, + { url = "https://files.pythonhosted.org/packages/83/3e/fb7615b211b82a32f44d5180a6d421b61f84d4fadd578b48ba4ac34e189f/numpy-2.5.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caf3e317d33d60c37986b452613f4ab51246d0691350c03d0cb4a898627f4a95", size = 15179720, upload-time = "2026-07-04T17:07:16.272Z" }, + { url = "https://files.pythonhosted.org/packages/41/5f/0f992cb24560673496c5d68de61913b57166ce530ffda07c1f280e0cc464/numpy-2.5.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54ad769f17bc2d833b620851989f62054fb9ab93c969d9e1dc3c8e3d56beea21", size = 16664835, upload-time = "2026-07-04T17:07:19.021Z" }, + { url = "https://files.pythonhosted.org/packages/a2/2f/97d6475ee91afe2587797d09446f9d3e475ad4cb681662d824809327b75a/numpy-2.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c12afb53450fa976d4c681c50a7423729a4c51c0465ed9f32b8a9cabbc472373", size = 16539135, upload-time = "2026-07-04T17:07:22.015Z" }, + { url = "https://files.pythonhosted.org/packages/c4/5b/4db81e4ba0be7e2776b1de68c82aa862c7f8ec27e1b4927d4ae075e20678/numpy-2.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e8c11c405efc5ff6816d5983c96cdfa215bab3428961243af3ff59b228490438", size = 18426684, upload-time = "2026-07-04T17:07:24.941Z" }, + { url = "https://files.pythonhosted.org/packages/1f/64/c0ba2d90724d450279a7df8f32057241070250a26a7e2b5337d77347f481/numpy-2.5.1-cp314-cp314-win32.whl", hash = "sha256:f2479a47f8d5932d1718168a681ad6e536a9df484c83cfcf9de365e164537ace", size = 6116103, upload-time = "2026-07-04T17:07:27.622Z" }, + { url = "https://files.pythonhosted.org/packages/c1/1a/837f9ed7405adcd7a40538792eb169eddd8fa5630c16a1ef49dae71a30f4/numpy-2.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:24d0eb82c0541d3415a33425db64ae439dffccd7b4dbcb30e7c35120205c506a", size = 12562177, upload-time = "2026-07-04T17:07:29.887Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/49707938b6dd0a78a9178dd93227dc89e4c11af47f5c798d70366e8d0483/numpy-2.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:5a4c988b38d261deeeaad9954e3deb091ad905c94e8bb6708654ef1d97f286b0", size = 10627739, upload-time = "2026-07-04T17:07:32.568Z" }, + { url = "https://files.pythonhosted.org/packages/a6/c7/bb4b882cfe7f299cbc8b66e42e7dd78cf9d14e40f9469fc5e3db7e15b3bd/numpy-2.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a33276be12fa045805f477f22482088b66bb758ffbe89a9d21457de863a32e22", size = 11894709, upload-time = "2026-07-04T17:07:34.941Z" }, + { url = "https://files.pythonhosted.org/packages/40/3f/5af7f4a7f6224aef48017aa82bb6174c7a659d724be0c75017b7e64a55b4/numpy-2.5.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f089d7b00756190aacf1f5d34bdf38c3c430ac82b4f868f8cede73380460fce7", size = 5453810, upload-time = "2026-07-04T17:07:37.495Z" }, + { url = "https://files.pythonhosted.org/packages/20/c9/3474309bc94d634d3f9c3eddf03250ecb8c22cd948ef16fef69a77cc5d7b/numpy-2.5.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:09e9bfd8d2cf479c7d174804fb3811c53a8e9f20a37444008606b57d6b7a826d", size = 6761189, upload-time = "2026-07-04T17:07:39.563Z" }, + { url = "https://files.pythonhosted.org/packages/90/8a/558ae39fdd55d7e7f7fef9a84a6e964ac6b23edbd2a07e52bb084500507d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e68d8dd1e7eba712948f2053a29ec86917bc70ba1358df869d9f06649ef9cf09", size = 15225039, upload-time = "2026-07-04T17:07:41.682Z" }, + { url = "https://files.pythonhosted.org/packages/63/27/ca7392b2d030277bdf0273e7d23255b3ee57d57a7c170a6f4fb3981e1e5d/numpy-2.5.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99d5095fa265a0c4152e7bb12759e14381ef5496152f1ce58f44bdf55c44beb4", size = 16701306, upload-time = "2026-07-04T17:07:44.611Z" }, + { url = "https://files.pythonhosted.org/packages/02/42/03d53ae7996c44d4374a8262e9dc41671fd56cbb98f7d47ef85cf5da4c6b/numpy-2.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ab87a91b3cc3382b8956095bd8f95e00cf679bb81554339be1a2ba404a1473c1", size = 16589955, upload-time = "2026-07-04T17:07:47.694Z" }, + { url = "https://files.pythonhosted.org/packages/7b/15/6c1784ae469640e65db111e9a34b3d0f14d91e8a38b9ce34810ced370dbb/numpy-2.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:224ca51130ef7da85bea2191625181cb4f337f9cb64b471f10c1a12aa8b60077", size = 18464252, upload-time = "2026-07-04T17:07:50.684Z" }, + { url = "https://files.pythonhosted.org/packages/94/a8/f98e50356cf167df656c526c2dfeec2d7dde182f2a3da4b458a5938e2776/numpy-2.5.1-cp314-cp314t-win32.whl", hash = "sha256:6eab239876581b2b3c5a242281b6007bbdbcd1c7085d7709bb57c5929b11e6bf", size = 6263298, upload-time = "2026-07-04T17:07:53.445Z" }, + { url = "https://files.pythonhosted.org/packages/72/ac/96ae880cdecad0b3275d9359fcec72667b49a4863c9f12942e43679dda02/numpy-2.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:83ce9c80d5b521b0d77ddcbe5447c218d247929b6cc056ca5351342accfff0af", size = 12748623, upload-time = "2026-07-04T17:07:55.384Z" }, + { url = "https://files.pythonhosted.org/packages/a1/5a/4d2b1601df3602dba7a14f3348ba9bfe94a18adb428e693df6154c293831/numpy-2.5.1-cp314-cp314t-win_arm64.whl", hash = "sha256:5a6db61f9aaa57e369905c67d852045d3c4f7126405b29d09b19dec118e9c9cb", size = 10697674, upload-time = "2026-07-04T17:07:58.506Z" }, +] + +[[package]] +name = "openapi-pydantic" +version = "0.5.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/02/2e/58d83848dd1a79cb92ed8e63f6ba901ca282c5f09d04af9423ec26c56fd7/openapi_pydantic-0.5.1.tar.gz", hash = "sha256:ff6835af6bde7a459fb93eb93bb92b8749b754fc6e51b2f1590a19dc3005ee0d", size = 60892, upload-time = "2025-01-08T19:29:27.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/cf/03675d8bd8ecbf4445504d8071adab19f5f993676795708e36402ab38263/openapi_pydantic-0.5.1-py3-none-any.whl", hash = "sha256:a3a09ef4586f5bd760a8df7f43028b60cafb6d9f61de2acba9574766255ab146", size = 96381, upload-time = "2025-01-08T19:29:25.275Z" }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.44.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ee/8b/aa9e2d8b8dfa7c946f7dec5d1f8f6ba8eca062f43509a06bdb5ce93d26c0/opentelemetry_api-1.44.0.tar.gz", hash = "sha256:67647e5e9566edcf421166fdf022b3537f818635daa852b289e34604dc6fb33a", size = 72406, upload-time = "2026-07-16T15:25:32.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/6f/a04e900f465ff3221ccc395522503e2d10e79fa21f2723c8e177aae1e0d1/opentelemetry_api-1.44.0-py3-none-any.whl", hash = "sha256:94b98c893a91b88657eaac1e3ba89618cdb85be6918196705354f34728b2cdef", size = 60018, upload-time = "2026-07-16T15:25:11.657Z" }, +] + +[[package]] +name = "packaging" +version = "26.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" }, +] + +[[package]] +name = "pathable" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/f3/5a20387de9bcd0607871bfc2198ee0e15836da7baa4592ccd7f24c27c986/pathable-0.6.0.tar.gz", hash = "sha256:6404b8b82aef5ff0fd478934137128b99b12212ba35afdde5525ca4f8388ea58", size = 18970, upload-time = "2026-05-19T18:15:11.911Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/e8/6d75ffd9784bce2e93d1ae4415649427e39a53bb172d4672b2b59c6f0a7b/pathable-0.6.0-py3-none-any.whl", hash = "sha256:82c4ca6c98c502ad12e0d4e9779b6210afee93c38990988c8c5d1b49bdcdf566", size = 18983, upload-time = "2026-05-19T18:15:10.728Z" }, +] + +[[package]] +name = "pillow" +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, +] + +[[package]] +name = "platformdirs" +version = "4.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "py-key-value-aio" +version = "0.4.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "beartype" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fb/e2/d689d922894a7ecde73b6daeaf9b13dab5aae06fe6aaaf7514722644d382/py_key_value_aio-0.4.5.tar.gz", hash = "sha256:c6563a2c6abe5da5e20f4f9e875c2a9b425a2244a54fadbf46cf140a9eea45d7", size = 107547, upload-time = "2026-05-27T16:37:08.107Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f6/95/b8ba862968712caa12a19666175334fa979e1f198b896a430adb3bacfe87/py_key_value_aio-0.4.5-py3-none-any.whl", hash = "sha256:ab862adbcb8c72547d1c57821f22cbbb71ab86509039c96f36e914e0336c8dd7", size = 170005, upload-time = "2026-05-27T16:37:06.629Z" }, +] + +[package.optional-dependencies] +filetree = [ + { name = "aiofile" }, + { name = "anyio" }, +] +keyring = [ + { name = "keyring" }, +] +memory = [ + { name = "cachetools" }, +] + +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[package.optional-dependencies] +email = [ + { name = "email-validator" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + +[[package]] +name = "pydantic-settings" +version = "2.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/b5/8f48e906c3e0205276e8bd8cb7512217a87b2685304d64be27cad5b3019f/pydantic_settings-2.14.2.tar.gz", hash = "sha256:c19dd64b19097f1de80184f0cc7b0272a13ae6e170cbf240a3e27e381ed14a5f", size = 237700, upload-time = "2026-06-19T13:44:56.324Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/c1/6e422f34e569cf8e18df68d1939c81c099d2b61e4f7d9621c8a77560799c/pydantic_settings-2.14.2-py3-none-any.whl", hash = "sha256:a20c97b37910b6550d5ea50fbcc2d4187defe58cd57070b73863d069419c9440", size = 61715, upload-time = "2026-06-19T13:44:55.02Z" }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, +] + +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + +[[package]] +name = "pyparsing" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, +] + +[[package]] +name = "pyperclip" +version = "1.11.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/52/d87eba7cb129b81563019d1679026e7a112ef76855d6159d24754dbd2a51/pyperclip-1.11.0.tar.gz", hash = "sha256:244035963e4428530d9e3a6101a1ef97209c6825edab1567beac148ccc1db1b6", size = 12185, upload-time = "2025-09-26T14:40:37.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/80/fc9d01d5ed37ba4c42ca2b55b4339ae6e200b456be3a1aaddf4a9fa99b8c/pyperclip-1.11.0-py3-none-any.whl", hash = "sha256:299403e9ff44581cb9ba2ffeed69c7aa96a008622ad0c46cb575ca75b5b84273", size = 11063, upload-time = "2025-09-26T14:40:36.069Z" }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, +] + +[[package]] +name = "pytest-cov" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "coverage", extra = ["toml"] }, + { name = "pluggy" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/51/a849f96e117386044471c8ec2bd6cfebacda285da9525c9106aeb28da671/pytest_cov-7.1.0.tar.gz", hash = "sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2", size = 55592, upload-time = "2026-03-21T20:11:16.284Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/7a/d968e294073affff457b041c2be9868a40c1c71f4a35fcc1e45e5493067b/pytest_cov-7.1.0-py3-none-any.whl", hash = "sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678", size = 22876, upload-time = "2026-03-21T20:11:14.438Z" }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" }, +] + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "python-multipart" +version = "0.0.32" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5b/42/55c32bb9b12693c092ad250a0e82edb5b31ddeda6eb772de5f308b3804ad/python_multipart-0.0.32.tar.gz", hash = "sha256:be54b7f3fa167bb83e4fcd936b887b708f4e57fe75911c02aebf53efaf8d938e", size = 46881, upload-time = "2026-06-04T16:18:58.647Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/04/e8135ebd1ad02c56ec633277529b2602ff99ff634be76cdba5744cf554fd/python_multipart-0.0.32-py3-none-any.whl", hash = "sha256:ff6d3f776f16878c894e52e107296ffc890e913c611b1a4ec6c44e2821fe2e23", size = 30042, upload-time = "2026-06-04T16:18:57.319Z" }, +] + +[[package]] +name = "pywin32" +version = "312" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/f5/10a6e845a00fc5e7afd0a988b744f403d4d57162a28d160a093c4d9322f0/pywin32-312-cp311-cp311-win32.whl", hash = "sha256:17948aeadbdb091f0ced6ef0841620794e68327b94ee415571c1203594b7215c", size = 6362659, upload-time = "2026-06-04T07:49:21.349Z" }, + { url = "https://files.pythonhosted.org/packages/35/c4/dcd2d62b5944b6d5db53413a5899016ccd57ffcb7278f3f81655d25d2027/pywin32-312-cp311-cp311-win_amd64.whl", hash = "sha256:d11417d84412f859b722fad0841b3614459ed0047f7542d8362e77884f6b6e8a", size = 6928825, upload-time = "2026-06-04T07:49:23.934Z" }, + { url = "https://files.pythonhosted.org/packages/b7/56/3cbb433fe4501cdba2eb9040f56a4e1a8243faa4186b25295564d1a7a79d/pywin32-312-cp311-cp311-win_arm64.whl", hash = "sha256:b2200a054ca6d6625c4842fc56a4976a4b47f96b73dbe5538c3f813a80359f47", size = 6721875, upload-time = "2026-06-04T07:49:26.416Z" }, + { url = "https://files.pythonhosted.org/packages/83/ff/32aa7d2ed0ab12b323aaa64f9b75e6ad4f8fd09f9ccfc28c79414d46838d/pywin32-312-cp312-cp312-win32.whl", hash = "sha256:dab4f65ac9c4e48400a2a0530c46c3c579cd5905ecd11b80692373915269208b", size = 6371877, upload-time = "2026-06-04T07:49:28.836Z" }, + { url = "https://files.pythonhosted.org/packages/03/d9/77040d3b43df3f3be32ea289433d660d2727f5ba327bc73be835127d9d60/pywin32-312-cp312-cp312-win_amd64.whl", hash = "sha256:b457f6d628a47e8a7346ce22acb7e1a46a4a78b52e1d17e1af56871bd19a93bc", size = 6914841, upload-time = "2026-06-04T07:49:31.85Z" }, + { url = "https://files.pythonhosted.org/packages/e3/cc/7b1ec671775756020a0ee7f4feeaf3c568f0ab86bd3900088cf986937a92/pywin32-312-cp312-cp312-win_arm64.whl", hash = "sha256:6017c58e12f6809fbb0555b75df144c2922a9ffd18e4b9b5afa863b6c1a9d950", size = 6727901, upload-time = "2026-06-04T07:49:34.244Z" }, + { url = "https://files.pythonhosted.org/packages/2d/41/12fbfd7f36ed2146d8bc9de96c2741296bf0d490b98508496cff322e274c/pywin32-312-cp313-cp313-win32.whl", hash = "sha256:7a27df850933d16a8eabfbaeb73d52b273e2da667f80d70b01a89d1f6828d02c", size = 6370184, upload-time = "2026-06-04T07:49:36.253Z" }, + { url = "https://files.pythonhosted.org/packages/ba/db/36a78e3403099d31d9746d13fdcde5accc43c1155f375a34d15983a479a7/pywin32-312-cp313-cp313-win_amd64.whl", hash = "sha256:c53e878d15a1c44788082bfe712a905433473aa38f86375b7cf8b45e3acbaaf9", size = 6914298, upload-time = "2026-06-04T07:49:38.876Z" }, + { url = "https://files.pythonhosted.org/packages/84/37/c1697194092b76de9ed47ca124323f02c57ffc8a45c06f88a3d5acaf01eb/pywin32-312-cp313-cp313-win_arm64.whl", hash = "sha256:59aba5d5940842075343a5ddc6b11f1cdf0d1567fe745290359dfbcc7c2eb831", size = 6727640, upload-time = "2026-06-04T07:49:41.083Z" }, + { url = "https://files.pythonhosted.org/packages/fc/2b/1f3cded5822fd49c02f40544cbb5f58c7cfd6b1694869fd476cb6170ee97/pywin32-312-cp314-cp314-win32.whl", hash = "sha256:a77a90fbb6881238d2ca9c6fd797b25817f3768fe78d214a90137ff055a75f5b", size = 6468928, upload-time = "2026-06-04T07:49:43.188Z" }, + { url = "https://files.pythonhosted.org/packages/21/82/3bf86d2e2808902013132e1ce905a7da0da53790f3836c64bf44d55e24f3/pywin32-312-cp314-cp314-win_amd64.whl", hash = "sha256:a4dd3a848290ef724347b19f301045831d8e802fa4464f491b98b1e0a081432e", size = 7024157, upload-time = "2026-06-04T07:49:45.34Z" }, + { url = "https://files.pythonhosted.org/packages/a4/0e/73f6d6800b4f27655abd9e9f6aaeaefcddb2b946e4674efa2bab184a7f7b/pywin32-312-cp314-cp314-win_arm64.whl", hash = "sha256:9fce94568364e0155e6dfb781ac5d95903be8baf28670632beab1b523f300daa", size = 6839598, upload-time = "2026-06-04T07:49:47.613Z" }, + { url = "https://files.pythonhosted.org/packages/eb/61/caa39686032d2ebdd04ff0ab5cbe163126c0066d98e00c9018646e42393b/pywin32-312-cp315-cp315-win32.whl", hash = "sha256:5c1fbe4a937a73ae9297384a3da38518cbc694c68ad8a809b2e19acd350f03ed", size = 6471159, upload-time = "2026-06-04T07:49:50.035Z" }, + { url = "https://files.pythonhosted.org/packages/0f/cd/7e1de64a4a6f69c04214169657ccab0d93a670ea50e35eb8f489d7378249/pywin32-312-cp315-cp315-win_amd64.whl", hash = "sha256:c2f03a0f73f804a13c2735b99392b0cd426bb4f2c4d0178e5ac966a0f21618d5", size = 7025293, upload-time = "2026-06-04T07:49:54.857Z" }, + { url = "https://files.pythonhosted.org/packages/23/ed/4532e9388e65fa16b46776ef47ad631a64eda1631884488af707666350ed/pywin32-312-cp315-cp315-win_arm64.whl", hash = "sha256:a8597d28f267b39074aef51fa593530082b39cbe5a074226096857b1fed2dfb9", size = 6840337, upload-time = "2026-06-04T07:49:57.531Z" }, +] + +[[package]] +name = "pywin32-ctypes" +version = "0.2.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/85/9f/01a1a99704853cb63f253eea009390c88e7131c67e66a0a02099a8c917cb/pywin32-ctypes-0.2.3.tar.gz", hash = "sha256:d162dc04946d704503b2edc4d55f3dba5c1d539ead017afa00142c38b9885755", size = 29471, upload-time = "2024-08-14T10:15:34.626Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/de/3d/8161f7711c017e01ac9f008dfddd9410dff3674334c233bde66e7ba65bbf/pywin32_ctypes-0.2.3-py3-none-any.whl", hash = "sha256:8a1513379d709975552d202d942d9837758905c8d01eb82b8bcc30918929e7b8", size = 30756, upload-time = "2024-08-14T10:15:33.187Z" }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, +] + +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, +] + +[[package]] +name = "rich-rst" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pygments" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e2/d6/d0b9fafc73b65767200da027acab1db1bdb1048f4fea5ebf659df01c700e/rich_rst-2.1.0.tar.gz", hash = "sha256:f4d117b49697f338769759fa5cacf5197da4888b347b9fda2e50aef5cd8d93bd", size = 302732, upload-time = "2026-07-05T02:59:44.308Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/68/1fc93dd759605b5d00fc98b50200739e41ed32bd22d6ba35ca6c3932371b/rich_rst-2.1.0-py3-none-any.whl", hash = "sha256:7ecd1343ee12c879d0e7ae74c3eb6d263b023d2929c6d114212eb1fd91057255", size = 272987, upload-time = "2026-07-05T02:59:42.792Z" }, +] + +[[package]] +name = "rpds-py" +version = "2026.6.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, +] + +[[package]] +name = "ruff" +version = "0.16.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/70/25/7113f6d5498888c5fb7db34081cba7d5971c4cb1bfb26819966eee68f003/ruff-0.16.1.tar.gz", hash = "sha256:fedad7c801dabd3fb9741d76aca39246e6ddd9ca446a015875207bf19f1e6bc7", size = 4877500, upload-time = "2026-07-30T19:37:01.379Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/bd/694da69368e0973de65df2ddc73ab18d43c469d5963d9b150911de6bc513/ruff-0.16.1-py3-none-linux_armv6l.whl", hash = "sha256:58edb313b88f0c5460a26adf5f39a37a3be789494a15e3e411e35fa78b89f9a0", size = 10839126, upload-time = "2026-07-30T19:36:13.697Z" }, + { url = "https://files.pythonhosted.org/packages/3f/f0/b626e5d5bd0dd9576263658ef12885e2288afd1029a48e26ffed65ec1ac1/ruff-0.16.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fde5a99e2f97479af66edd6622c6d5a2a7592c77cf4153d9e4428f5eeb55b60c", size = 11070253, upload-time = "2026-07-30T19:36:17.14Z" }, + { url = "https://files.pythonhosted.org/packages/83/63/f40acfb6b35b88623e71684942b552c3edd96035f5d98f313815f7b277de/ruff-0.16.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e0d4c20532fca4f7fa609369161d968dd28f65d83dabbd61d8e9c7edbf7001f6", size = 10561425, upload-time = "2026-07-30T19:36:20.04Z" }, + { url = "https://files.pythonhosted.org/packages/aa/dd/14ec0e9c2b4d315547dd38765004b4863e354e1b52cb308272215d9f6f6d/ruff-0.16.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:30affbcedf59ad5703d9c91f82266e02b47739f797e1a7b6e158e5526a6dae38", size = 10948879, upload-time = "2026-07-30T19:36:22.476Z" }, + { url = "https://files.pythonhosted.org/packages/33/e9/9d870cbae575030fdef595f04b4b97573c525b5497cce4f4498cf2f85446/ruff-0.16.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:24e9c631573cbca9d20f1283f8f479b2afa4a8503504822bd71a293889f16743", size = 10643691, upload-time = "2026-07-30T19:36:24.914Z" }, + { url = "https://files.pythonhosted.org/packages/c4/09/12743d544e2173f53ecd27217c65f90d2bc0f8424a66a60339e56bbc0457/ruff-0.16.1-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b41bdd48fb420987a9b5212e4957c26ad4abce401fa9ea9d4d85843727945f4f", size = 11435354, upload-time = "2026-07-30T19:36:28.447Z" }, + { url = "https://files.pythonhosted.org/packages/7f/89/a1652b2daee52083c9554a6333b678a8b01d0400f976827bb87857f9449a/ruff-0.16.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0d1e1393b7648079e13669de1c1f4fde06d4583e84d8fd5c1551e0a77a2aa75", size = 12259033, upload-time = "2026-07-30T19:36:31.326Z" }, + { url = "https://files.pythonhosted.org/packages/16/96/ecdcb8c54ee7b123b487f807eb014e6e019155a0b81dfb669acd52f28ce3/ruff-0.16.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:07bf434b1c95f4e093be4532068ef4fcf00924eb2ade8796075980902d6fd54a", size = 11667981, upload-time = "2026-07-30T19:36:34.394Z" }, + { url = "https://files.pythonhosted.org/packages/cd/90/c52e12e0d862e9572f2a33aa227409143520abe53111e9a6babbac7b4af8/ruff-0.16.1-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:39897739f112253ee4fdd2e8aa9a4f9ded99fb2be367d5f31dfa4ded6025584c", size = 11468183, upload-time = "2026-07-30T19:36:37.339Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6b/4ffb7ad1d83eb16cf8cbb3c8815d3f11c88460fd162d4b372a2059be1c2a/ruff-0.16.1-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:82ae3c0c0d74daf17b968a10b7b3bb3ef297ab7de0c1f749646b25e690ccb150", size = 11470071, upload-time = "2026-07-30T19:36:39.91Z" }, + { url = "https://files.pythonhosted.org/packages/9c/72/32ae7db4c0b5e32ab611787caa19d1546800676d79f7483b7100a3561bf4/ruff-0.16.1-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:4d5f2ed10f8242d83fc08d521301089364e3375375705356f20c0e31606ef3ef", size = 10919503, upload-time = "2026-07-30T19:36:42.65Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ca/3d901ba6ad6fc38da39c3448fc6c59ac945679293a17c3ceb6d6c1cba13e/ruff-0.16.1-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a4665b309891f83f3e3c25447935f1213e9abbd4b5640af7a1f2def9f8d413c1", size = 10649861, upload-time = "2026-07-30T19:36:45.18Z" }, + { url = "https://files.pythonhosted.org/packages/92/79/894ef1ced26552d5f8c9cf6d85b0687840e1128c55aeab7b9c2d54a0d880/ruff-0.16.1-py3-none-musllinux_1_2_i686.whl", hash = "sha256:26e9ca5c9bc3971f20d3cf18a957f52ffd6a5f6564ff15c4912a144dcac22494", size = 11148137, upload-time = "2026-07-30T19:36:47.936Z" }, + { url = "https://files.pythonhosted.org/packages/2d/69/3609a09fa1cb46cc28b762363e440a354204e5dff01bd0c8d7437874d6b9/ruff-0.16.1-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:67e1e1e3fa4f0c82f0e36d4cd61e661f6e7a6196cb1aa92fe0828fa7b8f257cd", size = 11559211, upload-time = "2026-07-30T19:36:50.448Z" }, + { url = "https://files.pythonhosted.org/packages/fc/8a/fb22af2fd78a736e241fabf67e30ce1799a64244026377a49e133af90762/ruff-0.16.1-py3-none-win32.whl", hash = "sha256:d31765e131295b8445caf301e3e8a85b34d1b9b211b4109b7ba457888b051806", size = 10838258, upload-time = "2026-07-30T19:36:53.298Z" }, + { url = "https://files.pythonhosted.org/packages/d4/35/e57fd9fb5d423961df087a00b12d42c0a830288dc2f3b45ecca299158b4f/ruff-0.16.1-py3-none-win_amd64.whl", hash = "sha256:09b05e8b90c2cb06ad63464350e7a45e8e44a2dfe52072ebfba6666ca8d3f596", size = 11961111, upload-time = "2026-07-30T19:36:56.107Z" }, + { url = "https://files.pythonhosted.org/packages/cb/46/240ea004bf6dc4feb40e9832f2205a476a47dd5b8a3f8211a5fc5f95e20e/ruff-0.16.1-py3-none-win_arm64.whl", hash = "sha256:dbaadaac38c70239f056d306b7476f246b0bf000fa6b3876402acbf5b227eaf8", size = 11309414, upload-time = "2026-07-30T19:36:58.79Z" }, +] + +[[package]] +name = "secretstorage" +version = "3.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cryptography" }, + { name = "jeepney" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1c/03/e834bcd866f2f8a49a85eaff47340affa3bfa391ee9912a952a1faa68c7b/secretstorage-3.5.0.tar.gz", hash = "sha256:f04b8e4689cbce351744d5537bf6b1329c6fc68f91fa666f60a380edddcd11be", size = 19884, upload-time = "2025-11-23T19:02:53.191Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, +] + +[[package]] +name = "sse-starlette" +version = "3.4.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "starlette" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6c/10/a34c656829ffc1c4b22ef36d70d9ebb6b99c020e2aeb17cee5485099f028/sse_starlette-3.4.6.tar.gz", hash = "sha256:725f8a1bd6d26ae1b2c9610c0ef5065dfdd496f3988d28adcf8c4b49dc25c627", size = 32542, upload-time = "2026-07-20T14:16:32.201Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/36/e10c1d1b7ca881d2625db2ec28508578499187bb1c389952c398474e1834/sse_starlette-3.4.6-py3-none-any.whl", hash = "sha256:56217ab4c9a9f9c5db7b21e08732d3e7c2b807f45231ad23de0551a24c4a41f6", size = 16516, upload-time = "2026-07-20T14:16:30.978Z" }, +] + +[[package]] +name = "starlette" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/e3/7c1dc7381d9f8ab7d854328ebfa884e62cb3f3d8549ddfd37c7814f42afa/starlette-1.3.1.tar.gz", hash = "sha256:05d0213193f2fbaae60e2ecb593b4add4262ad4e46536b54abe36f11a71724e0", size = 2703240, upload-time = "2026-06-12T09:23:11.602Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/bb/2799cc2ede3ed41131f8975621e7213dfc7ef4acbbaadfa440f32500c370/starlette-1.3.1-py3-none-any.whl", hash = "sha256:c7372aae11c3c3f26a42df7bd626cec2f47d03483d261d369516a615a53714c6", size = 73632, upload-time = "2026-06-12T09:23:10.017Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + +[[package]] +name = "uncalled-for" +version = "0.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/82/345cc927f7fbdae6065e7768759932fcc827fc20b29b45dfbafa2f1f7da4/uncalled_for-0.3.2.tar.gz", hash = "sha256:89f5dbcd71e2b8f47c030b1fa302e6cce2ec795d1ac565eeb6525c5fe55cb8a2", size = 50032, upload-time = "2026-05-06T13:38:25.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/25/2c87754f3a9e692315f7b811244090e68f362979fc8886b3fbd2985a1d8c/uncalled_for-0.3.2-py3-none-any.whl", hash = "sha256:0ff60b142c7d1f8070bde9d42afaa70aedc77dcc10998c227687e9c15713418e", size = 11444, upload-time = "2026-05-06T13:38:24.025Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.52.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/03/18/ccce41535dee1be77735592bd19965f3972c82e07ee703d324709496b716/uvicorn-0.52.1.tar.gz", hash = "sha256:112ec661814189acbccd3f7b86460147cc065fc92c0821afa78918780e4354dd", size = 100571, upload-time = "2026-08-01T18:19:30.732Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/d5/68e6e9bca63c0badf67002890a46d3784c958de45b65e1275ec583ca1f06/uvicorn-0.52.1-py3-none-any.whl", hash = "sha256:e4403f9d93188cf9d1088e9f40e3acd12630e2df8675316704379a7fc20fff6a", size = 79859, upload-time = "2026-08-01T18:19:29.294Z" }, +] + +[[package]] +name = "watchfiles" +version = "1.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/41/5e1a4bb12aac5f1493fa1bdc11154eca3b258ca4eba65d39c473fe19d8e9/watchfiles-1.2.0.tar.gz", hash = "sha256:c995fba777f1ea992f090f9236e9284cf7a5d1a0130dd5a3d82c598cacd76838", size = 108252, upload-time = "2026-05-18T04:32:04.251Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fc/3d/8024c801df84d1587740d0359e7fdd80afeae3d159011f3d5376dd82f18e/watchfiles-1.2.0-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:704fd259e332e01f9b9c178f4bce9e49027e5587cc2600eeeaf8e76e1c846201", size = 400242, upload-time = "2026-05-18T04:31:19.014Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/f4dfd45323e949984a3a7f9dc31d1cbb049921e7d98253488dda72ccdaa9/watchfiles-1.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6543cf55d170003296d185c0af981f3e1311564907e1f4e08671fc7693a890a5", size = 394562, upload-time = "2026-05-18T04:30:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/98/d8/19483ef075d601c409bce8bcbb5c0f81a10876fff870400568f08ce484a1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89d8c2394a065ca86f5d2910ff263ae67c127e1376ccc4f9fc35c71db879f80a", size = 456611, upload-time = "2026-05-18T04:30:45.723Z" }, + { url = "https://files.pythonhosted.org/packages/b1/6a/cc81fbe7ee42f2f22e661a6e12def7807e01b14b2f39e0ff83fd373fd307/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:772b80df316480d894a0e3165fdd19cf77f5d17f9a787f94029465ad0e3529d1", size = 461379, upload-time = "2026-05-18T04:31:29.292Z" }, + { url = "https://files.pythonhosted.org/packages/b1/57/7e669002082c0a0f4fb5113bb70125f7110124b846b0a11bc5ae8e90eac1/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d158cd89df6053823533e06fb1d73c549133bff5f0396170c0e53d9559340717", size = 493556, upload-time = "2026-05-18T04:30:05.44Z" }, + { url = "https://files.pythonhosted.org/packages/45/7d/f60a2b19807b21fe8281f3a8da4f59eef0d5f96825ac4680ba2d4f2ebf91/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d516b3283a758e087841aedb8031549fb41ced08f3db10aa6d2bf32dc042525b", size = 575255, upload-time = "2026-05-18T04:30:40.568Z" }, + { url = "https://files.pythonhosted.org/packages/bd/49/77f5b5e6efbcd57482f74948ebb1b97e5c0046d6b61475042d830c84b3ff/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:53b2290c92e0506d102cd448fbc610d87079553f86caa39d67440856a8b8bba5", size = 467052, upload-time = "2026-05-18T04:31:17.942Z" }, + { url = "https://files.pythonhosted.org/packages/ee/5a/73e2959af1b97fd5d556f9a8bdba017be23ceeef731869d5eaa0a753d5a3/watchfiles-1.2.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a711b51aec4370d0dcda5b6c09463206f133a5759341d7744b953a7b62e1100e", size = 456858, upload-time = "2026-05-18T04:30:30.182Z" }, + { url = "https://files.pythonhosted.org/packages/50/57/1bc8c27fad7e6c19bddee15d276dbb6ab72480ec01c127afff1673aee417/watchfiles-1.2.0-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:e2ca07fa7d89195ec0865d3d285666286740bfa83d83e5cee204043a31ecc165", size = 467579, upload-time = "2026-05-18T04:32:15.897Z" }, + { url = "https://files.pythonhosted.org/packages/09/6c/3c2e44edba3553c5e3c3b8c8a2a6dee6b9e12ae2cf4bd2378bebf9dc3038/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e0618518f282c4ebff60f5e5b1247b6d91bb8b9f4476947563a1e74acc66f3c6", size = 633253, upload-time = "2026-05-18T04:31:37.123Z" }, + { url = "https://files.pythonhosted.org/packages/30/c2/d8c84a882ab39bbefcc4915ab3e91830b7a7e990c5570b0b69075aba3faf/watchfiles-1.2.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:0d191c054d0715c3c95c99df9b8dbf6fd096d8c1e021e8f212e1bd8bc444ccb5", size = 660713, upload-time = "2026-05-18T04:31:24.62Z" }, + { url = "https://files.pythonhosted.org/packages/a9/07/f97736a5fc605364fe67b25e9fa4a6965dfd4840d50c406ada507e9d735f/watchfiles-1.2.0-cp311-cp311-win32.whl", hash = "sha256:9342472aff9b093c5acd4f6d8f70ae0937964ab56542502bcf5579782da69ae8", size = 277222, upload-time = "2026-05-18T04:31:21.131Z" }, + { url = "https://files.pythonhosted.org/packages/cf/99/2b04981977fc2608afd60360d928c6aecf6b950292ca221d98f4005f6694/watchfiles-1.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:dbd6c97045dad81227c8d040173da044c1de08de64a5ea8b555da4aee1d5fa22", size = 290274, upload-time = "2026-05-18T04:31:45.966Z" }, + { url = "https://files.pythonhosted.org/packages/3c/74/f7f58a7075ee9cf612b0cfcddb78b8cd8234f0742d6f0075cf0da2dde1c6/watchfiles-1.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:57a2d9fa4fb4c2ecae57b13dfff2c7ab53e21a2ba674fe9f05506680fcdcc0d7", size = 283460, upload-time = "2026-05-18T04:31:39.126Z" }, + { url = "https://files.pythonhosted.org/packages/b8/2f/e42c992d2afda3108ea1c02acecc991b9f31d05c14adc2a7cee9ee211fc4/watchfiles-1.2.0-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:bc13eb17538be00c874699dc0abe4ee2bc8d50bb1166a6b9e175ef3fd7eb8f26", size = 400115, upload-time = "2026-05-18T04:32:02.06Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8f/6af2ea19065c91d8b0ea3516fdfc8c0d349f407e8e9fbf4e5a17360de8ad/watchfiles-1.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:2d95ddc1eb6914154253d239089900813f6a767e174b8e6a50e7fdacb7e4236c", size = 393659, upload-time = "2026-05-18T04:30:50.951Z" }, + { url = "https://files.pythonhosted.org/packages/13/01/b32a967c56fb3e3e5be3db52c3d3b87fa4513aa367d8ed1ad96d42952e5f/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f70d8b291ef6e88d19b1f297a6905ddb978888d9272b0d05e6f53309856bcfc", size = 453207, upload-time = "2026-05-18T04:31:04.231Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/97557a812180338cb1abd32e1cffcc4588f59b5f23e0cb006b2ba95ba64a/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:56d8641cf834c2836922899105bd3ce3d0dfc69291d52edf0b4d0436829b34c0", size = 459273, upload-time = "2026-05-18T04:31:50.377Z" }, + { url = "https://files.pythonhosted.org/packages/e8/a8/b4b08dcb7653b8087c6586f7ce649505900e866bbcfe40dc9587af02e686/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2581a94056e55d7d0a31a823ea92bf73749c489ca2285bfdc0fbe6b2bb49d50c", size = 489927, upload-time = "2026-05-18T04:31:42.485Z" }, + { url = "https://files.pythonhosted.org/packages/50/94/3dceea03545d2e5ddfd839f0ddd5e1cecbf1697b5a428d5ba11cef6af95d/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41bc1199f7523b3f82843c88cbb979180c949caef0342cf90968f178e5d49b01", size = 570476, upload-time = "2026-05-18T04:31:03.071Z" }, + { url = "https://files.pythonhosted.org/packages/cc/f2/d39a5450c3532092b91f81d274360e613c2371bc874a89c7a1a3c5e8d138/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7571e4464cb6e434958f867f7f730b8ab0b75e3f8e5eac0499168486ab3c33a8", size = 465650, upload-time = "2026-05-18T04:30:12.701Z" }, + { url = "https://files.pythonhosted.org/packages/22/24/ed72f68cbc1333ca9b9f2200aa048bb6658ae41709bc1caad4310f4bdffd/watchfiles-1.2.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e53a384f76b631c3ae5334ce6a52f0baa3a911eb94a4eac7f160079868b716d5", size = 456398, upload-time = "2026-05-18T04:30:13.784Z" }, + { url = "https://files.pythonhosted.org/packages/0d/64/982ef4a4e5bab5b6e5b6becc8cd5e732f6130a78b855f0abec6439a9a135/watchfiles-1.2.0-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d20029a60a71a052a24c4db7673bc4de39ab89adbaccbfb5d67987c5d73f424d", size = 465140, upload-time = "2026-05-18T04:31:52.111Z" }, + { url = "https://files.pythonhosted.org/packages/a0/0c/95282abf4ed680b6096010bcfc30c5fa7a041fc5aa5a2ad17a2cc6c75bba/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:2cb93af48550faf1cea04c303107c8b75833de7013e57ce27d3b8d21d8d0f58c", size = 630259, upload-time = "2026-05-18T04:31:25.676Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/607c1de1530c4bdcf2cf1d1ecc2505ddba5d96bd43ba9f2b0e79876f850f/watchfiles-1.2.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2995c176de7692b86a2e4c58d9ec718f753150a979cb4a754e2b4ffa38e70906", size = 659859, upload-time = "2026-05-18T04:30:24.333Z" }, + { url = "https://files.pythonhosted.org/packages/fa/08/d9e2e0f9e8e6791d33aefc694ad7eefa7f901f63caff84a81ded38692f9c/watchfiles-1.2.0-cp312-cp312-win32.whl", hash = "sha256:7a2cffd17d27d2ecbb310c2b1d8174f222a5495b1a721894afa88ec11e25b898", size = 275480, upload-time = "2026-05-18T04:30:31.307Z" }, + { url = "https://files.pythonhosted.org/packages/1c/e6/9d42569c0102645cc8cea5d8c7d8a1e9d4ada2cb7f05f75e554b8aa2202a/watchfiles-1.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:f155b3a1b2a5fc89cdc70d47ee5d54e3b75e88efa34982028a35daef9ba00379", size = 288718, upload-time = "2026-05-18T04:32:10.745Z" }, + { url = "https://files.pythonhosted.org/packages/0a/26/88e0dc6ee3898169d7fa22bb6a69cabf2502d2ee25cb8c876d1262d204f8/watchfiles-1.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:8fa585ede612ee9f9e91b18bebf9ba11b9ae29a4e3a0d0cf6fca3e382133f0d5", size = 281026, upload-time = "2026-05-18T04:30:22.23Z" }, + { url = "https://files.pythonhosted.org/packages/d1/4d/70a7feced9f87e2ff26dba42667290f41694fc64646c67261fbb8cab5d5c/watchfiles-1.2.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:01ea8d66f0693b9b60a6541c8d10263091ca9a9060d242f3c1f3143f9aad2c98", size = 399730, upload-time = "2026-05-18T04:31:38.162Z" }, + { url = "https://files.pythonhosted.org/packages/31/3a/0da302f2307aee316922806ebd5726c542cbd787c938271cf14a074c7daf/watchfiles-1.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7ba0480b9a74af058f43b337e937a451e109295c420916d68ad24e3dc02f5e44", size = 392842, upload-time = "2026-05-18T04:30:27.051Z" }, + { url = "https://files.pythonhosted.org/packages/db/ef/d5bdb705c224dbc256aa0c1ec47bf4e61ec52558f2afb44a71a1fe4d7015/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f34e26a19f91f710c08e0183429f0d1d15df734e6bc78c31e77b9ea9c433658", size = 452989, upload-time = "2026-05-18T04:31:11.945Z" }, + { url = "https://files.pythonhosted.org/packages/71/29/5495f2c1661949ef7a35e4d71111d129cfe7606414a26887a919d0a55406/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b4e77f6a55f858504069abd35d336a637555c09bca453dde1ee1e5ada8a6a1fb", size = 458978, upload-time = "2026-05-18T04:30:52.606Z" }, + { url = "https://files.pythonhosted.org/packages/d5/8c/7f9c07c433811c2fffd93e13fdfb7135de9aab5f2ae41be08960fa0047dc/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0cb4d80e212f116474a545c21c912b445f16bb0cef9e6a73a498164223e14e2f", size = 490248, upload-time = "2026-05-18T04:31:36.003Z" }, + { url = "https://files.pythonhosted.org/packages/3c/11/d93632febc52fbc21be90231bb7c17fd5387f46c9076fd40a5f9c2ae6910/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b974946a10af379d425e2eef5b62f5c6ebeaccf91d45eaad6f5b27ecd4f91aa0", size = 571847, upload-time = "2026-05-18T04:31:10.862Z" }, + { url = "https://files.pythonhosted.org/packages/55/b4/383173e73aabb07ad1d9c7aa859d95437ac46a6d6a1e11005facda0c9d19/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86bc13c25a8d1fcd70b51d0ce7c9b65e90de5666fcbfd3e34957cc73ee19aeb5", size = 465974, upload-time = "2026-05-18T04:30:17.006Z" }, + { url = "https://files.pythonhosted.org/packages/a7/6c/89b1a230a78f57c52dd8893adb1f92f94411721b6ec12596c56d98c74356/watchfiles-1.2.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ca148d73dea36c9763aaa351e4d7a51780ec1584217c45276f4fe8239c768b71", size = 454782, upload-time = "2026-05-18T04:30:35.656Z" }, + { url = "https://files.pythonhosted.org/packages/24/62/1732118367cfff0a9fce3bf62ff4bfded09ef5df21d9d446b858b3f70a96/watchfiles-1.2.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:c525543d91961c6955b2636b308569e84a1d1c5f5f2932041ab9ef46422f43e3", size = 465182, upload-time = "2026-05-18T04:30:20.846Z" }, + { url = "https://files.pythonhosted.org/packages/28/96/716f7e5f51339bf22963f3345f9f27d7f3b30e2eadc597e257c881dd3c53/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:a204794696ffb8f9b10fba6f7cb5216d42f3b2b71860ccac6b6e42f5f10973b0", size = 629841, upload-time = "2026-05-18T04:31:05.397Z" }, + { url = "https://files.pythonhosted.org/packages/4c/fe/c40783950fd771ccf66ab3ec2722d188a9af1c7f96c6e811f36e40c6e03f/watchfiles-1.2.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:10d86db20695afe7997ac9e1717637d6714a8d0220458c33f3d2061f54cec427", size = 658028, upload-time = "2026-05-18T04:31:48.22Z" }, + { url = "https://files.pythonhosted.org/packages/71/72/4508db1856d1d87fcbb3b63f4839bab1b5682cb0e8d224d122263c09654a/watchfiles-1.2.0-cp313-cp313-win32.whl", hash = "sha256:eb283ee99e21ad6443c8cdb06ac5b34b1308c329cbdf03fa02b445363714c799", size = 275183, upload-time = "2026-05-18T04:30:59.57Z" }, + { url = "https://files.pythonhosted.org/packages/f9/36/14b76ca57652e5cc5fd1c11f32a261292c08a0d19a00351013c2549cbfb2/watchfiles-1.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:a0f27f01bee51861392bb6b7c4fdb290b27d1eb194e9e28788d68102a0e898d9", size = 288059, upload-time = "2026-05-18T04:32:07.937Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8d/0a85e395398d8d20fadfe5c5d32c726eee17a519e78fb356f2cf7531bffe/watchfiles-1.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:3651aa7058595e9cfb75d35dd5ada2bf9f48a5b8a0f3562821d3e210c507e077", size = 280186, upload-time = "2026-05-18T04:31:54.484Z" }, + { url = "https://files.pythonhosted.org/packages/37/68/36db056f1fdcc5f07302f56e631774d6835bcd6fa3ace402304621d5f9e5/watchfiles-1.2.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:faea288b6f0ab1902ef08f4ca6de005dccf856c4e0c4f21b8c5fce02d90a1b08", size = 399031, upload-time = "2026-05-18T04:30:44.576Z" }, + { url = "https://files.pythonhosted.org/packages/c1/64/01a9d6f66a82a5c101ce939274106cc72759d62427e153f01edd2b9f87c2/watchfiles-1.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:01859b11fd9fbca670f4d5da00fbac282cfea9bd67a2125d8b2833a3b5617ea9", size = 391205, upload-time = "2026-05-18T04:30:25.413Z" }, + { url = "https://files.pythonhosted.org/packages/84/2c/0a44fe058cb4bb7b8ede6b6670698bbb7c0400740e378d00022189b7b31d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fff610d7bb2256a317bb1e96f0d7862c7aa8076733ee5df0fd41bbe76a24a4f4", size = 451892, upload-time = "2026-05-18T04:32:14.005Z" }, + { url = "https://files.pythonhosted.org/packages/67/a1/351e0d56cd35e6488b5c8b4fb11a809a5bc923e8fe8fed9faf8920be0c89/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b141a4891c995a039cd89e9a49e62df1dc8a559a5d1a6e4c7106d16c12777a55", size = 458867, upload-time = "2026-05-18T04:31:22.279Z" }, + { url = "https://files.pythonhosted.org/packages/d5/7d/9d09605187f1b838998624049fcf8bf47b73c1a3b76901fcac1782f62277/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f22943b7770483f6ea0721c6b11d022947a98eb0acae14694de034f4d0d38925", size = 490217, upload-time = "2026-05-18T04:31:43.657Z" }, + { url = "https://files.pythonhosted.org/packages/60/5d/a17a16eccb182f04188cd308ec24b1a71a9b5c4e7098269cf35d9fa56d02/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1bc6195825b7dcd217968bb1f801a60fd4c16e8eeab5bedc7fe917d7d5995ab4", size = 571458, upload-time = "2026-05-18T04:32:11.875Z" }, + { url = "https://files.pythonhosted.org/packages/d3/3d/4dd457062083ab1938e5dfd45032eb425cee2ac817287ca8ff4356183e5d/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d4a4b147f5dca2a5d325a06a832fb43f345751adfbc63204aec30e0d9ca965a2", size = 464707, upload-time = "2026-05-18T04:30:43.492Z" }, + { url = "https://files.pythonhosted.org/packages/c6/71/ea8c57b128f5383de74d0c7d2d9c57ad7c9a65a930c451bd25d524b295b7/watchfiles-1.2.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4543579a9bdb0c9560039b4ffddbdb39545707659fbc430ce4c10f3f68d557f9", size = 454663, upload-time = "2026-05-18T04:30:16.061Z" }, + { url = "https://files.pythonhosted.org/packages/53/fd/2e812bf938406d7db351f0703ddd3fc6c061cf30d96153a77bc79a943a44/watchfiles-1.2.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:20aa0e708b920bde876a4aa82dc7dd6ebea228a63a67cda6632c2fc87b787efa", size = 463537, upload-time = "2026-05-18T04:31:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/86/56/d17a7f1dd1bc3035f1072694a551301272f1739c2d8e319c927cb9e29b38/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:d413349d565dab74297f2a63e84a097936be69bf8f3b3801f27f380e32040f44", size = 629194, upload-time = "2026-05-18T04:31:14.141Z" }, + { url = "https://files.pythonhosted.org/packages/be/06/f1ff66bf5cae50aa4062779a0ecd0bbaf15e466195719074078947d9a17d/watchfiles-1.2.0-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f28b2725eb8cce327b9b3ab02415c853011dc55c95832fe90de6bc56f5315f72", size = 656194, upload-time = "2026-05-18T04:31:47.14Z" }, + { url = "https://files.pythonhosted.org/packages/e7/54/a9c7ea9a82a4ac65e7004c0a03920b5cdd2f9c3b678757d9cd425aa51d53/watchfiles-1.2.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:b8c8358484d5fa12ef34f05b7f4168eaf1932f408725ff6d023c33ec17bd79d4", size = 400205, upload-time = "2026-05-18T04:32:05.153Z" }, + { url = "https://files.pythonhosted.org/packages/aa/5d/c9ab3534374a4a67450696905d6ef16a04405448b8dc52bd752ae50423d4/watchfiles-1.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f04b092229ad2c50126dd3c922c8822e51e605993764a33058d4a791ab42281", size = 392508, upload-time = "2026-05-18T04:30:54.849Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/1ad30103535cf0cecd7b993e8d50edc5351b1820e38f2d22e3df58962feb/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7a7ce236284f002a156f70add88efe5c70879cccbb658be0822c54b1306fc09d", size = 452448, upload-time = "2026-05-18T04:30:53.727Z" }, + { url = "https://files.pythonhosted.org/packages/37/a1/ceee2cdf2afbd715fa07758d39c9859513eae411b23196f7fd039e5feedd/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b9909cc2b48468b575eefa944919e1fe8a36c5849d5c7c168f80a8c1db69398e", size = 459605, upload-time = "2026-05-18T04:30:23.312Z" }, + { url = "https://files.pythonhosted.org/packages/e8/f6/421e30fd1cb3907a84ed92ab3f1983e37ba2dca015e9a894a048418417a2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a37faaed405c67e28e6be45a1fa4f206ef5a2860f27c237db9fa30704c38242", size = 490757, upload-time = "2026-05-18T04:30:47.358Z" }, + { url = "https://files.pythonhosted.org/packages/41/b0/55ed1b97ed08be7bba6f9a541cac15f2a858e1d74d2b07b6da70a82aab00/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9649193aa27bd9ff2e80ff29bfaa93085496c7a3a377592823cc58b77ee88add", size = 568672, upload-time = "2026-05-18T04:30:38.915Z" }, + { url = "https://files.pythonhosted.org/packages/d1/cf/d8ae8a80dd7bafab395ea7681c10237311bbf34d37704a8c744e7cf31fc7/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4e4ff8e37f99cf1da89e255e07c9c4b37c214038c4283707bdec308cb1b0ea1f", size = 464197, upload-time = "2026-05-18T04:30:09.914Z" }, + { url = "https://files.pythonhosted.org/packages/7c/8a/3076c496ca8dafe0e8cd03fcebdfc47be4b1174b4e5b24ff6e396e6b3af2/watchfiles-1.2.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:054dc20fd2e3132b4c3883b4a00d72fd6e1f56fdaf89fccd12e8057d74cd74d7", size = 453181, upload-time = "2026-05-18T04:30:14.829Z" }, + { url = "https://files.pythonhosted.org/packages/e5/10/9745e17c98e7b8a86454df0a3c7b5686bd650383f1e9f26e4ebcbd6cc0c0/watchfiles-1.2.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e140ed30ebde76796b686e67c182cff10ea2fbab186fafd1560f74bb5a473a6e", size = 465109, upload-time = "2026-05-18T04:30:28.123Z" }, + { url = "https://files.pythonhosted.org/packages/8f/95/8ef4a95481d3e0cb52d62a06fa6e972e81424be2d9698b91a2fecca9904c/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:bb7e52ecf68ba46d22df23467b87cffeb2146908aa523ebfe803019618cfda06", size = 630653, upload-time = "2026-05-18T04:31:49.304Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e4/3b3bf36b0f829b50c6ebcb8d031583863c59f923d6a6af3d485e470d0fac/watchfiles-1.2.0-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:23282a321c8baf9b3a3c4afff673f9fe65eb7fdc2338d765ccad9d3d1916a5ba", size = 657838, upload-time = "2026-05-18T04:31:06.497Z" }, + { url = "https://files.pythonhosted.org/packages/21/b1/6cbbb50c1f3002ab568777d44aa21206dfb8807a840990c4037523b51812/watchfiles-1.2.0-cp314-cp314-win32.whl", hash = "sha256:c0db965c5f79aa49fe672d297cf1febc5ad149b658594944f49a54a2b96270a7", size = 275108, upload-time = "2026-05-18T04:30:06.891Z" }, + { url = "https://files.pythonhosted.org/packages/92/45/190ce6db8dcb4536682cf75d3889ff1a27182a58cb519d343cb6d9ea63d8/watchfiles-1.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:71283b39fd17e5408eb123bd37aeecfd9d54c81fc184421943208aadb879d103", size = 288441, upload-time = "2026-05-18T04:32:12.901Z" }, + { url = "https://files.pythonhosted.org/packages/74/0d/3eae1c2313ab08378431d907c3f8095ecca00f3eda33111cf4f0f2591799/watchfiles-1.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:c5c19526f4e54a00f2666a6c0e9e40d582c09e865055ea7378bf0009aab857b3", size = 280684, upload-time = "2026-05-18T04:31:26.902Z" }, + { url = "https://files.pythonhosted.org/packages/b1/75/fb64e6c25d6b5ca636d03df34ffb1c6e9873303e76d27967e045f8df088f/watchfiles-1.2.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:d73a585accffa5ae39c17264c36ec3166d2fad7000c780f5ef83b2722afb9dd2", size = 398857, upload-time = "2026-05-18T04:32:17.108Z" }, + { url = "https://files.pythonhosted.org/packages/73/4e/9f7adf01754cbf81843722ccfec169d8f26c69778281a302855cecd2ee08/watchfiles-1.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae99b14c5f21e026e0e9d96f40e07d8570ebee6cafd9d8fc318354606daa7a28", size = 392413, upload-time = "2026-05-18T04:31:07.911Z" }, + { url = "https://files.pythonhosted.org/packages/47/c8/bec626bcc2d69f44b9acb24ce7d60ed7b16b73628eea747fcbd169d8edda/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4429f3b105524a10b72c3a819b091c495d2811d419c1e1e8df773a5a5974f831", size = 452409, upload-time = "2026-05-18T04:31:20.142Z" }, + { url = "https://files.pythonhosted.org/packages/00/b7/b6362068e81e7c556d155a34c35d40ac3ef42d747b06d7f6e5bf58e359c2/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:43d818978d06062d9b22c4fab2ebe44cf5213d42dc8e62bda8c2760cfa2eeb33", size = 458827, upload-time = "2026-05-18T04:32:06.219Z" }, + { url = "https://files.pythonhosted.org/packages/67/f8/9a813fa42afb1e0b4625e75f0479826644d3ee8dc287e093799bc01f390c/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b9f732dc58b2dbe69e464ccf8fff7a03b0dd0be439da4c0720d3558527d3d6b4", size = 490104, upload-time = "2026-05-18T04:31:56.034Z" }, + { url = "https://files.pythonhosted.org/packages/2f/bf/27dfb6094ca4c9aad21298b5525b6c53cb36121ee454331d05161e58d130/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8f200104103feb097de4cab8fe4f5dd18a2026934c7dea98c55a2f5fd6d5a33b", size = 571360, upload-time = "2026-05-18T04:31:57.133Z" }, + { url = "https://files.pythonhosted.org/packages/fb/39/44a096d67270ea93df91d33877dbe91fbda3aa4f8ec2edf799d93eda8736/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:63ac26eefbf4af1741247d6fb68b11c49a25b2f7413fbd318a83a12aaa9cf666", size = 464644, upload-time = "2026-05-18T04:30:57.33Z" }, + { url = "https://files.pythonhosted.org/packages/0e/80/c7472203bad6268e3ef1ad260739704847898938ad7ea8b63a5131f46b50/watchfiles-1.2.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c4997d4e4a55f0d02b6cde327322daf3a0400e5df6c6b15948994bf72497925", size = 454771, upload-time = "2026-05-18T04:30:48.736Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/3b10b268b4b7f0fc26e9debb5eef1998b515887840f444cd3ec80c688755/watchfiles-1.2.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4c887eba18b7945ac73067a8b4a66f21cd46c2539b2bc68588f7be6c7eb6d26b", size = 463494, upload-time = "2026-05-18T04:31:33.826Z" }, + { url = "https://files.pythonhosted.org/packages/3d/3e/a4302545cd589262a0dc7d140e86f7688eba3f9c72776c27f7e23b8864c4/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3416ff151bb6b5a8d8d11664974fbef4d9305b9b2957839ab5a270468fd8df30", size = 629383, upload-time = "2026-05-18T04:31:15.596Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/d5649df0a9a410d45b7c882304d0b790903ac9b6e8f2cfd12114e0c6b9f2/watchfiles-1.2.0-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:0e831a271c035d89789cffc386b6aa1375f39f1cd25eb7ca0997e4970d152fc5", size = 656093, upload-time = "2026-05-18T04:31:58.707Z" }, + { url = "https://files.pythonhosted.org/packages/92/b9/362702539275019a54dd2e94511b31a9b89c5f9e6a21966de7eb692549fc/watchfiles-1.2.0-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:37a6721cdf3f65dbb13aa9503510ccb4451603ac837e44d265d7992a597e1374", size = 400109, upload-time = "2026-05-18T04:31:16.879Z" }, + { url = "https://files.pythonhosted.org/packages/8f/75/71d5ba62db781e5587bded1d944c675374bc4aa37ff33d5018d98e8b6538/watchfiles-1.2.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2b37d10b5a63bd4d87e18472d80fa525bd670586fae62e5dd580452764879b65", size = 392167, upload-time = "2026-05-18T04:31:28.058Z" }, + { url = "https://files.pythonhosted.org/packages/3c/01/c66dd95d0423fe30d31820e2d1d5bda773764131bbb6ac0cb1cf303ac328/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a105bc2283f67e8fbec74253ec2d94925de92ed72c0393f1206bf326b7b7b69", size = 452372, upload-time = "2026-05-18T04:31:00.836Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/2fe99557e72f85627c6a8eed50d889e8d101623e060a22ad75b875cb932d/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5327989a465505f05cfe06f04fa9d0c2fd5432bb243e10e6f012b1bdca3c8579", size = 459596, upload-time = "2026-05-18T04:31:34.96Z" }, + { url = "https://files.pythonhosted.org/packages/ed/23/d4acfa0023367428ed48351b3b9b267893037b6cadae55620c61c24bcfd4/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ecb47f183a8025b2aa18b546725c3657e542112ae9c0613a2af79b4fa8d04ad7", size = 490869, upload-time = "2026-05-18T04:31:59.923Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5f/3164cbdce06c9fb95c4f7b9e2f9760b5e2797af43a9ecc317ef42a23a278/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8520a4ab0e37f770afc34459c4f8f7019e153f9124dc101c15538365875d1ab2", size = 571641, upload-time = "2026-05-18T04:32:00.948Z" }, + { url = "https://files.pythonhosted.org/packages/41/e6/85d3731c55e65cd7690f3f803d24c139588aaf863e4bf2148fe7a7fa1a19/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71cd71740ed2c15211ebb237ced4e39a1cdf6f80566e5fe95428da1626f4fde6", size = 464444, upload-time = "2026-05-18T04:30:34.298Z" }, + { url = "https://files.pythonhosted.org/packages/f4/7d/562641012b8b09872742c3b8adf9629ec479fd78f8d68ae4a0c13da8add6/watchfiles-1.2.0-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f88af53d6ddaf72179ef613ddc905e6f4785f712b49b80b3bef9f3525e6194b4", size = 453593, upload-time = "2026-05-18T04:31:23.464Z" }, + { url = "https://files.pythonhosted.org/packages/56/fe/cb8ef3d6f929d14158fdaaad9925985b7310abc9384dcd4d82dd0016fb59/watchfiles-1.2.0-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:cee9d5efd929efdac5f7e58f72b3376f676b64050a91c5b99a7094c5b2317488", size = 465096, upload-time = "2026-05-18T04:31:30.384Z" }, + { url = "https://files.pythonhosted.org/packages/25/91/80908e835e100527a9267147b08c0eee1fa6ab0ffec15edc04d1d44885f7/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:b718bf356bbc15e559bd8ef41782b573b8ae0e3f177ab244b440568d7ea02cfb", size = 630638, upload-time = "2026-05-18T04:30:49.89Z" }, + { url = "https://files.pythonhosted.org/packages/46/4b/95ab2f256bb4af3cb2eb23b9317bda984ee6e0f11733a5c004a6c95b06e3/watchfiles-1.2.0-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:922c0e019fe68b3ae392965a766b02a71ba1168c932cebc3733cd52c5fe5b377", size = 657684, upload-time = "2026-05-18T04:31:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/23/f4/7513ef1e85fc4c6331b59479d6d72661fc391fbe543678052ac72c8b6c19/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4674d49eb94706dfe666c069fc0a1b646ffcf920473492e209f6d5f60d3f0cc2", size = 403050, upload-time = "2026-05-18T04:30:36.753Z" }, + { url = "https://files.pythonhosted.org/packages/27/0b/a54103cfd732bb703c7a749222011a0483ef3705948dae3b203158601119/watchfiles-1.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:094b9b70103d4e963499bdea001ee3c2697b144cd9ae6218a62c0f89ec9e31db", size = 396629, upload-time = "2026-05-18T04:32:03.268Z" }, + { url = "https://files.pythonhosted.org/packages/5e/2c/73f31a3b893886206c3f54d73e8ad8dee58cdb2f69ad2622e0a8a9e07f4e/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b0ef001f8c25ad0fa9529f914c1600647ecd0f542d11c19b7894768c67b6acb7", size = 457318, upload-time = "2026-05-18T04:31:01.932Z" }, + { url = "https://files.pythonhosted.org/packages/e9/f9/45d021e4a5cc7b9dd567f7cbb06d3b75f751a690063fb6cc7ec60f4e46b7/watchfiles-1.2.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a88fc94e647bc4eec523f1caa540258eb71d14278b9daf72fa1e2658a98df0f0", size = 457771, upload-time = "2026-05-18T04:30:56.331Z" }, +] + +[[package]] +name = "websockets" +version = "17.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/96/e01084f83a64bcb3a27994bd0cb0db68ff29d9c6707fae37ec19b18ba990/websockets-17.0.1.tar.gz", hash = "sha256:5baa9bc0dfbae8c507e51c8cf1b6d4628086f7a87bbd3a9952bd5f035451f1cc", size = 183298, upload-time = "2026-07-31T11:31:27.665Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/54/b2bce5b754b91b727b852e78af6d7193d4fe985e420dc54e6c2abe161c1c/websockets-17.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:c38515cb54902f7e97d0239e81ef46c4444f9475f4807fb9bbdb789b4089abcf", size = 212573, upload-time = "2026-07-31T11:29:04.803Z" }, + { url = "https://files.pythonhosted.org/packages/78/1b/eaefeb695b217d8c735fc377ab53c6b00bc9ed64a01a3f6f797096ac0ee8/websockets-17.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:70d438268e49f1a4bd096b6b6f7010f3ab48b5db2574dbf7d8c864c46ce7a06a", size = 210262, upload-time = "2026-07-31T11:29:06.298Z" }, + { url = "https://files.pythonhosted.org/packages/87/2d/68439a174c74969fb51619bbe9af9496826610883b828a2edd2f022c94fc/websockets-17.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0b52c76b8a870b141b7ca0705289452183ce7a523101954ccfe29a25986a673f", size = 210535, upload-time = "2026-07-31T11:29:07.553Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ab/13a856b488dbac7fd3c5473e38098897cda0baa04e3ca3b34ec6c8a32b46/websockets-17.0.1-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b98860aefbd3d9bc8e3c7f0eefb83b11142b16110739c68cd33d3b4d6e84e536", size = 219602, upload-time = "2026-07-31T11:29:09.227Z" }, + { url = "https://files.pythonhosted.org/packages/45/a1/2b100e71aa1fe283ec8fb73f8b74a8576d855486a49117903b100dd3b78d/websockets-17.0.1-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d41e9845514754a42d1d83b2fca9d27fee2ca7b3b0bee6843ba5a9bb2b6e25ac", size = 219873, upload-time = "2026-07-31T11:29:10.362Z" }, + { url = "https://files.pythonhosted.org/packages/3b/71/92e6146d3588d145136c0e0e16d106bbb855bb5047e13ec3fdee39cce770/websockets-17.0.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9aac6081513f02eac3f8caace800dbfc5c608b69e4a7bef69e414eabfc95aa1", size = 221108, upload-time = "2026-07-31T11:29:11.708Z" }, + { url = "https://files.pythonhosted.org/packages/09/8d/357afa2ffd29686536109e7e3cb2a94f2d09e535df4cf3989393dc506a40/websockets-17.0.1-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b85b960a4507b0714c0a1246d031be9118d908ee974dc085257297a955205f1d", size = 224401, upload-time = "2026-07-31T11:29:12.959Z" }, + { url = "https://files.pythonhosted.org/packages/01/27/9efba1e7a8df48e405d017e67513c6b2a8f0b59c8500b820c47cd5f3dea9/websockets-17.0.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c356dbddab0a529ed7574f78f559d75a223735c321c28f6f587fbf02b11ed301", size = 221670, upload-time = "2026-07-31T11:29:14.199Z" }, + { url = "https://files.pythonhosted.org/packages/01/85/ab27d62103e8a150f3657e043e5fc711ad3e018c8cd8a715093e93d7640c/websockets-17.0.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5661f868ef191d33dfc6a0cc7c5b3d495f0cc8bb3f8b30d87bda8755c61c95f5", size = 220442, upload-time = "2026-07-31T11:29:15.417Z" }, + { url = "https://files.pythonhosted.org/packages/6d/04/289e00b8001b622b0c397a6901fcc4aa8f34a6d2ed42be17f2704f2faae1/websockets-17.0.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2fa2cb465a131c347ba6717a78c887746e73edb1c131d01c982d6ef0d68b82e0", size = 217760, upload-time = "2026-07-31T11:29:16.772Z" }, + { url = "https://files.pythonhosted.org/packages/2f/70/0fe58cdac988dfc0066786cc07b09dfd72b48b0c01e5de667721192b2e6e/websockets-17.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:55383d8177b3c99fd873ee5db0e0193f4c1dd4a3feaccf1a4a03c1b7cf539cac", size = 220597, upload-time = "2026-07-31T11:29:18.075Z" }, + { url = "https://files.pythonhosted.org/packages/d5/76/d3eaf120710d1a791d1c7a4963f0b50a589df8ba675394fd2a97dfea3746/websockets-17.0.1-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:038cfad5d5417f8bb09295abe986029a26d22f34bda622ccc79b670efd4dab56", size = 219187, upload-time = "2026-07-31T11:29:19.468Z" }, + { url = "https://files.pythonhosted.org/packages/e6/00/4e9ae886bdb1647537176ca33fca66d79dddb3773d213ac98ddc6bba9ab4/websockets-17.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:15920057a6b723f84734f0641403bca163a4b176e5af809ee4f0c4a1e75e9fed", size = 219955, upload-time = "2026-07-31T11:29:20.641Z" }, + { url = "https://files.pythonhosted.org/packages/7c/67/cd7cc6849a86cf8c9979c0c570b6b6ccee75d2872854238d8d54e77be6ec/websockets-17.0.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:5508f38c98ac29def9e747b87543b008a58b075df6da70b2cf2e0b47073d33bb", size = 221002, upload-time = "2026-07-31T11:29:21.753Z" }, + { url = "https://files.pythonhosted.org/packages/de/5c/4d14eaf7b2f1448d1af24c1641f04eb74c1632a5802952aac4b7e068b8e7/websockets-17.0.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a68e604c6d1b0338e46652e2688cbce8096ad9c03548b075fda9e2ea19a9b7dd", size = 218579, upload-time = "2026-07-31T11:29:22.888Z" }, + { url = "https://files.pythonhosted.org/packages/15/67/ff8bc4b8a6ec235ed8985de12fecc59ad2cd68cc8fc79b97deaa42e412ac/websockets-17.0.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:a8af570fc29cd998a921c7131c8ac81d9434466d6d25300cb12a690fb56a8a08", size = 219612, upload-time = "2026-07-31T11:29:24.052Z" }, + { url = "https://files.pythonhosted.org/packages/91/eb/103d81d655bab3ffd5c7d5d4b08f92c374499decd1d4be6035ce715b385b/websockets-17.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:246927ae9ae06ca0d42a483a4bdb80d4862e1ee5b4cab37c354a5e1ad8356448", size = 219846, upload-time = "2026-07-31T11:29:25.421Z" }, + { url = "https://files.pythonhosted.org/packages/b8/1e/3495161b1827941258545604fdf72e3e053d03f25bb61752228a784c26a1/websockets-17.0.1-cp311-cp311-win32.whl", hash = "sha256:1d4cf7e8e5b8b1fa40758ac7524843a00237b124ab217e227542cafcfeb7a946", size = 213046, upload-time = "2026-07-31T11:29:27.094Z" }, + { url = "https://files.pythonhosted.org/packages/7b/b1/ba0ce59681db38c320a6d485f95a497ddea20356d9a9e8e70615ddd867b9/websockets-17.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:02f0b037a737d0cb0c33866c97bcd1a0b73170dfbf42d69d8fb86f51002fd5ae", size = 213344, upload-time = "2026-07-31T11:29:28.28Z" }, + { url = "https://files.pythonhosted.org/packages/83/9e/abfdde9cbd57f0b5867a70e3426ac63341e1a21557b273c39bb6d2ccf8b9/websockets-17.0.1-cp311-cp311-win_arm64.whl", hash = "sha256:1bdd8c4be420905dd732e00dcd669852d8128cc723efa585a0c0e51adb00a28a", size = 213277, upload-time = "2026-07-31T11:29:29.504Z" }, + { url = "https://files.pythonhosted.org/packages/50/ff/6199a52d864215750af8668d84b0274775011a90052081f5a9495807a92b/websockets-17.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:10f461191125c63902ea7394ae9e752b1b5785641850c1d365bb30b0f88bc53f", size = 212603, upload-time = "2026-07-31T11:29:30.771Z" }, + { url = "https://files.pythonhosted.org/packages/54/7e/439a962bcada88dcf586da77a1b2385f91e2d2910e9359540934c827156b/websockets-17.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:cffc84ddec6da7f447677266fee2a3c40ecc78172f00752aa1150b8a8d65df1d", size = 210286, upload-time = "2026-07-31T11:29:32.157Z" }, + { url = "https://files.pythonhosted.org/packages/d9/82/123660edc759c225626b3b91952c7625f85c77a8362acbc35a4623120f7d/websockets-17.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:c23e532c8a2325a1e7486de8763a60dc43e83f01bcaeca07e3ba79652c156db1", size = 210549, upload-time = "2026-07-31T11:29:33.388Z" }, + { url = "https://files.pythonhosted.org/packages/cd/2f/2940e57080cf56f28190287516400126d5a76b52b9a61dc10ba6f6400dbe/websockets-17.0.1-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c09e097d0e46e3c289bedab9a475ae344b70c30ff5646e46af22b4e6fdc97b21", size = 219874, upload-time = "2026-07-31T11:29:34.608Z" }, + { url = "https://files.pythonhosted.org/packages/42/28/9ec976c16d63cc51c28dfec74b66854048c0b8b6579946e902f91b69e8bf/websockets-17.0.1-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f47b0815af3948ec6a440b3afa02f05b18cc0939549e91b5c677b5d9c2c8472a", size = 220150, upload-time = "2026-07-31T11:29:35.831Z" }, + { url = "https://files.pythonhosted.org/packages/f4/a4/850c699a16bbc451723856360c59bd997bec075e637154f3fa96e80d5760/websockets-17.0.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8848c207049ad49d318e5f64a3d4d7bb189f8328d0d98e65647788f2a085785c", size = 221389, upload-time = "2026-07-31T11:29:37.189Z" }, + { url = "https://files.pythonhosted.org/packages/47/e1/f60a891c1a4b3420d5052333a84eb7241e1fb4a71866dec1562f5fa30027/websockets-17.0.1-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2604de7228506b13a44a256a9d223943340c0e725af5d367dc068e192b027761", size = 224169, upload-time = "2026-07-31T11:29:38.61Z" }, + { url = "https://files.pythonhosted.org/packages/26/fb/e2a893be6fae4fddfe50ddc3035a331d3f381103d5467b7900026bdb3a64/websockets-17.0.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:07abc3bd196a48af476a82fd47f3f79a6a3f70937a9f930cef703cfa0c9d83b6", size = 222025, upload-time = "2026-07-31T11:29:39.897Z" }, + { url = "https://files.pythonhosted.org/packages/d9/72/e3144b2d79276fab9798ed7d4aea2f0847434f186800b6f56a1eddcb3114/websockets-17.0.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:769ce7e2acfd9a89f2bed3a9c0da229459516bbc00bd4c9e2ca492c613ae4861", size = 220779, upload-time = "2026-07-31T11:29:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/c0/5e/69c02174fbcf1c40c6adc45d3c316a401558392fe7bab8969ef8c46f1689/websockets-17.0.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:07d78a509c3333f5908c83d7f78144ea68a6c9ec28110f5c54d81d8fcdc262c4", size = 218053, upload-time = "2026-07-31T11:29:42.322Z" }, + { url = "https://files.pythonhosted.org/packages/b3/09/7574778b095b99cfa0856583462f56568df784f9b41485145169b2ec9c64/websockets-17.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ffad64ce7ad3703d652a3fd9af26238377d24ce52c6ad8ff35d26d82f61f493f", size = 220825, upload-time = "2026-07-31T11:29:43.553Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e6/f46571f38765dbc4cbc0d0b47de8db65768006dbbd4340e6f5f51bc1d895/websockets-17.0.1-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:e95e321d0d763f2b6633512605f6112ebd70d5746f3ce05c941909d4a25233f2", size = 219427, upload-time = "2026-07-31T11:29:44.731Z" }, + { url = "https://files.pythonhosted.org/packages/9d/31/6ff1fee057bd7e9dd5237fc064a749615378d003aa045b5bfc2d12b2f4f7/websockets-17.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cd526c8228e759c1006c4b7c9ac71dc4e925ced1a6a6a5a8e94643709738f63e", size = 220198, upload-time = "2026-07-31T11:29:45.997Z" }, + { url = "https://files.pythonhosted.org/packages/7a/4f/d41847227a44b9ad87c3d5a9fddbfad8b7c4d6032878d8460d9d37c2d44f/websockets-17.0.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8cd3369e42c0246afaf9d669cfc19797e3a49e8c0a639544459c57597108b966", size = 221304, upload-time = "2026-07-31T11:29:47.314Z" }, + { url = "https://files.pythonhosted.org/packages/63/30/21a7e326c6ad2eb526cd5b816383d59cdeb28b8805b65a543c3cfbd8e8ce/websockets-17.0.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b580794e926cab7ff42ee4371ef14e0b22cb2bb722a607f77769136468f49a3f", size = 218858, upload-time = "2026-07-31T11:29:48.587Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/55b0331cd5bec9ce29748f79edc00075805450a47011d0c8e3b1c61dbf04/websockets-17.0.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5033ffe6804dd53afafa7d08e8c3eef2d2431f34d58ca30507a8442dd04a033a", size = 219840, upload-time = "2026-07-31T11:29:49.791Z" }, + { url = "https://files.pythonhosted.org/packages/78/6e/2e8bc06e546f49b32a58a2bc2957902d1809ecc37552d3d7ccd6639a126e/websockets-17.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6db9e5bf3649ab506c6ae8a3ac85a00fb1ae3816d75962771b2df8adbc5d40d2", size = 220116, upload-time = "2026-07-31T11:29:51.026Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ad/4bac01fa41aca54307157b9c9f68b066a6bb51fb18716ba618078a67b283/websockets-17.0.1-cp312-cp312-win32.whl", hash = "sha256:bc0bca48ba24c6c866847fd20478a51dd547fa0ad258dab9615c414ec534bbc0", size = 213050, upload-time = "2026-07-31T11:29:52.328Z" }, + { url = "https://files.pythonhosted.org/packages/82/d8/c3a78cccc74a554780e9e76e323d5cde891048627025f0f82623e22dc3df/websockets-17.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:2b3f3020171202b135ca078e20434977c6b2b02af647130d6980c9e39b9462e3", size = 213348, upload-time = "2026-07-31T11:29:53.891Z" }, + { url = "https://files.pythonhosted.org/packages/7b/25/e1b8824bd632c8a5a62d504b61e9e35e470b67e4be0206f5c28f90c7f86d/websockets-17.0.1-cp312-cp312-win_arm64.whl", hash = "sha256:41d6aa06b5ab832aee72fedf47a149535b121ac900b6bb4d3fe14712afac9a79", size = 213276, upload-time = "2026-07-31T11:29:55.299Z" }, + { url = "https://files.pythonhosted.org/packages/ba/a8/79c577bc2f874ee22f6f5ccdab97ba9ce6b96806be3fcc3a6d8490f88a21/websockets-17.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:55b12e47dcee83673a40d07686cfb6f9d6dfc285976ade9463f61d2bef3fad22", size = 212593, upload-time = "2026-07-31T11:29:56.518Z" }, + { url = "https://files.pythonhosted.org/packages/db/99/e1cfaf419bb3b2fcfd6792a846f1d936293132b0b9a56530ced016c83c7b/websockets-17.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c1c118a6b0e25bfc9a6802075d748fa6321714ffbdf3c88d29d9a0e3c7386c75", size = 210280, upload-time = "2026-07-31T11:29:57.768Z" }, + { url = "https://files.pythonhosted.org/packages/a2/ef/cc994494bf7d97e41833f6ff55c24f535e4d527a10370b9631737e9c2f00/websockets-17.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:734d20364dc2cfe03674883cafcf580b6e431c5ce42b476312b9285310230cf9", size = 210538, upload-time = "2026-07-31T11:29:59.021Z" }, + { url = "https://files.pythonhosted.org/packages/87/32/fbf2d132f63ba3e67f675bccf333469786a24e0418969ce1d8e6ff9e6f02/websockets-17.0.1-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9493314a99e599163c854fb5900ad7f7ea38c5cb9d9103aa30b3c6b8181c01fa", size = 219925, upload-time = "2026-07-31T11:30:00.298Z" }, + { url = "https://files.pythonhosted.org/packages/16/50/64eee3d25a47fe744a9490e0627cc373dca096755db740f91c28bd61cd35/websockets-17.0.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:18ded646ce98cdd3c0235825b3252f1df55765ba49b616bb10282f758667b4d0", size = 220206, upload-time = "2026-07-31T11:30:01.52Z" }, + { url = "https://files.pythonhosted.org/packages/15/56/10ed4bc4dd75f204e3c62bd4898e44a8742a27773c80b188cfa7888aad2d/websockets-17.0.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1bec5d6a19f5fbe87e4940739cfc65e7bb53d8b353e1029b8037a1653b321bc", size = 221445, upload-time = "2026-07-31T11:30:02.788Z" }, + { url = "https://files.pythonhosted.org/packages/bf/be/bb14328614c068ab09569962fbf218fc00413ce3febc6d2684c764b6f37e/websockets-17.0.1-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:872273e629ca7e3d35f16a2dc6ede84e1d5c831e616b8277de6e4f83114e7c58", size = 222887, upload-time = "2026-07-31T11:30:03.943Z" }, + { url = "https://files.pythonhosted.org/packages/32/1b/4cb0eec2fee310007104687493175af190019f705940c864f9c523fe9f6f/websockets-17.0.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1df81d174c1561292de9e40b141cafc04f69077272f6c352afe1d743e20810df", size = 222072, upload-time = "2026-07-31T11:30:05.258Z" }, + { url = "https://files.pythonhosted.org/packages/6c/9c/14e6391de777ddb39c439c450deb551406d445e25a5877d6fa25c49d4544/websockets-17.0.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:759adeb5b0c5775b563254ec63b5b79089fc0045b479143a0b1b8c0ebaae1253", size = 220826, upload-time = "2026-07-31T11:30:06.53Z" }, + { url = "https://files.pythonhosted.org/packages/cb/57/96e94e384442247bbed5d3ab67381c7257355c2d66b62c3ad33a17f5d385/websockets-17.0.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1d99db29b5444e3982f1ce2ba8a833508ad44b2f1fbd0bd99e81d825c0b461", size = 218107, upload-time = "2026-07-31T11:30:07.766Z" }, + { url = "https://files.pythonhosted.org/packages/c0/8c/9c9dedd14c3919435df9b35cdee7111268c751252b87652f3a6a4f56e760/websockets-17.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:02ed63bf26dda9fa27df730a41f6664586c4ee05972c8fb667ce1725b3fd13d3", size = 220889, upload-time = "2026-07-31T11:30:09.035Z" }, + { url = "https://files.pythonhosted.org/packages/94/4d/ca73c2ac82c00f50c529784bacb323e42da4816333211bc1543d90c9cf11/websockets-17.0.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:eab6de8a98b9a7772cf686d00b4de439fc7efb8ab05ae106ef227291d06f87c5", size = 219486, upload-time = "2026-07-31T11:30:10.289Z" }, + { url = "https://files.pythonhosted.org/packages/5b/da/fb37ac09dcd7c69dd73bac979ed393df35f78a3c232e293d1ff3bd586d24/websockets-17.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2a855b6dfe21c4d3420be265ae031829ba8ba0be0ea350d9f7c3ef30ae63ebe2", size = 220258, upload-time = "2026-07-31T11:30:11.605Z" }, + { url = "https://files.pythonhosted.org/packages/fc/04/9693f191d968a93f37326a17301a101d49580889c688f466699f89ecdee1/websockets-17.0.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7002d5f9e1c3ddd991cdfdbfee18cc8c8b196b2445022892badacd6cb338bbbc", size = 221358, upload-time = "2026-07-31T11:30:12.858Z" }, + { url = "https://files.pythonhosted.org/packages/cf/29/ad0d85c01db5dcf22898d51648bd2c25af0dd0a4a41c550b11acddeeeba7/websockets-17.0.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c395bda8e7d8f51a02e80261fb57127979e5c472675d9a96b2860619ad47da48", size = 218921, upload-time = "2026-07-31T11:30:14.064Z" }, + { url = "https://files.pythonhosted.org/packages/18/3b/bf8e855e495dcca63f2b8aa019cf2ada3160e1fa66d833c7417f3b1f7f38/websockets-17.0.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aadc298969ad229d8e3029fc5cc751fdad286696230f9cf014e90ff9cd8e6ea0", size = 219871, upload-time = "2026-07-31T11:30:15.358Z" }, + { url = "https://files.pythonhosted.org/packages/3b/db/c7abd6639a93a40279cd1ddc57e09e1c4f8381c4cfccdb775aa5aac9770a/websockets-17.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f11a398d8170b7ac5000baf7f258dcda579ef3ea744e0cc6a165e0dfbc0d3198", size = 220154, upload-time = "2026-07-31T11:30:16.96Z" }, + { url = "https://files.pythonhosted.org/packages/f6/2a/25a9f8f2e5a6ef34e911d2f55d9f756bdeb92b4c28cfb77b8430bbc73cb1/websockets-17.0.1-cp313-cp313-win32.whl", hash = "sha256:846a4a8b0833e3cad57523d9e3bd50ec8ea05ab9d06c582f82a1340ba096af5f", size = 213038, upload-time = "2026-07-31T11:30:18.434Z" }, + { url = "https://files.pythonhosted.org/packages/81/2f/ea1380f72bb11b64fc5bc7ae0d42de5bbf3e6dc13b965706b2a1d4e17cdf/websockets-17.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:409d93efcaa14f7a99592c5baaef5ec6ca94fba0f5aec1a86f693977c69c9c1c", size = 213348, upload-time = "2026-07-31T11:30:19.693Z" }, + { url = "https://files.pythonhosted.org/packages/e6/c7/b956ed9151c3c74530ebc62d716fbfdbde7507a6acc6423a64f9ecfb6b8a/websockets-17.0.1-cp313-cp313-win_arm64.whl", hash = "sha256:90246fa9e6cb192a778ce6ce024057ec54317a894db7899c922dcdc1f4cbf6a5", size = 213282, upload-time = "2026-07-31T11:30:21.045Z" }, + { url = "https://files.pythonhosted.org/packages/98/dc/cadab608924ac605647031472fb1f8792d7d4ea07565ba1899ec42028e0d/websockets-17.0.1-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:53b90c00bc6201ab6695c7ff51a04d0e425514c37515e9eeecd2c1b978ac6c0e", size = 212640, upload-time = "2026-07-31T11:30:22.436Z" }, + { url = "https://files.pythonhosted.org/packages/16/7a/b034d13ca181211bbd58bb50835cb196a7784cd505b5a2079d4d03374f9f/websockets-17.0.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5f33a649bfcb8312524173cc4bbafa7dbb236e18eee9aa31a1d324ca0ddda28c", size = 210332, upload-time = "2026-07-31T11:30:23.608Z" }, + { url = "https://files.pythonhosted.org/packages/2f/4d/943ede39b53744768edf1ed84a3f9401527388228a3d6c1249c02c3d6bd7/websockets-17.0.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:cddc675ec31bca65473321f9a9794e488b43b3b8de5d02c8ef4810c5d5792163", size = 210546, upload-time = "2026-07-31T11:30:24.932Z" }, + { url = "https://files.pythonhosted.org/packages/cf/e3/88dc159d2ae66743c669443246243f28d873b0c5e58271b8cc1ca0440334/websockets-17.0.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b3ff0ad440ad52dda64138f16895f66403f40192365e39b1010e889f289746b0", size = 219928, upload-time = "2026-07-31T11:30:26.221Z" }, + { url = "https://files.pythonhosted.org/packages/fe/f2/ff27eaefa15851a5cf7f004ab827a022bf2d6632cb520f89cb100db7e84b/websockets-17.0.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:72d7f2a5aeb4e82daa4ee18f125b4277f427033359be5c745ad709608446cc2c", size = 220279, upload-time = "2026-07-31T11:30:27.49Z" }, + { url = "https://files.pythonhosted.org/packages/19/2e/a5166149f363d2449c1cb2dde6486a245521979509d53b87a09f3e79662b/websockets-17.0.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6fd88365da261c53d3e943fb37e0d0721b9cde119f6b2e3fc84369b6ab234d63", size = 221525, upload-time = "2026-07-31T11:30:28.872Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/f46931269b3ff3bde65d27c65ddb22f9bb8ce92ac2c6c4df0910128f6219/websockets-17.0.1-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ab9f962a5b64a5c3c845d556b7dc4e6fb683f7b67179f8205e814bb2e0213ffe", size = 222897, upload-time = "2026-07-31T11:30:30.164Z" }, + { url = "https://files.pythonhosted.org/packages/42/f4/deccf3439f35df953ec35e13fe07986821c5f1ab5785d69614283bdb9034/websockets-17.0.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8c07f145d0b9e90cbd96035f31fb79199aef4da1872854e36ebeb258e3d57594", size = 222129, upload-time = "2026-07-31T11:30:31.489Z" }, + { url = "https://files.pythonhosted.org/packages/35/a5/e1b57a59da92ade37fd021567a17b518ea8267b28e5530075844cdb525fe/websockets-17.0.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9f7747d3daa41a11f25f7cca5dc988fc51da97b311bed4c9d843860f79779283", size = 220875, upload-time = "2026-07-31T11:30:32.805Z" }, + { url = "https://files.pythonhosted.org/packages/f0/30/e7d0889c790a854156de424575fd67af79ddbaed9ff3157ae863dfd1c1dc/websockets-17.0.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:2abb1ba0a5133b7d2ef3c1c9f4b0c1e8a101012dce0b594ab2b2888d9a64820e", size = 218160, upload-time = "2026-07-31T11:30:34.512Z" }, + { url = "https://files.pythonhosted.org/packages/09/2e/43db785d6ed9ae7594fae7b62bbc9cb4dfee2b015e06a1005f1e5ce283b6/websockets-17.0.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f3fd9a1f87f8f0f3f8e9f9bd0195f7516562d13f5b178db8c5784d1f60b60bed", size = 220951, upload-time = "2026-07-31T11:30:35.806Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f5/4ac3cab3d5e8a830657a822f64a8910e3803229c6783e59c3fd9a3487427/websockets-17.0.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:2bc14b481e05e331811108daa1aeb41a5e237a5564ef2f02ec5a356a0f102f78", size = 219460, upload-time = "2026-07-31T11:30:37.273Z" }, + { url = "https://files.pythonhosted.org/packages/03/0e/c3a4020673ffc17c82cf1a467835038a196a555d3b4f2a50f0f063cf8ccc/websockets-17.0.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:57d2ee9b24b404ce75f3814f92073c0ed88106c950148d2427fe8d25ca254d1f", size = 220248, upload-time = "2026-07-31T11:30:38.527Z" }, + { url = "https://files.pythonhosted.org/packages/7d/87/e47a6a278cc1dfade38444c893ce18322943c25d4b780a74450d9d164be1/websockets-17.0.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:1b363bfd72a52c0658a3154a4cff219f15a474b35a235057d38853bf151acce7", size = 221421, upload-time = "2026-07-31T11:30:39.879Z" }, + { url = "https://files.pythonhosted.org/packages/da/8f/473d5fc4e3836e375b0233c6ef26777e6e5e3f7bfc84ccd524eae4090ed5/websockets-17.0.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:10b1587c599fa0f2c89154587c80e0fda98ade6c9fa8c0260a2823fb1800b685", size = 218975, upload-time = "2026-07-31T11:30:41.192Z" }, + { url = "https://files.pythonhosted.org/packages/d4/b9/819ec2dcdf69031d7e9cab11247f3a6ff9bbc8c7c53ada1dbcb9055b227b/websockets-17.0.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d7d72843691f50b91127c50688df10cb72ec6f4c4b1d7e2c11ab33b16acf8e51", size = 219925, upload-time = "2026-07-31T11:30:42.524Z" }, + { url = "https://files.pythonhosted.org/packages/85/b9/6c0da301f6118502e079cf92f4e864adf28e56b3f8c0f6085076ced7b876/websockets-17.0.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:90973a3a00f23afdfd1c9b06fb84289bf0220f247ef8a62501a1967c7af54f7b", size = 220218, upload-time = "2026-07-31T11:30:44.04Z" }, + { url = "https://files.pythonhosted.org/packages/55/f9/cba32dc9dd856565263d6272f594255bd0e2781deb8cd982c026a54760ad/websockets-17.0.1-cp314-cp314-win32.whl", hash = "sha256:599b03beb77633bffc095334338fad79cafc2b01fbd58953838130a9ae967d7b", size = 212626, upload-time = "2026-07-31T11:30:45.579Z" }, + { url = "https://files.pythonhosted.org/packages/fa/95/91cdd8c192287d7ea741f37cf7d64fdc1a14410f06f73805e428a1a590af/websockets-17.0.1-cp314-cp314-win_amd64.whl", hash = "sha256:81ce19c6046ace11da7001781be7317bb1dc389f399af4b2ed962190f76f9add", size = 212969, upload-time = "2026-07-31T11:30:46.983Z" }, + { url = "https://files.pythonhosted.org/packages/8e/fd/8c98a1e431960661c5769ab1a4dd66494e87ab02d791cc79e51e0d9a289f/websockets-17.0.1-cp314-cp314-win_arm64.whl", hash = "sha256:efe0ae052a8d023b87198921e8a7ce1dc7768816bcd2fbc20df171ac73a04891", size = 212850, upload-time = "2026-07-31T11:30:48.304Z" }, + { url = "https://files.pythonhosted.org/packages/13/c1/142f5186ee7dc3beee0426b998a79e223e067b7689afcaa95890d64aa800/websockets-17.0.1-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:ab56439c9f74c52770690c7b2f616b3bf775cb3920453ee355ac765c032d8bbf", size = 212967, upload-time = "2026-07-31T11:30:49.688Z" }, + { url = "https://files.pythonhosted.org/packages/02/de/4b03ed316c9dee180365286c298219809ff247be39beeaaf9958b21167ab/websockets-17.0.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:20a92f78ac8250984ed459faa9ca48c285adbfc0038ddc3fdac6046990a9c9ed", size = 210504, upload-time = "2026-07-31T11:30:50.987Z" }, + { url = "https://files.pythonhosted.org/packages/cd/d8/ad2b3e8f867e1e8cac3077e2f33ffb60b71bd763d6cfc71bd916f113c3bf/websockets-17.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6a434e59962a4fb9016bea327e1d14d6cd67670ecfb8942b4f4a0c24036634ce", size = 210702, upload-time = "2026-07-31T11:30:52.261Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/7a77a82ce9d6f831c07b176da3942f7e71acd0f115f3ecdb1d00a040eb01/websockets-17.0.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2503c7e2a5049a12d5dac917a46d5d52591283a766165b8176bb167560421b38", size = 220290, upload-time = "2026-07-31T11:30:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/f5/af/43c3e3c3ea7ba4693c2181743f3221957df28bededadcbd9fc8a0661bde0/websockets-17.0.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:28012a54510fe8301bb893ef143cec30a2780a2d3bc20b7bbdf4379d7a63945d", size = 220573, upload-time = "2026-07-31T11:30:54.966Z" }, + { url = "https://files.pythonhosted.org/packages/1f/bd/ed48eca15725743ee7e2dc172e15c61de29e85ec98dace7b14257f366836/websockets-17.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:22bd00f8bae2bccdb5dbe41e20f58ba44ca9fff0b4b561aaf39099c35da762ed", size = 221747, upload-time = "2026-07-31T11:30:56.762Z" }, + { url = "https://files.pythonhosted.org/packages/99/50/838deb7937a8225c4925dd4a977eafea473fabf444178a99de0bc7e92bb0/websockets-17.0.1-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e98ec9ec61cce5bc4b8b218322ad090b0994eb060bb04da704c62ef0a3d864e6", size = 223891, upload-time = "2026-07-31T11:30:58.127Z" }, + { url = "https://files.pythonhosted.org/packages/f8/e9/657fb70c6eb6bcd01adfa5d2b06496e9911e1c1a8813d353b8c00f7591cd/websockets-17.0.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8e387adb0c692c6b5571bdeafc8ac9d1901ea30f10309134780b16ecd35e6605", size = 222317, upload-time = "2026-07-31T11:30:59.416Z" }, + { url = "https://files.pythonhosted.org/packages/07/4c/82cb722afa5428fed981331210c4c07600570db01bb1620579f655b5adaf/websockets-17.0.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd1470d2c53fe53269bf5619da7725d30dd9b9693f1689f7a85eab8dea734442", size = 221047, upload-time = "2026-07-31T11:31:00.757Z" }, + { url = "https://files.pythonhosted.org/packages/90/84/bd6d67d6bc65f0de0cb50de55dab42f256a9876a351c4736522eb168fda0/websockets-17.0.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:884af729b8ab50486acd94d9768c2b60914bf39b579ebba0a5cb73bfdfd61fd2", size = 218626, upload-time = "2026-07-31T11:31:02.48Z" }, + { url = "https://files.pythonhosted.org/packages/96/cb/6a372c8553976f0d8f97f5115826ed47e34b3be6b8bf0d0249af249a7416/websockets-17.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a60fa1a25cca1bcc2bf87b8d6be37a741f0a3239fb5e9cfb7a37173b68ffcf87", size = 221299, upload-time = "2026-07-31T11:31:03.795Z" }, + { url = "https://files.pythonhosted.org/packages/bc/31/f966e8472337974f74d788b3ef6c6f3b8b9a5f201efd16a843c91d269fa5/websockets-17.0.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:e8208f2729cba030ff872a92064c97584eeb9502f53d32a05a0f05d5a17ca6c6", size = 219789, upload-time = "2026-07-31T11:31:05.08Z" }, + { url = "https://files.pythonhosted.org/packages/57/34/404e83a6cc7b0efcac810b7041bffd72ff76900e6fd0aa45a26c92fb2ffe/websockets-17.0.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:54cdcaa56f5d3eafd57058f0fa4a3de93a310b43a3c4699f06efc4c0bd054a5a", size = 220678, upload-time = "2026-07-31T11:31:06.635Z" }, + { url = "https://files.pythonhosted.org/packages/e5/70/8946188c2a68d67251859b589a3634918cf7867bf0b891347a5ecaa43d30/websockets-17.0.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:4d41c0a1d47a478bc432b3b9068097bee1ce0c5b19327ea6f75c2ab34ab1f2fb", size = 221697, upload-time = "2026-07-31T11:31:08.023Z" }, + { url = "https://files.pythonhosted.org/packages/04/16/ee73fc2083a2938ac6209f4ec804960496835b20a0068dbcfe8424957c04/websockets-17.0.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f991247276797d0c61ab7770bc9791eadc16f683b4d83517f624932adc1a8bab", size = 219390, upload-time = "2026-07-31T11:31:09.378Z" }, + { url = "https://files.pythonhosted.org/packages/94/6a/d5f88033c69932af6cdaa72da62516ade47c257e3bf69f4c0ba5f40e12a2/websockets-17.0.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:733e3cc7171fa1b899edbe725ef9382d0e960657dc1fd933f3281ae910c01dab", size = 220161, upload-time = "2026-07-31T11:31:10.915Z" }, + { url = "https://files.pythonhosted.org/packages/e1/2e/6183dd2c0370287ecf4afe0bb33aca364208e5e7b0e1a286adcaecc0c78b/websockets-17.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:810cb3fb5fa6e447216f4e82d9a85cb8aed0929ae3538153ddfe8a6e3121a58d", size = 220591, upload-time = "2026-07-31T11:31:12.289Z" }, + { url = "https://files.pythonhosted.org/packages/26/93/70f6516d85b9744f7eac224c4b1b9ef4e84133f80b53be02080cb1c3e663/websockets-17.0.1-cp314-cp314t-win32.whl", hash = "sha256:17ac37716c0244e82c9e384c41653c090b1864c6610224ca3857e7f7b58fce10", size = 212755, upload-time = "2026-07-31T11:31:13.883Z" }, + { url = "https://files.pythonhosted.org/packages/f1/2c/9d9c1da5a7ea9af307b386d25f64d1dead4729644198d2b92e36db5dfd41/websockets-17.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:bb31f42ea095ea826463c770829aa188a86c9a5c976b1467cbbf583c811de833", size = 213094, upload-time = "2026-07-31T11:31:15.367Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/a585e7573e128070605d003b5544729bcd58d9756c7e99d550818ca4b916/websockets-17.0.1-cp314-cp314t-win_arm64.whl", hash = "sha256:dbfae8e75b342e31fc6fd1a8bbb393b7cbb91d6cfd581650300a94381e7b7e2b", size = 213009, upload-time = "2026-07-31T11:31:16.776Z" }, + { url = "https://files.pythonhosted.org/packages/51/77/63b4abd29f15107d856f010de6f35434faa7c49ef89151e051d2807a9c40/websockets-17.0.1-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:49266e4488309b38783257293a38298942b9a03aa106fcb45195377a77c0c1e2", size = 210194, upload-time = "2026-07-31T11:31:18.046Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ab/6160542ee644f72b865af13ddfff23740c95595b237e16312262cafdb641/websockets-17.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d69fd559f9f0e8a52d2fce6f04ee143f86e70df0a189cd95164eddac599e810f", size = 210465, upload-time = "2026-07-31T11:31:19.333Z" }, + { url = "https://files.pythonhosted.org/packages/53/1a/d4437d3cb0691eeac2c6064e21c83a9eeda04f6ef0261abfc0dde708590d/websockets-17.0.1-pp311-pypy311_pp73-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a39ce3a7b0e6059be093213d637963101380157bcbad355916738fafb490698d", size = 211417, upload-time = "2026-07-31T11:31:20.687Z" }, + { url = "https://files.pythonhosted.org/packages/88/28/2d671e23a20359a1cc142848384659813b49028d46cb0acdc28e81ac59b5/websockets-17.0.1-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2437d4ca208cc0f246d3a2297ae7474b4ba18261aaf5b9c79c84c031ecf348e1", size = 211309, upload-time = "2026-07-31T11:31:21.969Z" }, + { url = "https://files.pythonhosted.org/packages/02/52/b85a676b161991e5c0d884252376435f60510789e7740e3da73489d22a6e/websockets-17.0.1-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6740be6d1bab69f08ab52cb15b08f76c143b6fe61c580ba62bd929f3ab7a1d42", size = 212203, upload-time = "2026-07-31T11:31:23.38Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/91e297e41381d9131f3142a9c1a50389fd96b98125ce9b81526fa4e14b9f/websockets-17.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:afbce6e3f0fac32dc87c2a0d84869d1a706460d64f39f3889386413e6e4d3d26", size = 213434, upload-time = "2026-07-31T11:31:24.707Z" }, + { url = "https://files.pythonhosted.org/packages/09/ce/3929538b2b9918f5eee623fbf3346893973191f6df93f19bbda097bd7bb7/websockets-17.0.1-py3-none-any.whl", hash = "sha256:c6be9cba65c65cc76dfa3d4619e359ff02a4476c74e179b215236c11a0b32345", size = 206718, upload-time = "2026-07-31T11:31:26.037Z" }, +] + +[[package]] +name = "zipp" +version = "4.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, +]