fix: seven defects an adversarial audit found, and the gates that missed them
Release / test (full, windows-latest, 3.11) (push) Canceled after 0s
Release / test (pdf, ubuntu-latest, 3.11) (push) Canceled after 0s
Release / test (pdf, ubuntu-latest, 3.12) (push) Canceled after 0s
Release / build (push) Canceled after 0s
Release / publish-pypi (push) Canceled after 0s
Release / github-release (push) Canceled after 0s

Every one of these was reported ok/clean to the caller while being wrong.

Silently wrong numbers
- selection_filter(min_area) read `entity.dxf.get("area", 0.0)` for SOLID,
  TRACE and HATCH, none of which carry that attribute, so every hatch
  measured 0.0. A 1600 mm^2 hatch was excluded by min_area=1.0 while
  `filtered_by` asserted the filter had run, and COM (which reads ActiveX
  .Area) returned a different selection for the same call. It now answers
  from the measurement `entity_measure` uses, so the two cannot disagree.
- Every headless dimension rendered through ezdxf's bare `Standard` style:
  1.0 mm text with a comma decimal marker, against ISO 3098's 2.5 mm minimum
  and ISO 129's point -- while the document header itself said $DIMTXT 2.5.
  `ezdxf.new(setup=True)` is not the fix; measured, it renders a 100 mm
  dimension as "10000". The attributes are now stored explicitly.
- Saving writes `dimrnd = 0.0` onto the Standard style, and ezdxf applies
  `xround(value, dimrnd)` whenever the attribute is present at all --
  `xround(33.333, 0.0)` is 33. So on any reopened file, including one this
  server saved, every dimension was rounded to a whole unit before DIMDEC
  formatted it: a 12.75 mm feature dimensioned 13. AutoCAD means DIMRND 0 as
  *no* rounding; the sentinel is now discarded at every load site, and a real
  0.5 mm rounding is left alone.
- The DIM* header fold gave out-of-range values teeth they did not have when
  they died in the header: dim_decimals=-1 returned ok and then killed the
  next dimension with `ValueError: Format specifier missing precision` from
  inside ezdxf, and 40 printed forty digits of float noise onto the sheet.
  Ranges are now AutoCAD's own (DIMDEC 0-8, DIMZIN 0-15, lengths positive).

Security
- security.py recorded the policy as "sanitize_command and sanitize_lisp each
  have exactly ONE caller ... the policy is scoped to the CHANNEL". The
  premise was false. `_ensure_linetype_loaded` and `ComBackend.linetype_load`
  build `_-LINETYPE _LOAD {name} {file}` and hand it to SendCommand, which
  treats a newline and ';' as segment separators -- so a `linetype=` argument
  carrying either injected further AutoCAD commands, and the 36-verb denylist
  never saw that channel. Guarded with the DXF symbol-name rule (the
  injection characters fall out of it rather than being enumerated) plus
  validate_path for the file argument, which was also the one path-taking
  tool reaching the filesystem unchecked. The policy comment is corrected.

Gates that could not see
- drawing_critique returned zero issues on a quarantined document: all seven
  checks wrapped their backend call in a bare `except Exception`. CLAUDE.md
  rule 7 makes that call the gate an agent is told to trust. Only the
  quarantine now escapes; an ordinary broken check still degrades.
  `_check_dimension_legibility` also read `_doc` directly from the event loop
  thread, missing the gate and performing exactly the unsynchronised access
  the quarantine exists to prevent. `construction_clear` answered
  "deleted: 0" -- there was nothing to clear -- for the same reason.
- The iso128 focus checked layer lineweights only, so a sheet lettered at
  1.0 mm critiqued clean. It now reads the effective dimension height and
  separator, header-then-style, in the order the renderer resolves.
- check_doc_numbers.py derived five values it never checked, and those were
  the ones that had drifted. The collected-test count (README said 1274, the
  tree had 1369) and the A/B pass-rate and Fixed columns are now gated.
- benchmarks/README.md's v1.4.0 table was impossible on its face -- 21
  passing plus 5 fixed is 26, not 24 -- and contradicted the report it
  linked, because `ab-v1.4.0-vs-v1.5.0.json` was regenerated from a tree that
  already carried the dimension fixes and so never measured the v1.5.0 tag.
  Replaced with a real run: ab-v1.4.0-vs-v1.5.1.json, 21/26 -> 26/26.

Release gate
- release.yml ran one ubuntu/3.11 lane, so a Windows-only defect could reach
  PyPI with no Windows job having executed. That is not hypothetical: v1.5.0
  shipped `except pywintypes.com_error` raising NameError wherever pywin32 is
  absent. It now runs the same three lanes as ci.yml, Windows included.

1368 passed, 1 xfailed. ruff clean. Tool inventory and all 16 gated document
numbers match the tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Umut CAN
2026-08-06 23:07:50 +03:00
co-authored by Claude Opus 5
parent 6b9860e68e
commit ca1c79fd01
28 changed files with 3376 additions and 148 deletions
+21 -3
View File
@@ -19,20 +19,38 @@ env:
jobs:
test:
runs-on: ubuntu-latest
# The same three lanes as ci.yml, not one. This job was a single
# ubuntu/3.11 run, so a Windows-only defect could reach PyPI without any
# Windows job having executed -- which is not hypothetical: v1.5.0 shipped
# with `except pywintypes.com_error` raising NameError wherever pywin32 is
# absent, and only the Linux legs of ci.yml caught it.
strategy:
fail-fast: true
matrix:
include:
- os: ubuntu-latest
python-version: "3.11"
extra: pdf
- os: ubuntu-latest
python-version: "3.12"
extra: pdf
- os: windows-latest
python-version: "3.11"
extra: full
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
id: python
with:
python-version: "3.11"
python-version: ${{ matrix.python-version }}
- 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 sync --locked --extra ${{ matrix.extra }} --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
+6 -1
View File
@@ -1,6 +1,11 @@
# The ruff version here MUST equal the `ruff==` pin in pyproject.toml's
# [dependency-groups] dev. It was v0.8.6 against a project pin of 0.16.1, and
# ruff 0.16 formats Python inside Markdown while 0.8 does not -- so anyone who
# ran `pre-commit install` produced commits that failed CI's
# `ruff format --check .`. tests/test_release_consistency.py pins them equal.
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.6
rev: v0.16.1
hooks:
- id: ruff
args: [--fix]
+29 -16
View File
@@ -13,7 +13,7 @@ Live through COM on Windows, or headless through ezdxf on any platform — one t
[![License: MIT](https://img.shields.io/badge/license-MIT-3fb950)](https://github.com/U-C4N/Autocad-MCP/blob/main/LICENSE)
[![Stars](https://img.shields.io/github/stars/U-C4N/Autocad-MCP?style=flat&color=e3b341)](https://github.com/U-C4N/Autocad-MCP/stargazers)
[Install](#install-in-10-seconds) · [Evidence](#evidence) · [Two engines](#the-two-engines) · [Limits](#what-this-release-is-bad-at) · [Config](#configuration) · [Changelog](https://github.com/U-C4N/Autocad-MCP/blob/main/CHANGELOG.md)
[Install](https://github.com/U-C4N/Autocad-MCP#install-in-10-seconds) · [Evidence](https://github.com/U-C4N/Autocad-MCP#evidence) · [Two engines](https://github.com/U-C4N/Autocad-MCP#the-two-engines) · [Limits](https://github.com/U-C4N/Autocad-MCP#what-this-release-is-bad-at) · [Config](https://github.com/U-C4N/Autocad-MCP#configuration) · [Changelog](https://github.com/U-C4N/Autocad-MCP/blob/main/CHANGELOG.md)
</div>
@@ -29,7 +29,7 @@ Live through COM on Windows, or headless through ezdxf on any platform — one t
---
> **v1.5 release snapshot:** 154 tools · 6 resources · 5 prompt templates · 1258 collected tests.
> **v1.5 release snapshot:** 154 tools · 6 resources · 5 prompt templates · 1369 collected tests.
> 154 is the **registered** count. A default install **advertises 149** over `tools/list`, because
> `ENABLE_3D` is unset and the five `solid_*` tools stay hidden. `system_about` is the runtime authority.
@@ -104,7 +104,7 @@ Works with Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp
<tr><td><b>Geometry</b></td><td>lines, arcs, polylines, splines, hatches, trim/extend/fillet/chamfer, handle-preserving edits</td></tr>
<tr><td><b>Annotation</b></td><td>ISO 129 toleranced dimensions, ISO 286 fits (<code>fit="H7"</code>), TABLE, MLEADER, GD&amp;T frames and datums (ISO 1101)</td></tr>
<tr><td><b>Engineering generators</b></td><td>involute gears (front + section A-A), DIN 6885 keyed bores, ISO A3 title block</td></tr>
<tr><td><b>Discovery</b> ✨</td><td><code>search_tools</code> ranked over an AutoCAD command and synonym corpus — <code>FILLET</code>, <code>BPOLY</code>, <code>QSELECT</code>, <code>WBLOCK</code>, <code>OVERKILL</code>, <code>CHSPACE</code> each rank <b>#1</b> of the 149-tool advertised catalog</td></tr>
<tr><td><b>Discovery</b> ✨</td><td><code>search_tools</code> ranked over an AutoCAD command and synonym corpus — <code>FILLET</code>, <code>BPOLY</code>, <code>QSELECT</code>, <code>WBLOCK</code>, <code>OVERKILL</code>, <code>CHSPACE</code> each rank <b>#1</b> of the 154-tool registry</td></tr>
<tr><td><b>Batching</b> ✨</td><td><code>cad_batch</code> runs a whole step list in one round trip; <code>fields=</code> projects the response on 11 result-heavy tools (a closed, gated list)</td></tr>
<tr><td><b>Paper space</b> ✨</td><td>8 of the 12 layout tools are new — <code>layout_delete/rename/copy</code>, <code>viewport_list/set_scale/lock/delete</code>, <code>entity_change_space</code> (CHSPACE, headless only); layout listing, creation and activation, <code>viewport_create</code> and <code>drawing_export_pdf(layout=...)</code> shipped in v1.4.0, which wrongly declared headless viewport rendering impossible</td></tr>
<tr><td><b>Selection</b> ✨</td><td>window vs crossing stated back to the caller; the polygon is tested as a shape, not as its bounding box — the entity side is still <i>its</i> bounding box, on both engines</td></tr>
@@ -144,8 +144,9 @@ There are **27 capability keys**. `system_capabilities` returns the live map, an
| Viewport model-content rendering | ✅ | ✅ ᵐ *(no viewport borders)* |
| 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* |
| HATCH filled area (islands subtracted) | AutoCAD's own number — *measured: a r20 hatch with an r10 island returns 942.478, so the island is subtracted* | Loops walked, `hatch_style` reported |
| REGION area | ✅ *`.Area` + `.Perimeter`, verified on a seat* | — *ACIS is opaque to ezdxf* |
| 3DSOLID **surface** area | — *ActiveX exposes `.Volume` only; use MASSPROP* | — |
| 3D solids | ✅ *only with `ENABLE_3D=true`* | — *no headless ACIS* |
| DWG write | ✅ | — *no DWG writer* |
| WIPEOUT | — *`AddWipeout` verified absent, AutoCAD 2026* | ✅ |
@@ -190,7 +191,7 @@ Competitors are driven **black-box over MCP stdio** against the tool contracts r
| v3 task | Verified against |
|---|---|
| `tool_discovery` | six AutoCAD command names, each ranking **#1** of the 149-tool advertised catalog |
| `tool_discovery` | six AutoCAD command names, each ranking **#1** of the 154-tool registry the harness ranks (pass bar is top-3) |
| `token_budget` | 40,305 → 356 tokens advertised, against a 2,000 ceiling fixed in advance |
| `hatch_islands` | **300** filled with the island, 400 ignoring it |
| `selection_filter` | window 1, crossing 2, bounding box 3, polygon 1 |
@@ -200,17 +201,26 @@ Competitors are driven **black-box over MCP stdio** against the tool contracts r
26 deterministic headless checks against the previous tag and the current tree, each in its own subprocess so a hard crash counts as a miss rather than killing the run.
**v1.5.1 release gate**, baseline `v1.5.0` ([report](https://github.com/U-C4N/Autocad-MCP/blob/main/benchmarks/results/published/ab-v1.5.0-vs-v1.5.1.json)):
| Version | Checks passing | Pass rate | Fixed | Regressed |
|---|---:|---:|---:|---:|
| v1.4.0 *(baseline)* | 21 / 26 | 80.8 % ᵃ | — | — |
| **v1.5.0** *(this release)* | **26 / 26** | **100 %** | 5 | **0** |
| v1.5.0 *(baseline)* | 24 / 26 | 92.3 % | — | — |
| **v1.5.1** *(this release)* | **26 / 26** | **100 %** | 2 | **0** |
<sub>ᵃ Against the 26-check v1.5.0 suite. v1.4.0 passed 21/21 of the checks that existed when it shipped.</sub>
**v1.5.0 fails two of these**, and they are the reason it is yanked:
`diameter_dim_measures_the_diameter` and `radius_dim_ignores_the_leader_length`.
It has both methods and gets both wrong — `leader_length`, a *text placement*,
was being measured as geometry, so at default settings every diameter callout
came out **2 × leader_length too large** (60 on a true ⌀40). v1.4.0 shipped the
same defect. The live COM backend was never affected.
The five splits into two kinds, and the difference matters:
Against the older `v1.4.0` baseline the same suite reports **21 / 26** ᵃ with
five fixed ([report](https://github.com/U-C4N/Autocad-MCP/blob/main/benchmarks/results/published/ab-v1.4.0-vs-v1.5.0.json)) — three of those
five are new capability rather than repairs: v1.4.0 has no `entity_measure` and
no boundary tracing, so it misses them by not having the method.
- **Three 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.
- **Two are real defects that v1.4.0 shipped.** It has `dimension_diameter` and `dimension_radius`, and both returned the wrong number: `leader_length` — a *text placement* — was being measured as geometry, so at default settings every diameter callout came out **2 × leader_length too large** (60 on a true ⌀40) and every radius `leader_length` too large. Found while building the drawing at the top of this page. The live COM backend was never affected.
<sub>ᵃ Against the 26-check current suite. v1.4.0 passed 21/21 of the checks that existed when it shipped.</sub>
The 21 pre-existing checks are unchanged and all still pass: the discovery layer, `cad_batch`, the 19-module contract split, layouts/viewports and Wave A landed with **zero correctness regressions**.
@@ -230,7 +240,7 @@ The 21 pre-existing checks are unchanged and all still pass: the discovery layer
Workloads call the same backend methods the MCP tools call, so the backend's own per-call overhead — worker-thread hand-off, document lock, the `EZDXF_CALL_TIMEOUT` guard — is included. The FastMCP layer above it is **not**: `perf_suite.py` never builds the server, so tool dispatch, argument validation, the five middleware and result serialization all sit outside the timer, and in-process they cost roughly an order of magnitude more than the backend call itself. Read these as backend-engine numbers, not per-tool-call latency. **Self-measurement only** — competitor servers pay an extra stdio serialization cost that in-process runs do not, so no cross-server timing claims are made.
> [!CAUTION]
> These are **not** an improvement over v1.4.0, and they are not the engine's floor. The per-call timeout guard costs about **21%** of entity-creation throughput — see [what this release is bad at](#what-this-release-is-bad-at) for the paired measurement and why the guard is kept.
> These are **not** an improvement over v1.4.0, and they are not the engine's floor. The per-call timeout guard costs about **21%** of entity-creation throughput — see [what this release is bad at](https://github.com/U-C4N/Autocad-MCP#what-this-release-is-bad-at) for the paired measurement and why the guard is kept.
### 5 · Source-review leaderboard
@@ -310,13 +320,15 @@ Nothing here loads a `.env` file — there is no dotenv dependency. Export these
## Architecture
The request path, as a Mermaid diagram — GitHub draws it; on PyPI it is the same graph in source form:
```mermaid
flowchart LR
A[MCP host / AI agent] --> B[FastMCP 3 server]
B --> C[Error · audit · timing · logging middleware]
C --> D[Typed contract · 19 modules · 27 capabilities]
D --> E[COM backend<br/>single-STA thread]
D --> F[ezdxf backend<br/>asyncio.to_thread]
D --> E[COM backend — single-STA thread]
D --> F[ezdxf backend — asyncio.to_thread]
E --> G[Live AutoCAD]
F --> H[Headless DXF]
E --> I[Engineering · critique · scoring · delivery]
@@ -403,5 +415,6 @@ Built from production drawing work, then made model-agnostic through MCP.
[MIT](https://github.com/U-C4N/Autocad-MCP/blob/main/LICENSE)
<!-- MCP registry ownership marker; must equal the "name" in server.json. -->
<sub>The line below is the MCP registry's ownership marker. It must equal the `name` in `server.json`, and it is deliberately plain text so it survives PyPI's HTML stripping.</sub>
mcp-name: io.github.u-c4n/autocad-mcp
+75 -17
View File
@@ -19,6 +19,7 @@ from typing import Any
import config
from engineering.measure import is_self_intersecting, polygon_area_perimeter
from security import sanitize_macro_argument, sanitize_symbol_name, validate_path
from . import ocs
from .base import (
@@ -203,6 +204,9 @@ def _ensure_linetype_loaded(name: str) -> None:
"""
if not name or name.lower() in _BUILTIN_LINETYPES:
return
# Before any COM call: this name is about to be interpolated into a
# SendCommand macro, where a newline or ';' would start a second command.
name = sanitize_symbol_name(name, kind="linetype")
doc = _acad_doc()
existing = {doc.Linetypes.Item(i).Name.lower() for i in range(doc.Linetypes.Count)}
if name.lower() in existing:
@@ -293,6 +297,10 @@ def _apply_dim_tolerance(dim, tol_upper, tol_lower, tol_mode="none", text_overri
#: 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.
#: Solids carry a volume but no surface area over ActiveX. Verified on a live
#: AutoCAD 2026 (2026-08-06): AcDb3dSolid answers `.Volume` and raises on `.Area`.
_COM_NO_SURFACE_AREA_TYPES = frozenset({"AcDb3dSolid", "AcDbBody"})
_COM_NO_AREA_TYPES = frozenset(
{
"AcDbLine",
@@ -652,6 +660,14 @@ class ComBackend(AutoCADBackend):
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``.
R32 deliberately stops short of the ezdxf document quarantine here. There
is no ``self._doc`` to replace: the drawing belongs to the operator, lives
in AutoCAD's process, and AutoCAD's own single-threaded command processor
arbitrates the abandoned call — not us. The only "reopen" available would
close the operator's drawing and discard unsaved work the server never
created. What COM is exposed to is idempotency, so the message says the
call may still land.
"""
if self._executor is None:
raise RuntimeError("ComBackend not connected. Call connect() first.")
@@ -691,8 +707,11 @@ class ComBackend(AutoCADBackend):
raise RuntimeError(
f"AutoCAD did not respond within {timeout:.0f}s. The application "
"may be showing a modal dialog or have an active command prompt "
"(press ESC in AutoCAD). Increase COM_CALL_TIMEOUT if a long "
"operation is expected."
"(press ESC in AutoCAD). The abandoned call may still complete in "
"AutoCAD after this error - the worker is blocked inside an "
"uncancellable COM call, not stopped - so verify the drawing before "
"retrying: a retry can double-apply the operation. Increase "
"COM_CALL_TIMEOUT if a long operation is expected."
) from e
except _COM_ERROR as e:
hr = e.args[0] if e.args else 0
@@ -2903,15 +2922,27 @@ class ComBackend(AutoCADBackend):
# dialog deadlocks SendCommand; (2) ISO/metric drawings need acadiso.lin,
# imperial drawings need acad.lin. We pick automatically from MEASUREMENT
# if the caller didn't specify a file.
#
# Both arguments end up inside a SendCommand macro, so both are checked
# before the COM thread is entered: the name against the DXF
# symbol-table rule, the path against the same allowlist every other
# file-taking tool uses. This was the one path-taking tool that reached
# the filesystem with neither.
safe_name = sanitize_symbol_name(name, kind="linetype")
safe_file = None
if file is not None:
resolved = validate_path(str(file))
safe_file = sanitize_macro_argument(str(resolved), kind="linetype file")
def _sync():
app = _acad_app()
doc = _acad_doc()
existing = {doc.Linetypes.Item(i).Name.lower() for i in range(doc.Linetypes.Count)}
if name.lower() in existing:
return {"ok": True, "name": name, "already_loaded": True}
if safe_name.lower() in existing:
return {"ok": True, "name": safe_name, "already_loaded": True}
if file is None:
if safe_file is None:
try:
measurement = int(app.GetVariable("MEASUREMENT"))
except Exception as exc:
@@ -2919,7 +2950,7 @@ class ComBackend(AutoCADBackend):
measurement = 1
lin_file = "acadiso.lin" if measurement == 1 else "acad.lin"
else:
lin_file = file
lin_file = safe_file
try:
old_filedia = int(app.GetVariable("FILEDIA"))
@@ -2930,7 +2961,7 @@ class ComBackend(AutoCADBackend):
app.SetVariable("FILEDIA", 0)
# Double newline: first ends the file name, second exits
# -LINETYPE's [?/Create/Load/Set] option menu.
doc.SendCommand(f"_-LINETYPE _LOAD {name} {lin_file}\n\n")
doc.SendCommand(f"_-LINETYPE _LOAD {safe_name} {lin_file}\n\n")
finally:
try:
app.SetVariable("FILEDIA", old_filedia)
@@ -2938,13 +2969,13 @@ class ComBackend(AutoCADBackend):
log.debug("FILEDIA restore failed: %s", exc)
after = {doc.Linetypes.Item(i).Name.lower() for i in range(doc.Linetypes.Count)}
if name.lower() not in after:
if safe_name.lower() not in after:
raise RuntimeError(
f"Failed to load linetype '{name}' from '{lin_file}'. "
f"Failed to load linetype '{safe_name}' from '{lin_file}'. "
"Check the linetype name spelling and that the .lin file is "
"on AutoCAD's support path."
)
return {"ok": True, "name": name, "file": lin_file}
return {"ok": True, "name": safe_name, "file": lin_file}
return await self._run(_sync)
@@ -3189,10 +3220,16 @@ class ComBackend(AutoCADBackend):
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)
# Measured on a live AutoCAD 2026 (2026-08-06): the perimeter lives
# under a different member per type, and on some types nowhere.
# AcDbPolyline -> .Length, AcDbCircle -> .Circumference,
# AcDbRegion -> .Perimeter, AcDbHatch -> none of them.
for member in ("Length", "Circumference", "Perimeter"):
try:
perimeter = float(getattr(ent, member))
break
except Exception as exc:
log.debug("ActiveX .%s unavailable for %s: %s", member, obj_name, exc)
method = "activex_area"
closed = True
@@ -3215,6 +3252,17 @@ class ComBackend(AutoCADBackend):
for i in range(0, len(coords) - 1, 2)
]
if not vertices:
if obj_name in _COM_NO_SURFACE_AREA_TYPES:
# Verified on a live AutoCAD 2026 (2026-08-06): AcDb3dSolid
# exposes `.Volume` and no `.Area` whatsoever. The generic
# message would send the caller hunting for geometry that
# was never the problem.
raise RuntimeError(
f"entity_measure: handle {handle} is {obj_name}, and ActiveX "
"exposes no surface-area member for it — only .Volume. "
"AutoCAD's own MASSPROP reports the surface area; this server "
"does not wrap it."
)
raise RuntimeError(
f"entity_measure: AutoCAD reported neither an area nor usable "
f"vertices for handle {handle} ({obj_name})."
@@ -3436,12 +3484,22 @@ class ComBackend(AutoCADBackend):
def _sync():
doc = _acad_doc()
# R32: record the mark from inside the worker, immediately before
# setting it. `_transaction_active = True` used to run only after
# `_run` returned, so a timed-out begin left StartUndoMark open in
# AutoCAD while the server recorded no transaction, and the next
# transaction_begin nested a second mark nobody could see — the
# exact inverse of the R16 fix transaction_commit already carries.
# The abandoned STA thread cannot be cancelled, so the flag has to
# be written where the call happens; and it is written *before*
# StartUndoMark because a call that hangs inside it may still land.
# A `_acad_doc()` that fails leaves the flag alone, which is right:
# nothing reached AutoCAD.
self._transaction_active = True
doc.StartUndoMark()
return {"ok": True, "message": "Transaction begun (AutoCAD undo mark set)"}
result = await self._run(_sync)
self._transaction_active = True
return result
return await self._run(_sync)
async def transaction_commit(self) -> dict:
def _sync():
+9 -5
View File
@@ -117,12 +117,16 @@ class AnalysisContract(ABC):
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 reads ActiveX ``.Area``, and whether that number already has the
island style applied is **not verified against a live seat**. Until it
is, do not read a COM hatch area as equivalent to the headless one.
it reads ActiveX ``.Area``, which **does** apply the island style —
verified on a live AutoCAD 2026 (2026-08-06): a hatch of r20 with an
r10 island returns 942.478, which is pi*(400-100).
``perimeter`` is ``None`` where ActiveX exposes no length for the type
(REGION and 3DSOLID among them), and ``perimeter_exact`` is then False.
``perimeter`` is ``None`` where ActiveX exposes no length member for the
type, and ``perimeter_exact`` is then False. The member differs per
type and was measured rather than assumed: LWPOLYLINE ``.Length``,
CIRCLE ``.Circumference``, REGION ``.Perimeter``, HATCH none. A
3DSOLID has no ``.Area`` at all — only ``.Volume`` — and is refused by
name.
A missing perimeter is not a reason to discard an area that was read
successfully — doing exactly that is what made this method raise on
every ACIS type it advertised.
+7
View File
@@ -11,6 +11,8 @@ import math
from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Any
from backends.quarantine import DocumentQuarantineError
if TYPE_CHECKING:
from backends.base import EntityInfo
from engineering.plan_spec import (
@@ -579,6 +581,11 @@ class PremiumContract(ABC):
layer = layer or self._role_layer("construction")
try:
ents = await self.entity_list(layer_filter=layer, limit=5000)
except DocumentQuarantineError:
# "deleted: 0" here would read as "there was nothing to clear" on a
# document the backend has declared untrusted -- and this is step 9
# of the standard workflow, immediately before finalize.
raise
except Exception:
ents = []
deleted = 0
+86 -4
View File
@@ -22,8 +22,13 @@ class SettingsContract:
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)."""
dimscale, dim_text_height, dim_arrow_size, dim_decimals,
decimal_separator, zero_suppression, text_size, point_mode, point_size,
osmode, fillet_radius. `units` accepts mm/cm/m/inch/feet (mapped to the
INSUNITS code) and `decimal_separator` accepts "." or ",".
Note that a no-argument snapshot is one ``system_get_variable`` per key.
Free headless; a COM round trip each on a live seat."""
if not settings:
snapshot: dict = {}
for key, (var, kind) in _SETTING_MAP.items():
@@ -68,6 +73,16 @@ _SETTING_MAP: dict[str, tuple[str, str]] = {
"angular_precision": ("AUPREC", "int"),
"ltscale": ("LTSCALE", "float"),
"dimscale": ("DIMSCALE", "float"),
# The dimension variables had no friendly name until v1.5.2, so the only
# way to reach dimension lettering was a raw `system_set_variable` — which
# is precisely the path that silently did nothing on the headless engine.
# `text_size` is TEXTSIZE, the height of a standalone TEXT entity; it has
# never had anything to do with the numbers on a dimension.
"dim_text_height": ("DIMTXT", "float"),
"dim_arrow_size": ("DIMASZ", "float"),
"dim_decimals": ("DIMDEC", "int"),
"decimal_separator": ("DIMDSEP", "char"),
"zero_suppression": ("DIMZIN", "int"),
"text_size": ("TEXTSIZE", "float"),
"point_mode": ("PDMODE", "int"),
"point_size": ("PDSIZE", "float"),
@@ -96,6 +111,13 @@ _UNIT_TO_CODE: dict[str, int] = {
}
_CODE_TO_UNIT: dict[int, str] = {0: "unitless", 1: "inch", 2: "feet", 4: "mm", 5: "cm", 6: "m"}
#: DIMDSEP is a character *code*, not a character — ezdxf raises DXFValueError
#: on a string in the header — so the friendly key is where the translation
#: belongs. Only two markers are in real use: ISO 129's point and the comma most
#: of continental Europe drafts with. Anything else lands an arbitrary glyph in
#: the middle of every number on the sheet, so it is refused rather than stored.
_DECIMAL_MARKERS = (".", ",")
def _encode_setting(key: str, kind: str, value: Any) -> Any:
"""Coerce a friendly value into the raw system-variable value."""
@@ -109,10 +131,62 @@ def _encode_setting(key: str, kind: str, value: Any) -> Any:
f"{sorted(set(_UNIT_TO_CODE))} or an INSUNITS integer."
)
return code
if kind == "char":
if isinstance(value, bool):
marker = ""
elif isinstance(value, (int, float)):
code = int(value)
marker = chr(code) if 0 < code < 0x110000 else ""
else:
marker = str(value).strip()
if marker not in _DECIMAL_MARKERS:
raise ValueError(
f"{key}: unknown value {value!r}. Use one of {list(_DECIMAL_MARKERS)}."
)
return ord(marker)
if kind == "int":
return int(float(value))
return _in_range(key, int(float(value)))
if kind == "float":
return float(value)
return _in_range(key, float(value))
return value
#: AutoCAD's own ranges for the settings that now reach the renderer.
#:
#: Before the DIM* header fold, an out-of-range value died harmlessly in the
#: header. Folding it into the per-dimension override is what gave it teeth:
#: `dim_decimals=-1` reported `ok: True` and then killed the *next* dimension
#: with `ValueError: Format specifier missing precision` raised from inside
#: ezdxf's formatter, nowhere near the call responsible; `dim_decimals=40`
#: printed forty digits of float noise onto the sheet; and a non-positive
#: length was reported applied and silently discarded. `applied` has to mean
#: applied.
_SETTING_RANGES: dict[str, tuple[float, float]] = {
"dim_decimals": (0, 8), # DIMDEC
"zero_suppression": (0, 15), # DIMZIN is a bitmask
"linear_precision": (0, 8), # LUPREC
"angular_precision": (0, 8), # AUPREC
"dim_text_height": (1e-9, 1e6), # DIMTXT, strictly positive
"dim_arrow_size": (1e-9, 1e6), # DIMASZ, strictly positive
"text_size": (1e-9, 1e6), # TEXTSIZE
"dimscale": (0, 1e6), # 0 means "scale to the viewport" and is legal
"ltscale": (1e-9, 1e6),
"point_size": (-100, 1e6), # negative is a percentage of the screen
"fillet_radius": (0, 1e6),
}
def _in_range(key: str, value: float) -> float:
"""Refuse a value the renderer cannot use, naming the range."""
bounds = _SETTING_RANGES.get(key)
if bounds is None:
return value
low, high = bounds
if not low <= value <= high:
low_text = "greater than 0" if 0 < low < 1e-6 else f"at least {low:g}"
raise ValueError(
f"{key}: {value:g} is out of range - must be {low_text} and at most {high:g}."
)
return value
@@ -126,6 +200,14 @@ def _decode_setting(key: str, kind: str, raw: Any) -> Any:
except (TypeError, ValueError):
return raw
return {"code": code, "name": _CODE_TO_UNIT.get(code, "unknown")}
if kind == "char":
try:
code = int(raw)
except (TypeError, ValueError):
return raw
# ezdxf's renderer reads DIMDSEP 0 as a comma rather than as "unset",
# so report the marker that would actually be drawn.
return "," if code == 0 else chr(code)
if kind == "int":
try:
return int(raw)
+458 -56
View File
@@ -39,8 +39,25 @@ from .base import (
normalize_lineweight,
shoelace_area,
)
from .quarantine import (
AbandonedCall,
DocumentQuarantineError,
QuarantineRecord,
quarantine_refusal,
real_call_name,
)
__all__ = ["EzdxfBackend", "UnsupportedCapabilityError", "_unsupported"]
__all__ = [
"DocumentQuarantineError",
"EzdxfBackend",
"UnsupportedCapabilityError",
"_unsupported",
]
#: How many abandoned calls ``system_status`` keeps. The interesting one is
#: always the most recent; older entries exist so a support transcript shows a
#: document that has been abandoned on repeatedly rather than once.
_MAX_ABANDONED_RECORDS = 10
log = logging.getLogger(__name__)
@@ -337,6 +354,64 @@ def _flatten_measure(ent, tolerance: float, result: dict) -> dict:
return result
#: ISO-conformant dimension style for a new headless document, in mm.
#:
#: ezdxf's bare `Standard` dimstyle leaves every one of these unset, and the
#: renderer then falls back to its own defaults rather than to the DXF schema
#: defaults -- 1.0 mm text with a *comma* decimal marker. ISO 3098 puts the
#: minimum lettering height at 2.5 mm and ISO 129 wants a point, so every
#: headless drawing this server made was non-conformant on both counts while
#: the document header claimed `$DIMTXT 2.5`.
#:
#: `ezdxf.new(setup=True)` is not the fix: measured, it renders a 100 mm
#: dimension as "10000" at 0.25 mm text, because its metric styles carry a
#: length factor. Setting the attributes explicitly is what reaches the
#: renderer, because only a *stored* value does.
_ISO_DIMSTYLE = {
"dimtxt": 2.5, # ISO 3098 minimum lettering height
"dimasz": 2.5, # arrowhead, matched to the text
"dimdsep": ord("."), # ISO 129 decimal marker; ezdxf defaults to a comma
"dimdec": 2,
"dimexe": 1.25, # extension line beyond the dimension line
"dimexo": 0.625, # offset from the measured feature
"dimgap": 0.625, # gap around the text
}
def _normalise_dimstyle_rounding(doc) -> None:
"""Discard a stored ``dimrnd`` of 0.0, which AutoCAD means as *no* rounding.
ezdxf applies ``xround(value, dimrnd)`` whenever the attribute is present at
all, and ``xround(33.333, 0.0)`` is ``33``. Saving a drawing writes the
attribute out, so every reopened file — including one this server saved
itself — silently rounded every dimension to a whole unit *before* DIMDEC
got to format it: a 12.75 mm feature dimensioned as 13, R6.35 as R6.
Only the 0.0 sentinel is removed. A drafter who genuinely asked for 0.5 mm
rounding keeps it.
"""
try:
style = doc.dimstyles.get("Standard")
except Exception:
return
if style.dxf.hasattr("dimrnd") and float(style.dxf.dimrnd) == 0.0:
style.dxf.discard("dimrnd")
def _apply_iso_dimstyle(doc) -> None:
"""Store ISO defaults on the document's `Standard` dimension style."""
try:
style = doc.dimstyles.get("Standard")
except Exception: # a template without a Standard style is the caller's own
return
for attribute, value in _ISO_DIMSTYLE.items():
style.dxf.set(attribute, value)
doc.header["$DIMTXT"] = _ISO_DIMSTYLE["dimtxt"]
doc.header["$DIMASZ"] = _ISO_DIMSTYLE["dimasz"]
doc.header["$DIMDSEP"] = _ISO_DIMSTYLE["dimdsep"]
doc.header["$DIMDEC"] = _ISO_DIMSTYLE["dimdec"]
#: AutoCAD's HPISLANDDETECTION, by the code DXF stores in group 75.
_HATCH_STYLE_NAMES = {0: "normal", 1: "outer", 2: "ignore"}
@@ -443,27 +518,147 @@ def _measure_hatch(ent, tolerance: float, result: dict) -> dict:
return _round_measure(result)
def _with_header_dimscale(doc, override: dict | None) -> dict | None:
"""Carry `$DIMSCALE` onto the dimension being created.
#: The dimension style every headless dimension is rendered from.
#:
#: `msp.add_*_dim` defaults to `dimstyle="EZDXF"` (`"EZ_RADIUS"` for the radial
#: ones) and ezdxf silently substitutes `Standard` when that entry is missing —
#: which it is in every document this server creates, so the five call sites got
#: `Standard` by accident rather than by name. Naming it keeps three things
#: pointed at one style: `_apply_iso_dimstyle`, which writes it; the `iso128`
#: critique, which grades it; and the header fold below, which compares against
#: it. A file authored by `ezdxf.new(setup=True)` *does* carry an `EZDXF` entry,
#: and its metric styles carry a length factor — measured, that renders a 100 mm
#: dimension as "10000" — so on an opened file the substitution would have
#: pointed the dimension at a style nobody here configured and left both the
#: fold and the ISO defaults inert.
_DIMSTYLE_NAME = "Standard"
`drawing_settings({"dimscale": ...})` writes the header variable, which is
what AutoCAD consults when it draws a dimension — so the call worked on the
live backend. ezdxf renders from the dimstyle table entry and never reads
the header, so headlessly the same call reported success and changed
nothing. Folding it into the per-dimension override makes one call mean one
thing on both engines.
1.0 is the default and is deliberately not written, so an untouched drawing
does not gain an override on every dimension.
#: DIM* header variables folded onto the dimension being created, as
#: ``dimstyle attribute -> (header variable, renderer fallback, coercion)``.
#:
#: The third column is what ezdxf's *renderer* resolves the attribute to when
#: the dimstyle stores nothing — `DimStyleOverride.get(attr, default)` with the
#: defaults spelled out in `render/dim_base.py` and `get_decimal_separator`.
#: Deliberately NOT the DXF schema default, which differs for three of the six:
#: dimtxt 1.0 vs 2.5, dimasz 0.25 vs 2.5, dimdsep 0 (a comma) vs 44. Keying the
#: "header already agrees, nothing to fold" test on the schema default is the
#: invisible way to get this wrong — it switches the fold off for exactly the
#: opened-file case it exists for, and switches it *on* for a fresh drawing,
#: where folding dimdsep would put ezdxf's comma back onto a sheet ISO 129 wants
#: a point on.
#:
#: This is an allowlist, not "every DIM* variable". $DIMADEC and $DIMAZIN sit at
#: 0 in a fresh header while the renderer falls back to 2 and 2, so completing
#: the table would silently reformat every angular dimension this server has
#: ever drawn (measured: 239.04° becomes 239°). $DIMGAP is the other one that
#: looks safe and is not: `build_dim_override` sets dimgap=-1.0 for
#: `tol_mode="basic"` to draw the theoretically-exact box, and a fold would be
#: competing with it for the same key.
_HEADER_DIMVARS: dict[str, tuple[str, Any, Any]] = {
"dimscale": ("$DIMSCALE", 1.0, float),
"dimtxt": ("$DIMTXT", 1.0, float),
"dimasz": ("$DIMASZ", 0.25, float),
"dimdec": ("$DIMDEC", 2, int),
"dimdsep": ("$DIMDSEP", 0, int), # a character code; 0 renders as a comma
"dimzin": ("$DIMZIN", 8, int),
}
def _coerce_dimvar(value, coerce):
"""The value as the override dict needs it, or None if it is junk.
ezdxf validates the *header* (a string in `$DIMDSEP` raises DXFValueError)
but not the override dict, so a float 46.0 or a stray string reaching
`dimdsep` renders garbage instead of raising. Coercing here is what keeps an
opened file's odd header out of the drawing.
"""
try:
scale = float(doc.header.get("$DIMSCALE", 1.0) or 1.0)
except (AttributeError, TypeError, ValueError):
return override
if abs(scale - 1.0) < 1e-9:
return override
merged = dict(override or {})
merged.setdefault("dimscale", scale)
return coerce(value)
except (TypeError, ValueError):
return None
def _header_dimvar(doc, header_var: str, coerce):
"""The header's value for a DIM* variable, or None if it has none usable."""
try:
raw = doc.header.get(header_var, None)
except Exception: # an opened document may not have a readable header
return None
return None if raw is None else _coerce_dimvar(raw, coerce)
def _style_dimvar(doc, attribute: str, fallback, coerce):
"""What the renderer would use with no header and no override in play."""
try:
style = doc.dimstyles.get(_DIMSTYLE_NAME)
if style.dxf.hasattr(attribute):
stored = _coerce_dimvar(style.dxf.get(attribute), coerce)
if stored is not None:
return stored
except Exception: # a document without a Standard style is the caller's own
pass
return fallback
def effective_dimvar(doc, attribute: str):
"""The value the next dimension will actually be rendered with.
Header first, then the stored dimstyle attribute, then ezdxf's renderer
fallback — the order `_with_header_dimvars` produces below, and the order a
live seat uses (the header DIMVARS *are* the current settings; the dimstyle
table entry is only the base underneath them). `drawing_critique` has to
resolve the same value, or it reports a defect the sheet does not have and
clears one it does.
"""
header_var, fallback, coerce = _HEADER_DIMVARS[attribute]
from_header = _header_dimvar(doc, header_var, coerce)
if from_header is not None:
return from_header
return _style_dimvar(doc, attribute, fallback, coerce)
def _with_header_dimvars(doc, override: dict | None) -> dict | None:
"""Carry the DIM* header variables onto the dimension being created.
`system_set_variable("DIMTXT", 5.0)` writes the header variable, which is
what AutoCAD consults when it builds a dimension — so the call worked on the
live backend. ezdxf renders from the dimstyle table entry plus the
per-dimension override and never reads the header, so headlessly the same
call reported success and changed nothing. Measured on v1.5.1: after
DIMTXT=5.0, DIMASZ=6.0, DIMDEC=1, DIMDSEP=44 and DIMZIN=0 the header read
all five back, and the next dimension was byte-identical to the one before
them — still 2.5 mm text, 2.5 arrows, "33.33". Folding them into the
override makes one call mean one thing on both engines.
v1.5.1 folded `$DIMSCALE` alone, which is why this is a table rather than a
fifth copy of the same six lines.
A variable whose header value already matches what the renderer would use is
skipped, so an untouched `drawing_new` drawing gains no override at all —
header and style agree on all six there. `setdefault` is what keeps the fold
below `build_dim_override`: an explicit tolerance/limit/basic key, or any
value a caller passed, always outranks the header.
"""
merged = override
for attribute, (header_var, fallback, coerce) in _HEADER_DIMVARS.items():
from_header = _header_dimvar(doc, header_var, coerce)
if from_header is None:
continue
if attribute == "dimscale" and not from_header:
# DIMSCALE 0 means "scale to the paper-space viewport". There is no
# viewport to scale to here, and folding a literal 0 would render
# the dimension to nothing; 1.0 is what the renderer already uses.
continue
stored = _style_dimvar(doc, attribute, fallback, coerce)
if isinstance(from_header, float):
if abs(from_header - float(stored)) < 1e-9:
continue
elif from_header == stored:
continue
if merged is override: # only allocate once something is actually folded
merged = dict(override or {})
merged.setdefault(attribute, from_header)
return merged
@@ -744,6 +939,10 @@ class EzdxfBackend(AutoCADBackend):
self._transaction_stack: list[Path] = [] # isolated transaction snapshots
self._connected = False
self._lock = asyncio.Lock()
# R32: the document a timed-out call was abandoned on is untrusted until
# it is replaced. See backends/quarantine.py for what that buys.
self._quarantine: QuarantineRecord | None = None
self._abandoned_calls: list[QuarantineRecord] = []
@property
def name(self) -> str:
@@ -837,6 +1036,9 @@ class EzdxfBackend(AutoCADBackend):
async def disconnect(self) -> None:
self._cleanup_undo_stack()
self._reset_document_state()
# The document goes away with the backend, so nothing is left to protect.
# The record stays in _abandoned_calls for anyone reading the transcript.
self._clear_quarantine("disconnect")
self._connected = False
def _cleanup_undo_stack(self):
@@ -887,17 +1089,128 @@ class EzdxfBackend(AutoCADBackend):
)
@staticmethod
def _timeout_error(func, timeout: float) -> RuntimeError:
name = getattr(func, "__name__", "call")
def _timeout_error(call: str, timeout: float) -> RuntimeError:
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."
f"ezdxf call '{call}' timed out after {timeout:g}s. asyncio.to_thread "
"cannot be cancelled, so the worker thread was abandoned and may still be "
"writing to the drawing. Every later call on this document is refused - "
"reads included - until drawing_open(path), drawing_new() or "
"drawing_close() rebinds it, or until the abandoned call is seen to finish "
"cleanly, which lifts the refusal by itself. system_status() stays "
"answerable throughout. Raise EZDXF_CALL_TIMEOUT (seconds; 0 disables the "
"timeout entirely) if this operation is legitimately slow."
)
async def _async(self, func, *args, **kwargs):
# ── document quarantine (R32) ─────────────────────────────────────────────
def _reap_quarantine(self) -> None:
"""The free exit: an abandoned call that finished cleanly is no corruption.
The common real trigger for the deadline is a legitimately slow
``drawing_audit`` or a big ``drawing_export_pdf`` against the 120s
default, not a hang. If the ``_sync`` body ran to completion the document
holds exactly the state that call intended, and forcing a reopen for that
would be its own defect.
This inference is sound *only* because every other caller was refused in
the meantime, so "the call finished" really does mean "the document is
what that call intended". Add one mutating exemption to the gate and the
lift becomes a lie.
The lift is not silent: the caller never received the return value, so a
late-completing ``entity_create_*`` leaves an entity whose handle nobody
holds. That fact survives in the record.
"""
record = self._quarantine
if record is None or not record.watcher.finished:
return
if record.watcher.error is not None:
if record.outcome == "running":
error = record.watcher.error
record.outcome = "failed_after_deadline"
record.detail = f"{type(error).__name__}: {error}"
log.error(
"abandoned ezdxf call %r raised %s after its deadline; the document "
"stays quarantined - it was left mid-write",
record.call,
record.detail,
)
return
record.outcome = "completed_after_deadline"
record.capture_lost_result()
self._quarantine = None
log.warning(
"abandoned ezdxf call %r completed %.1fs after its deadline; lifting the "
"document quarantine. Its return value never reached the caller (%s), so "
"anything it created is in the drawing with no handle reported",
record.call,
record.age(),
record.result_repr,
)
def _clear_quarantine(self, released_by: str, new_path: str | None = None):
"""Release the quarantine because ``self._doc`` was just rebound.
Only the three calls that construct a *fresh* ``Drawing`` may do this:
the runaway keeps the object it captured, so its remaining writes land on
an orphan nobody reads. That holds for a ``_sync`` which captured
doc/msp once at entry — the shape of nearly every closure in this file.
One that re-reads ``self._msp()`` inside its own loop would follow the
rebind into the fresh document, and ``_async`` cannot prevent that, which
is why a still-live runaway is said out loud rather than implied away.
"""
record = self._quarantine
if record is None:
return None
self._quarantine = None
record.outcome = "released"
record.released_by = released_by
if record.thread_alive():
record.detail = "the abandoned worker was still running at recovery time"
log.warning(
"%s cleared the document quarantine while the abandoned %r worker is "
"STILL RUNNING; its writes go to the replaced Drawing unless its _sync "
"re-reads self._msp() inside its own loop",
released_by,
record.call,
)
if new_path and record.document_path:
try:
same = os.path.abspath(new_path) == os.path.abspath(record.document_path)
except (OSError, ValueError): # a path this OS cannot normalise
same = new_path == record.document_path
if same:
log.warning(
"%s is reopening %s, the same path the abandoned %r call was working "
"against; if that call is inside doc.saveas the file is being written "
"right now and this read may be of a truncated document",
released_by,
new_path,
record.call,
)
return record
def _quarantine_document(self, func, watcher, timeout: float) -> str:
"""Record an abandoned call and refuse the document until it is replaced.
``func`` is the caller's closure (it carries the qualname the message
needs); ``watcher`` is the wrapper the worker thread is actually running,
and the only thing that can report whether it ever finished.
"""
call = real_call_name(func)
record = QuarantineRecord(
call,
timeout,
watcher,
document_path=self._doc_path,
document_id=id(self._doc),
)
self._quarantine = record
self._abandoned_calls.append(record)
del self._abandoned_calls[:-_MAX_ABANDONED_RECORDS]
return call
async def _async(self, func, *args, quarantine_exit: bool = False, **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
@@ -922,9 +1235,34 @@ class EzdxfBackend(AutoCADBackend):
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.
R32 — swapping the lock is only half the answer, and on its own it is the
more dangerous half: it removes the mutual exclusion the lock existed
for, so the *next* call runs against the same ``Drawing`` the runaway is
still mutating. Measured, that returned a fresh circle handle, an entity
count for a modelspace 50 entities further on, and a valid DXF of a state
that never existed. So the abandonment quarantines the document, and
every later call is refused **before** ``lock.acquire()`` — after the
acquire the refusal would queue behind an unrelated in-flight call and
the caller would wait the full timeout to be told the document is
untrusted. Reads are refused too: a read of a container another thread is
appending to does not tear loudly, it runs unbounded — ``drawing_info``
blew its own 2.0s deadline at 2.03s and abandoned a *second* worker.
``quarantine_exit=True`` is for the three calls that rebind ``self._doc``
to a freshly constructed object. They still take the lock, because two
concurrent recoveries would otherwise both rebind it and one fresh
document would be silently discarded with its caller's work in it.
"""
# The gate, before any await: a refusal must not queue.
if self._quarantine is not None:
self._reap_quarantine()
if self._quarantine is not None and not quarantine_exit:
raise quarantine_refusal(self._quarantine)
timeout = config.settings.ezdxf_call_timeout
if timeout <= 0:
# No deadline means no abandon branch, so nothing here can quarantine.
async with self._lock:
return await asyncio.to_thread(func, *args, **kwargs)
@@ -939,11 +1277,15 @@ class EzdxfBackend(AutoCADBackend):
# distinguishable, which is the whole reason they were separate.
acquired = False
call: asyncio.Future | None = None
# The only place an abandoned thread's outcome is observable is inside
# the thread: `call` is CANCELLED by the deadline, so its done-callback
# reports the await ending and never the worker. See AbandonedCall.
watched = AbandonedCall(func)
try:
async with asyncio.timeout_at(deadline):
await lock.acquire()
acquired = True
call = asyncio.ensure_future(asyncio.to_thread(func, *args, **kwargs))
call = asyncio.ensure_future(asyncio.to_thread(watched, *args, **kwargs))
result = await call
except TimeoutError as exc:
if not acquired:
@@ -960,13 +1302,18 @@ class EzdxfBackend(AutoCADBackend):
raise
if self._lock is lock:
self._lock = asyncio.Lock()
# Same handler, same branch as the lock swap: the two other exits
# above touched nothing, and quarantining on either would refuse the
# whole document for a failure that never reached it.
name = self._quarantine_document(func, watched, timeout)
log.error(
"ezdxf call %r exceeded EZDXF_CALL_TIMEOUT=%gs; abandoning the worker "
"thread and the lock it holds",
getattr(func, "__name__", func),
"thread and the lock it holds, and quarantining the document it may "
"still be writing to",
name,
timeout,
)
raise self._timeout_error(func, timeout) from exc
raise self._timeout_error(name, timeout) from exc
except BaseException:
# Only ours to release if we ever took it — cancellation can land
# while still queueing, and releasing someone else's lock there
@@ -1025,17 +1372,26 @@ class EzdxfBackend(AutoCADBackend):
def _sync():
if template and Path(template).exists():
self._doc = ezdxf.readfile(template)
_normalise_dimstyle_rounding(self._doc)
else:
self._doc = ezdxf.new(dxfversion="R2010")
_apply_iso_dimstyle(self._doc)
# R32: the rebind above is the release — the runaway keeps the
# Drawing it captured, so its remaining writes land on an orphan.
# Clear before _reset_history_baseline, which snapshots the new doc.
released = self._clear_quarantine("drawing_new")
self._doc_path = None
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"}
result = {"ok": True, "name": "untitled.dxf"}
if released is not None:
result["quarantine_cleared"] = released.to_dict()
return result
return await self._async(_sync)
return await self._async(_sync, quarantine_exit=True)
async def drawing_open(self, path: str) -> dict:
def _sync():
@@ -1051,6 +1407,12 @@ class EzdxfBackend(AutoCADBackend):
f"({size} > {max_bytes}). Set MAX_DXF_BYTES env var to override."
)
self._doc = ezdxf.readfile(path)
_normalise_dimstyle_rounding(self._doc)
# R32: only reached if the read succeeded. Reopening the very path a
# runaway doc.saveas is writing either fails loudly here (acceptable)
# or hands back a truncated document (not) — _clear_quarantine
# compares the two paths and says so.
released = self._clear_quarantine("drawing_open", path)
self._doc_path = path
self._dirty = False
try:
@@ -1061,9 +1423,12 @@ class EzdxfBackend(AutoCADBackend):
self._reset_document_state()
self._current_space = "Model"
self._reset_history_baseline()
return {"ok": True, "name": Path(path).name, "path": path}
result = {"ok": True, "name": Path(path).name, "path": path}
if released is not None:
result["quarantine_cleared"] = released.to_dict()
return result
return await self._async(_sync)
return await self._async(_sync, quarantine_exit=True)
async def drawing_save(self, path: str | None = None) -> dict:
save_path = path or self._doc_path
@@ -2042,16 +2407,32 @@ class EzdxfBackend(AutoCADBackend):
async def drawing_close(self, save: bool = True) -> dict:
def _sync():
if save and self._dirty and self._doc_path:
# R32: closing is a way out of a quarantine, but saving on the way is
# not. That save is the exact defect measured — a valid DXF holding
# 15200 of the 60000 entities the runaway went on to write, a
# considered document of a state that never existed. Say what was
# dropped instead of writing it.
quarantined = self._quarantine is not None
if save and self._dirty and self._doc_path and not quarantined:
self._doc.saveas(self._doc_path)
self._cleanup_undo_stack()
self._doc = None
released = self._clear_quarantine("drawing_close")
self._doc_path = None
self._dirty = False
self._reset_document_state()
return {"ok": True}
result = {"ok": True}
if released is not None:
result["quarantine_cleared"] = released.to_dict()
result["saved"] = False
result["warning"] = (
"the document was quarantined after a call was abandoned mid-write, "
"so unsaved changes were discarded rather than serialised out of a "
"drawing another thread may still have been mutating"
)
return result
return await self._async(_sync)
return await self._async(_sync, quarantine_exit=True)
async def drawing_undo(self) -> dict:
self._require_undo_history("drawing_undo")
@@ -2179,6 +2560,7 @@ class EzdxfBackend(AutoCADBackend):
def _restore_snapshot(self, path: Path) -> None:
self._doc = ezdxf.readfile(str(path))
_normalise_dimstyle_rounding(self._doc)
self._current_layer = str(self._doc.header.get("$CLAYER", "0"))
self._dirty = True
@@ -2909,7 +3291,7 @@ class EzdxfBackend(AutoCADBackend):
from engineering.tolerances import build_dim_override
override, text = build_dim_override(tol_upper, tol_lower, tol_mode, text_override)
override = _with_header_dimscale(self._require_doc(), override)
override = _with_header_dimvars(self._require_doc(), override)
msp = self._msp()
dim = msp.add_linear_dim(
base=(float(dim_x), float(dim_y)),
@@ -2917,6 +3299,7 @@ class EzdxfBackend(AutoCADBackend):
p2=(float(x2), float(y2)),
angle=float(rotation),
text=text if text is not None else "<>",
dimstyle=_DIMSTYLE_NAME,
override=override or None,
)
dim.render()
@@ -2946,7 +3329,8 @@ class EzdxfBackend(AutoCADBackend):
distance=math.sqrt(
(float(dim_x) - float(x1)) ** 2 + (float(dim_y) - float(y1)) ** 2
),
override=_with_header_dimscale(self._require_doc(), None),
dimstyle=_DIMSTYLE_NAME,
override=_with_header_dimvars(self._require_doc(), None),
)
dim.render()
ent = dim.dimension
@@ -2977,7 +3361,8 @@ class EzdxfBackend(AutoCADBackend):
line1=((vxf, vyf), (float(x1), float(y1))),
line2=((vxf, vyf), (float(x2), float(y2))),
location=(float(tx), float(ty)),
override=_with_header_dimscale(self._require_doc(), None),
dimstyle=_DIMSTYLE_NAME,
override=_with_header_dimvars(self._require_doc(), None),
)
dim.render()
ent = dim.dimension
@@ -3005,7 +3390,7 @@ class EzdxfBackend(AutoCADBackend):
from engineering.tolerances import build_dim_override
override, text = build_dim_override(tol_upper, tol_lower, tol_mode, text_override)
override = _with_header_dimscale(self._require_doc(), override)
override = _with_header_dimvars(self._require_doc(), override)
msp = self._msp()
cxf, cyf = float(cx), float(cy)
radius = math.sqrt((chord_x - cxf) ** 2 + (chord_y - cyf) ** 2)
@@ -3021,6 +3406,7 @@ class EzdxfBackend(AutoCADBackend):
cyf + (radius + leader) * math.sin(angle_rad),
),
text=text if text is not None else "<>",
dimstyle=_DIMSTYLE_NAME,
override=override or None,
)
dim.render()
@@ -3049,7 +3435,7 @@ class EzdxfBackend(AutoCADBackend):
from engineering.tolerances import build_dim_override
override, text = build_dim_override(tol_upper, tol_lower, tol_mode, text_override)
override = _with_header_dimscale(self._require_doc(), override)
override = _with_header_dimvars(self._require_doc(), override)
msp = self._msp()
x1f, y1f, x2f, y2f = float(x1), float(y1), float(x2), float(y2)
cx = (x1f + x2f) / 2
@@ -3070,6 +3456,7 @@ class EzdxfBackend(AutoCADBackend):
cy + (radius + leader) * math.sin(angle_rad),
),
text=text if text is not None else "<>",
dimstyle=_DIMSTYLE_NAME,
override=override or None,
)
dim.render()
@@ -3718,7 +4105,6 @@ class EzdxfBackend(AutoCADBackend):
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"}
@@ -3739,19 +4125,26 @@ class EzdxfBackend(AutoCADBackend):
]
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
"""None means "this shape has no area", not "its area is zero".
Answered by the same measurement `entity_measure` uses, so the
two cannot disagree about the same entity. They did: this used
to read ``entity.dxf.get("area", 0.0)`` for SOLID, TRACE and
HATCH, none of which carry an ``area`` DXF attribute, so every
one of them measured 0.0 and any positive threshold silently
excluded every hatch in the drawing while ``filtered_by``
reported that the filter had run.
The two refusals stay distinguishable. A type that bounds no
area on any engine raises RuntimeError and is *excluded*; a
type this engine cannot evaluate (ACIS) refuses with a
capability and is also excluded, because "I could not measure
it" must never read as "it is big enough".
"""
try:
return float(_measure_dxf_entity(entity, 0.001)["area"])
except (UnsupportedCapabilityError, RuntimeError, ValueError, TypeError):
return None
handles = []
for entity in self._msp():
@@ -4846,6 +5239,7 @@ class EzdxfBackend(AutoCADBackend):
p = self._transaction_stack.pop()
try:
self._doc = ezdxf.readfile(str(p))
_normalise_dimstyle_rounding(self._doc)
self._current_layer = str(self._doc.header.get("$CLAYER", "0"))
self._dirty = True
finally:
@@ -4860,6 +5254,12 @@ class EzdxfBackend(AutoCADBackend):
# ── system ────────────────────────────────────────────────────────────────
async def system_status(self) -> dict:
# R32: deliberately NOT routed through _async. It reads self._doc
# directly, which is what keeps it answerable while the document is
# quarantined; putting it behind the lock would make the diagnostic
# channel the first thing the quarantine closes, leaving the user with a
# refusal and no way to ask what happened.
self._reap_quarantine()
has_doc = self._doc is not None
return {
"backend": "ezdxf",
@@ -4868,6 +5268,8 @@ class EzdxfBackend(AutoCADBackend):
"document_path": self._doc_path,
"unsaved_changes": self._dirty,
"transaction_depth": len(self._transaction_stack),
"quarantine": self._quarantine.to_dict() if self._quarantine else None,
"abandoned_calls": [record.to_dict() for record in self._abandoned_calls],
"capabilities": [
"file_read_write",
"dxf_export",
+208
View File
@@ -0,0 +1,208 @@
"""Document quarantine — the vocabulary for a call that was abandoned mid-write.
``EzdxfBackend._async`` cannot cancel an ``asyncio.to_thread`` worker. When a
call overruns ``EZDXF_CALL_TIMEOUT`` the handler swaps in a fresh
``asyncio.Lock`` so later callers do not deadlock behind the orphaned one — and
that swap is precisely what removes the mutual exclusion the lock existed for.
The runaway thread keeps a live reference to the one shared ``ezdxf.Drawing``,
which is explicitly not thread-safe, through the closure it was handed.
Measured, with a runaway ``_sync`` that really appends LINEs to ``_doc``:
``entity_create_circle`` after the abandonment returned handle ``A1``;
``drawing_info`` reported 116 entities for a modelspace observed at 302 and
climbing; ``drawing_save`` returned ``{"ok": True}`` and wrote a valid DXF of
15200 of the 60000 entities the runaway would go on to write — a considered
document of a state that never existed. Only the timed-out caller was warned.
So the abandonment quarantines the DOCUMENT, not the backend. The document stays
untrusted until it is REPLACED by an object the abandoned thread has no
reference to (``drawing_open`` / ``drawing_new`` / ``drawing_close``), or until
the abandoned call is seen to finish cleanly — see ``AbandonedCall``.
This module deliberately has no ezdxf import: ``server.py`` needs the exception
type to classify a refusal on a box where a backend's dependencies are missing.
"""
from __future__ import annotations
import time
from typing import Any
__all__ = [
"AbandonedCall",
"DocumentQuarantineError",
"QuarantineRecord",
"quarantine_refusal",
"real_call_name",
]
#: Longest ``repr`` kept for a return value nobody received. A late
#: ``entity_list`` can return thousands of entities; the diagnostic is that the
#: value was lost, not what was in it.
_MAX_RESULT_REPR = 200
def real_call_name(func: Any) -> str:
"""The backend method ``func`` belongs to, not the closure's own name.
Every real backend method hands ``_async`` a closure literally named
``_sync`` — measured: ``__name__='_sync'``,
``__qualname__='EzdxfBackend.drawing_save.<locals>._sync'`` — so
``__name__`` made every production timeout read ``ezdxf call '_sync' timed
out``. The qualname carries the name the user typed and was being discarded.
"""
qualname = getattr(func, "__qualname__", "") or ""
parts = qualname.split(".")
if "<locals>" in parts:
index = parts.index("<locals>")
if index:
return parts[index - 1]
return getattr(func, "__name__", None) or "call"
class AbandonedCall:
"""Observation post for a worker thread ``_async`` can no longer await.
The obvious hook does not work, and this was checked before being relied on:
``asyncio.timeout_at`` **cancels** the Future ``_async`` awaits, so by the
time the abandon branch runs that Future is already done and
``add_done_callback`` fires immediately with ``cancelled()`` True. It reports
the *await* ending, never the thread — which ``asyncio.to_thread`` cannot
cancel. The only place the thread's outcome is observable is inside the
thread, so ``_async`` wraps every timed call in one of these.
Deliberately no ``threading.Event``: this is allocated on every ``_async``
call and an Event carries a Condition and a Lock. ``finished`` is a plain
bool written once by the worker and read from the event loop; a bool store is
atomic under the GIL and nothing here ever waits on it.
"""
__slots__ = ("func", "finished", "result", "error")
def __init__(self, func):
self.func = func
self.finished = False
self.result: Any = None
self.error: BaseException | None = None
def __call__(self, *args, **kwargs):
try:
self.result = self.func(*args, **kwargs)
return self.result
except BaseException as exc:
self.error = exc
raise
finally:
self.finished = True
class QuarantineRecord:
"""One abandoned call, and what became of it.
Holds the identity of the ``Drawing`` that was live at abandon time as well
as its path: rebinding ``self._doc`` isolates a ``_sync`` that captured
doc/msp once at entry (the shape of nearly every closure in the backend), but
one that re-reads ``self._msp()`` inside its own loop would follow the rebind
into the fresh document. ``_async`` cannot prevent that, so recovery says
when the runaway was still alive instead of implying it was fenced off.
"""
def __init__(
self,
call: str,
timeout: float,
watcher: Any,
*,
document_path: str | None = None,
document_id: int | None = None,
):
self.call = call
self.timeout = float(timeout)
self.watcher = watcher
self.document_path = document_path
self.document_id = document_id
self.stamp = time.monotonic()
self.outcome = "running"
self.detail = ""
self.released_by: str | None = None
self.result_lost = False
self.result_repr: str | None = None
def age(self) -> float:
return time.monotonic() - self.stamp
def thread_alive(self) -> bool:
return not self.watcher.finished
def capture_lost_result(self) -> None:
"""Record that the return value never reached the caller.
A late-completing ``entity_create_*`` leaves an entity in the document
whose handle nobody holds. That is not recoverable, so it is at least
reported rather than dropped.
"""
self.result_lost = True
try:
text = repr(self.watcher.result)
except Exception: # a __repr__ that raises must not break the report
text = f"<unreprable {type(self.watcher.result).__name__}>"
if len(text) > _MAX_RESULT_REPR:
text = text[: _MAX_RESULT_REPR - 3] + "..."
self.result_repr = text
def to_dict(self) -> dict:
return {
"call": self.call,
"timeout": self.timeout,
"age_seconds": round(self.age(), 3),
"thread_alive": self.thread_alive(),
"outcome": self.outcome,
"detail": self.detail,
"released_by": self.released_by,
"result_lost": self.result_lost,
"lost_return_value": self.result_repr,
"document_path": self.document_path,
}
def quarantine_refusal(record: QuarantineRecord) -> DocumentQuarantineError:
"""The refusal every later call on a quarantined document gets."""
thread = "still running" if record.thread_alive() else "has since stopped"
return DocumentQuarantineError(
record,
f"ezdxf document quarantined: '{record.call}' overran "
f"EZDXF_CALL_TIMEOUT={record.timeout:g}s {record.age():.1f}s ago and its worker "
f"thread ({thread}) cannot be cancelled, so it may still be mutating this "
"drawing. Every call on it is refused, reads included - a read of a container "
"another thread is appending to does not fail loudly, it never finishes. "
"Recover with drawing_open(path), drawing_new() or drawing_close(): those "
"rebind the document to a freshly constructed object the abandoned thread "
"cannot reach. Undo and rollback are not a way back - their snapshots may "
"themselves have been serialised mid-write. system_status() stays answerable "
"and reports the abandoned call.",
)
class DocumentQuarantineError(RuntimeError):
"""The open document is untrusted because a call was abandoned on it.
Carries **no** ``capability`` attribute, on purpose.
``server._batch_capability_of`` classifies refusals structurally — any
exception with a non-empty ``capability`` str plus a callable ``to_dict()``
is reported to the client as ``kind: "unsupported"`` and filed by
``cad_batch`` as a capability step row. Borrowing one here would tell the
client the ezdxf engine cannot draw a circle. The engine is fine; the
document is not. This needs its own kind, not a borrowed one.
"""
def __init__(self, record: QuarantineRecord, message: str):
super().__init__(message)
self.quarantine = record.to_dict()
def to_dict(self) -> dict:
return {
"ok": False,
"kind": "quarantined",
"error": str(self),
"quarantine": self.quarantine,
}
+31 -9
View File
@@ -183,7 +183,6 @@ for i in 1 2 3; do
python -m benchmarks.perf_suite --out /tmp/on-$i.json
EZDXF_CALL_TIMEOUT=0 python -m benchmarks.perf_suite --out /tmp/off-$i.json
done
git worktree remove --force /tmp/wt-v140
```
## Correctness A/B suite
@@ -202,17 +201,39 @@ python benchmarks/compare_versions.py v1.0.0 # vs a tag/ref
python benchmarks/compare_versions.py --json results.json
```
### Result — v1.5.0 vs v1.4.0 (release gate)
### Result — v1.5.1 vs v1.5.0 (release gate)
26 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).
[`results/published/ab-v1.5.0-vs-v1.5.1.json`](results/published/ab-v1.5.0-vs-v1.5.1.json).
| Version | Checks passing | Pass rate | Fixed | Regressed |
|---------|----------------|-----------|-------|-----------|
| **v1.5.0** (baseline) | 24 / 26 | 92.3 % | — | — |
| **v1.5.1** (this release) | 26 / 26 | 100 % | 2 | **0** |
v1.5.0 fails `diameter_dim_measures_the_diameter` and
`radius_dim_ignores_the_leader_length`: it has both methods and gets both
wrong. That is the defect 1.5.1 exists to fix and the reason 1.5.0 is yanked.
### Result — this release vs v1.4.0
Same suite, older baseline:
[`results/published/ab-v1.4.0-vs-v1.5.1.json`](results/published/ab-v1.4.0-vs-v1.5.1.json).
| Version | Checks passing | Pass rate | Fixed | Regressed |
|---------|----------------|-----------|-------|-----------|
| **v1.4.0** (baseline) | 21 / 26 | 80.8 % | — | — |
| **v1.5.0** (this release) | 26 / 26 | 100 % | 5 | **0** |
| **v1.5.1** (this release) | 26 / 26 | 100 % | 5 | **0** |
The pass rate is against the 26-check v1.5.0 suite; v1.4.0 passed 21/21 of the
> This row used to read `v1.5.0 | 24 / 26 | 92.3 % | 5`, which was impossible on
> its face — 21 passing plus 5 fixed is 26, not 24 — and it contradicted the
> report it linked. The cause is worth recording: the file named
> `ab-v1.4.0-vs-v1.5.0.json` was regenerated from a tree that already carried
> the dimension fixes, so it never measured the v1.5.0 *tag*. The v1.5.0 tag
> really does score 24/26, which is what the v1.5.0-baseline table above
> reports. This table now names the tree it actually measured.
The pass rate is against the 26-check current suite; v1.4.0 passed 21/21 of the
checks that existed when it shipped. The five splits into two kinds, and the
difference is the whole reason this lane distinguishes `miss` from `fail`:
@@ -222,10 +243,11 @@ difference is the whole reason this lane distinguishes `miss` from `fail`:
having the method (`miss → pass`). 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.
* **Two are repaired defects.** v1.4.0 *has* `dimension_diameter` and
`dimension_radius` and gets them wrong (`fail → pass`): `leader_length`, a
text placement, was being measured as geometry, so at default settings every
diameter callout came out 2 × `leader_length` too large.
* **Two are repaired defects.** v1.4.0 *and v1.5.0* both have
`dimension_diameter` and `dimension_radius` and both get them wrong
(`fail → pass`): `leader_length`, a text placement, was being measured as
geometry, so at default settings every diameter callout came out
2 × `leader_length` too large. Only 1.5.1 passes them.
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
@@ -0,0 +1,170 @@
{
"summary": {
"baseline_ref": "v1.4.0",
"total": 26,
"old_pass": 21,
"new_pass": 26,
"old_pct": 80.8,
"new_pct": 100.0,
"fixed": 5,
"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"
},
{
"check": "diameter_dim_measures_the_diameter",
"category": "Dimensions",
"old": "fail",
"new": "pass"
},
{
"check": "radius_dim_ignores_the_leader_length",
"category": "Dimensions",
"old": "fail",
"new": "pass"
}
]
}
@@ -0,0 +1,170 @@
{
"summary": {
"baseline_ref": "v1.5.0",
"total": 26,
"old_pass": 24,
"new_pass": 26,
"old_pct": 92.3,
"new_pct": 100.0,
"fixed": 2,
"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": "pass",
"new": "pass"
},
{
"check": "boundary_area_agrees_with_measure",
"category": "Measurement",
"old": "pass",
"new": "pass"
},
{
"check": "two_vertex_circle_has_area",
"category": "Measurement",
"old": "pass",
"new": "pass"
},
{
"check": "diameter_dim_measures_the_diameter",
"category": "Dimensions",
"old": "fail",
"new": "pass"
},
{
"check": "radius_dim_ignores_the_leader_length",
"category": "Dimensions",
"old": "fail",
"new": "pass"
}
]
}
+95
View File
@@ -10,6 +10,8 @@ from __future__ import annotations
import math
from typing import TYPE_CHECKING
from backends.quarantine import DocumentQuarantineError, quarantine_refusal
from .layers import ENGINEERING_LAYERS
from .plan_spec import (
ALL_CRITIQUE_FOCUSES,
@@ -45,6 +47,8 @@ async def _check_iso128(backend: AutoCADBackend) -> list[Issue]:
issues: list[Issue] = []
try:
layers = await backend.layer_list()
except DocumentQuarantineError:
raise
except Exception as exc:
return [Issue("warning", "iso128", f"layer_list failed: {exc}")]
allowed = set(ISO_128_LINEWEIGHTS_MM)
@@ -73,6 +77,87 @@ async def _check_iso128(backend: AutoCADBackend) -> list[Issue]:
detail={"layer": lyr.name, "lineweight": lw},
)
)
issues.extend(_check_dimension_legibility(backend))
return issues
#: ISO 3098 minimum lettering height for technical drawings, in mm.
ISO_3098_MIN_TEXT_MM = 2.5
def _check_dimension_legibility(backend: AutoCADBackend) -> list[Issue]:
"""Dimension text must be readable and use an ISO 129 decimal marker.
This lane was blind: `iso128` checked layer lineweights only, so a sheet
whose dimensions rendered at ezdxf's bare 1.0 mm with a comma separator
critiqued clean — and "zero issues" is the claim that the drawing is
conformant. Reading the settings rather than the rendered block keeps it
cheap and catches the case before a dimension is even drawn.
It reads the *effective* value — header first, then the stored dimstyle
attribute, then ezdxf's renderer fallback — because that is the order the
renderer resolves in since the DIM* header variables started reaching it.
Grading the dimstyle alone made this check wrong in both directions: it
would report a 1.0 mm defect on a sheet the header letters at 2.5, and clear
a sheet the header letters at 1.0 because the style still said 2.5.
Headless only by construction: a live AutoCAD document carries the
operator's own template, and second-guessing it from here would report
their house standard as a defect.
"""
doc = getattr(backend, "_doc", None)
if doc is None: # the live backend has no ezdxf document to read
return []
# This is the one check that reads `_doc` directly instead of going through
# `_async`, so it also misses `_async`'s quarantine gate — and it runs on
# the event loop thread, which is exactly the unsynchronised access to a
# document an abandoned worker may still be writing that the quarantine
# exists to prevent. Refuse here rather than read.
record = getattr(backend, "_quarantine", None)
if record is not None:
reap = getattr(backend, "_reap_quarantine", None)
if reap is not None:
reap()
record = getattr(backend, "_quarantine", None)
if record is not None:
raise quarantine_refusal(record)
from backends.ezdxf_backend import effective_dimvar
try:
height = float(effective_dimvar(doc, "dimtxt"))
separator = int(effective_dimvar(doc, "dimdsep"))
except DocumentQuarantineError:
raise
except Exception:
return []
issues: list[Issue] = []
if height < ISO_3098_MIN_TEXT_MM:
issues.append(
Issue(
severity="warning",
focus="iso128",
message=(
f"Dimension text is {height:g} mm; ISO 3098 sets the minimum "
f"lettering height at {ISO_3098_MIN_TEXT_MM} mm."
),
detail={"dimtxt": height, "minimum": ISO_3098_MIN_TEXT_MM},
)
)
# DIMDSEP 0 is not "unset" — ezdxf's `get_decimal_separator` renders it as a
# comma — so it is the violation, not an excuse to skip the check.
marker = "," if separator == 0 else chr(separator)
if marker != ".":
issues.append(
Issue(
severity="warning",
focus="iso128",
message=(f"Dimension decimal marker is {marker!r}; ISO 129 uses a point."),
detail={"dimdsep": separator},
)
)
return issues
@@ -81,6 +166,8 @@ async def _check_layer_color(backend: AutoCADBackend) -> list[Issue]:
issues: list[Issue] = []
try:
layers = await backend.layer_list()
except DocumentQuarantineError:
raise
except Exception:
return []
for lyr in layers:
@@ -111,6 +198,8 @@ async def _check_construction_left(backend: AutoCADBackend) -> list[Issue]:
"""
try:
ents = await backend.entity_list(limit=5000)
except DocumentQuarantineError:
raise
except Exception:
return []
leftover = [e for e in ents if "CONST" in (e.layer or "").upper()]
@@ -139,6 +228,8 @@ async def _check_untrimmed_corner(backend: AutoCADBackend) -> list[Issue]:
tol = 0.5 # mm — generous; tighten via PlanSpec.notes later
try:
lines = await backend.entity_list(type_filter="LINE", limit=5000)
except DocumentQuarantineError:
raise
except Exception:
return []
endpoints: list[tuple[str, tuple[float, float]]] = []
@@ -182,6 +273,8 @@ async def _check_duplicate_entities(backend: AutoCADBackend) -> list[Issue]:
"""Two LINEs with identical endpoints (in either direction) are duplicates."""
try:
lines = await backend.entity_list(type_filter="LINE", limit=5000)
except DocumentQuarantineError:
raise
except Exception:
return []
seen: dict[tuple, str] = {}
@@ -240,6 +333,8 @@ async def _check_dim_overlap(backend: AutoCADBackend) -> list[Issue]:
tol = 5.0 # mm
try:
ents = await backend.entity_list(limit=5000)
except DocumentQuarantineError:
raise
except Exception:
return []
dim_points: list[tuple[str, tuple[float, float]]] = []
+214
View File
@@ -0,0 +1,214 @@
"""Hold the numbers in the documents equal to the numbers in the tree.
Every figure this checks has been wrong at least once, and the expensive one was
wrong in three files at the same time: `benchmarks/README.md` and `CLAUDE.md`
described the A/B suite as 24 checks at 87.5% while the suite held 26 and the
published report said 80.8%. Nothing caught it because each file was edited on
its own and nobody re-read the other two.
The rule is the one the release snapshot already follows: a number a human
types is a number that rots, so derive it and fail when the prose disagrees.
python scripts/check_doc_numbers.py # report and exit non-zero on drift
python scripts/check_doc_numbers.py --list # show every derived value
`tests/test_release_consistency.py` runs this as a gate.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def _read(relative: str) -> str:
return (ROOT / relative).read_text(encoding="utf-8")
def _json(relative: str) -> dict:
return json.loads(_read(relative))
def _collected_tests() -> int:
"""How many tests pytest actually collects.
On the same README line as tools/resources/prompts, and the only number on
it that nothing derived -- so it drifted by 95 while its neighbours stayed
exact. Collection only; running the suite here would make the gate cost a
minute.
"""
env = dict(os.environ, AUTOCAD_MCP_BACKEND="ezdxf")
result = subprocess.run(
[sys.executable, "-m", "pytest", "--collect-only", "-q"],
cwd=ROOT,
capture_output=True,
text=True,
env=env,
timeout=300,
)
match = re.search(r"(\d+) tests collected", result.stdout)
if not match:
raise SystemExit(
"check_doc_numbers: could not read a collected-test count from pytest.\n"
+ result.stdout[-2000:]
)
return int(match.group(1))
def derived() -> dict[str, object]:
"""Every number a document is allowed to quote, computed from the tree."""
sys.path.insert(0, str(ROOT))
inventory = _json("docs/tool-inventory.json")["totals"]
ab = _json("benchmarks/results/published/ab-v1.5.0-vs-v1.5.1.json")["summary"]
perf = {
w["workload_id"]: w["wall_ms"]
for w in _json("benchmarks/results/published/perf-ezdxf.json")["workloads"]
}
from benchmarks import correctness_suite
from benchmarks.tasks_v3 import TASKS_V3
return {
"collected_tests": _collected_tests(),
"tools": inventory["tools"],
"resources": inventory["resources"],
"prompts": inventory["prompts"],
"groups": inventory["groups"],
"ab_total": ab["total"],
"ab_old_pass": ab["old_pass"],
"ab_old_pct": ab["old_pct"],
"ab_fixed": ab["fixed"],
"ab_regressed": ab["regressed"],
"suite_checks": len(correctness_suite.CHECKS),
"v3_tasks": len(TASKS_V3),
"perf_ms": perf,
}
#: (relative path, human label, regex over the file, callable -> expected value)
#:
#: Each pattern captures the number as group 1. A pattern that matches nothing is
#: itself a failure: the sentence it was written for has moved, and a silent
#: no-op gate is worse than no gate.
CHECKS: list[tuple[str, str, str, str]] = [
("README.md", "release snapshot tools", r"release snapshot:\*\*\s*(\d+) tools", "tools"),
(
"README.md",
"release snapshot resources",
r"release snapshot:.*?(\d+) resources",
"resources",
),
(
"README.md",
"release snapshot prompts",
r"release snapshot:.*?(\d+) prompt templates",
"prompts",
),
("README.md", "A/B suite size", r"(\d+) deterministic headless checks", "ab_total"),
("README.md", "A/B baseline pass", r"v1\.5\.0 \*\(baseline\)\* \| (\d+) / \d+", "ab_old_pass"),
("README.md", "A/B suite denominator", r"v1\.5\.0 \*\(baseline\)\* \| \d+ / (\d+)", "ab_total"),
("benchmarks/README.md", "A/B suite size", r"(\d+) checks, ezdxf backend", "ab_total"),
(
"benchmarks/README.md",
"A/B baseline pass",
r"\*\*v1\.5\.0\*\* \(baseline\)\s*\| (\d+) / \d+",
"ab_old_pass",
),
(
"benchmarks/README.md",
"A/B suite denominator",
r"\*\*v1\.5\.0\*\* \(baseline\)\s*\| \d+ / (\d+)",
"ab_total",
),
(
"README.md",
"release snapshot collected tests",
r"release snapshot:.*?(\d+) collected tests",
"collected_tests",
),
(
"README.md",
"A/B baseline pass rate",
r"v1\.5\.0 \*\(baseline\)\* \| \d+ / \d+ \| ([\d.]+) %",
"ab_old_pct",
),
(
"README.md",
"A/B fixed count",
r"v1\.5\.1\*\* \*\(this release\)\* \| \*\*\d+ / \d+\*\* \| \*\*\d+ %\*\* \| (\d+)",
"ab_fixed",
),
(
"benchmarks/README.md",
"A/B baseline pass rate",
r"\*\*v1\.5\.0\*\* \(baseline\)\s*\| \d+ / \d+ \| ([\d.]+) %",
"ab_old_pct",
),
(
"benchmarks/README.md",
"A/B fixed count",
r"\*\*v1\.5\.1\*\* \(this release\) \| \d+ / \d+ \| \d+ % \| (\d+)",
"ab_fixed",
),
("CLAUDE.md", "A/B suite size", r"correctness_suite\.py` \((\d+) checks\)", "ab_total"),
("CLAUDE.md", "v3 task count", r"the current set \((\d+) tasks", "v3_tasks"),
]
def run(verbose: bool = False) -> int:
values = derived()
if values["suite_checks"] != values["ab_total"]:
print(
f"DRIFT correctness_suite.py has {values['suite_checks']} checks but the published "
f"A/B report was run over {values['ab_total']} -- regenerate the report:\n"
f" python benchmarks/compare_versions.py v1.5.0 "
f"--json benchmarks/results/published/ab-v1.5.0-vs-v1.5.1.json"
)
return 1
if verbose:
for key, value in values.items():
print(f" {key:20s} {value}")
print()
failures = 0
for relative, label, pattern, key in CHECKS:
text = _read(relative)
match = re.search(pattern, text, re.DOTALL)
expected = values[key]
if match is None:
print(
f"DRIFT {relative}: the sentence carrying '{label}' no longer matches its pattern"
)
failures += 1
continue
found = type(expected)(match.group(1))
if found != expected:
print(f"DRIFT {relative}: {label} says {found}, the tree says {expected}")
failures += 1
elif verbose:
print(f" ok {relative:24s} {label:24s} {found}")
if failures:
print(f"\n{failures} document number(s) disagree with the tree.")
return 1
print(f"ok: {len(CHECKS)} document numbers match the tree")
return 0
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--list", action="store_true", help="Print every derived value")
return run(verbose=parser.parse_args().list)
if __name__ == "__main__":
raise SystemExit(main())
+82 -1
View File
@@ -151,7 +151,15 @@ def validate_path(path: str, *, allow_write: bool = False) -> Path:
# `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:
# rather than a contradiction.
#
# That "the CHANNEL" was singular was wrong, and it took an audit to notice.
# `_ensure_linetype_loaded` and `ComBackend.linetype_load` reach SendCommand
# too, carrying a caller's linetype name and file path, and neither passed
# through anything here — so the denylist below was guarding one door of two.
# `sanitize_symbol_name` / `sanitize_macro_argument` at the end of this module
# close the other. Before adding a third SendCommand site, guard it there and
# amend this paragraph rather than this one:
#
# * `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
@@ -349,3 +357,76 @@ def sanitize_lisp(expression: str) -> str:
)
return expression
# ---------------------------------------------------------------------------
# Symbol-table names interpolated into a SendCommand macro
# ---------------------------------------------------------------------------
#
# The scoped-to-the-CHANNEL argument above assumed `sanitize_command` and
# `sanitize_lisp` were the only routes into SendCommand. They were not.
# `_ensure_linetype_loaded` builds `_-LINETYPE _LOAD {name} {file}` from a
# caller's `linetype=` argument, and `ComBackend.linetype_load` adds a
# caller-supplied path. SendCommand takes a whole macro and treats a newline
# and `;` as segment separators, so either string could append commands that
# the 36-verb denylist never sees.
#
# The guard is the DXF symbol-name rule rather than a second blocklist. AutoCAD
# already forbids these characters in a table name, so nothing this rejects was
# ever a loadable linetype, and the injection characters fall out of the rule
# instead of having to be enumerated and kept up to date.
#: Characters DXF forbids in a symbol-table name, plus the whitespace that ends
#: a SendCommand segment.
_FORBIDDEN_SYMBOL_CHARS = set('<>/\\":;?*|,=`') | set("\n\r\t")
#: Long enough for any real linetype, short enough not to be a payload.
_MAX_SYMBOL_NAME = 255
def sanitize_symbol_name(name: str, *, kind: str = "symbol") -> str:
"""Return `name` if it is a legal DXF symbol-table name, else refuse.
Used at every point where a caller-controlled name is interpolated into an
AutoCAD command macro. The refusal deliberately does not echo the rejected
string back: an error message that a client logs or renders is a channel
too, and a payload that reaches a log is a payload that reached something.
"""
candidate = (name or "").strip()
if not candidate:
raise ToolError(f"{kind} name rejected: empty.")
if len(candidate) > _MAX_SYMBOL_NAME:
raise ToolError(f"{kind} name rejected: longer than {_MAX_SYMBOL_NAME} characters.")
offenders = sorted({c for c in candidate if c in _FORBIDDEN_SYMBOL_CHARS or ord(c) < 32})
if offenders:
shown = ", ".join(repr(c) for c in offenders)
raise ToolError(
f"{kind} name rejected: DXF symbol-table names cannot contain {shown}. "
"This name would also end the AutoCAD command macro it is placed in."
)
return candidate
#: What ends a segment inside a SendCommand macro. A path legitimately contains
#: ':' and '\', so a file argument cannot be held to the symbol-name rule -- it
#: gets this narrower one plus `validate_path`.
_MACRO_SEPARATORS = set("\n\r;") | {chr(c) for c in range(32)}
def sanitize_macro_argument(value: str, *, kind: str = "argument") -> str:
"""Return `value` if it cannot end the AutoCAD command macro it sits in.
For arguments that are legitimately allowed the characters DXF forbids in a
symbol name -- a file path, mostly. Narrower than `sanitize_symbol_name` on
purpose: the goal is only that the string cannot start a second command.
"""
candidate = (value or "").strip()
if not candidate:
raise ToolError(f"{kind} rejected: empty.")
if any(c in _MACRO_SEPARATORS for c in candidate):
raise ToolError(
f"{kind} rejected: contains a newline, carriage return, semicolon or "
"control character, which would end the AutoCAD command macro it is "
"placed in and start another."
)
return candidate
+79 -14
View File
@@ -198,6 +198,11 @@ class CapabilityRefusalMiddleware(Middleware):
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.
R32 added a sibling kind. A quarantined document is *not* a capability
boundary — the engine can draw a circle perfectly well, the document it would
draw into is untrusted — so it gets ``kind: "quarantined"`` and carries the
abandoned-call record instead of a capability key.
"""
async def on_call_tool(self, context: MiddlewareContext, call_next):
@@ -205,19 +210,25 @@ class CapabilityRefusalMiddleware(Middleware):
return await call_next(context)
except Exception as exc:
capability = _batch_capability_of(exc)
if capability is None:
quarantine = None if capability else _batch_quarantine_of(exc)
if capability is None and quarantine is None:
raise # not ours — leave it byte-for-byte as it was
message = _refusal_message(exc)
payload = {
"ok": False,
"error": message,
"tool": context.message.name,
"backend": _backend_name_or_none(context),
}
if capability is not None:
payload["kind"] = "unsupported"
payload["capability"] = capability
else:
payload["kind"] = "quarantined"
payload["quarantine"] = quarantine
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),
},
structured_content=payload,
is_error=True,
)
@@ -3514,6 +3525,43 @@ def _batch_capability_of(exc: BaseException) -> str | None:
return None
_quarantine_error_type: type | None = None
def _quarantine_refusal_type() -> type | None:
"""``DocumentQuarantineError``, imported lazily like the capability types.
It lives in ``backends.quarantine``, which deliberately imports no ezdxf, so
this resolves even on a box where a backend's dependencies are missing.
"""
global _quarantine_error_type
if _quarantine_error_type is None:
from backends.quarantine import DocumentQuarantineError
_quarantine_error_type = DocumentQuarantineError
return _quarantine_error_type
def _batch_quarantine_of(exc: BaseException) -> dict | None:
"""The abandoned-call record ``exc`` refuses on behalf of, or None.
Recognised by type, not structurally: ``DocumentQuarantineError``
deliberately has no ``capability`` attribute, because
``_batch_capability_of`` would then report it to the client as
``kind: "unsupported"`` — telling them the ezdxf engine cannot draw a circle
when the engine is fine and the document is not.
"""
try:
quarantine_type = _quarantine_refusal_type()
except Exception as exc_import: # pragma: no cover - core module
log.debug("quarantine refusal type unavailable: %s", exc_import)
return None
for error in _batch_cause_chain(exc):
if isinstance(error, quarantine_type):
return getattr(error, "quarantine", None) or {}
return None
def _refusal_message(exc: BaseException) -> str:
"""The most specific message on the cause chain, in full.
@@ -3560,6 +3608,16 @@ def _classify_batch_error(exc: BaseException) -> dict:
"capability": capability,
"message": _batch_message(exc),
}
quarantine = _batch_quarantine_of(exc)
if quarantine is not None:
# R32: its own kind. Filing it under "unsupported" would tell the batch
# reader the engine lacks a feature; filing it under "failed" would hide
# that every remaining step will refuse for the same reason.
return {
"kind": "quarantined",
"quarantine": quarantine,
"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)}
@@ -4849,8 +4907,10 @@ async def drawing_settings(
description=(
"Omit to READ every setting; pass a dict to CHANGE them. Friendly keys: "
"units (mm/cm/m/inch/feet), linear_precision, angular_precision, ltscale, "
"dimscale, text_size, point_mode, point_size, osmode, fillet_radius. "
'Example: {"units": "mm", "dimscale": 1.0, "linear_precision": 2}.'
"dimscale, dim_text_height, dim_arrow_size, dim_decimals, "
'decimal_separator ("." or ","), zero_suppression, text_size, point_mode, '
"point_size, osmode, fillet_radius. "
'Example: {"units": "mm", "dimscale": 1.0, "dim_text_height": 3.5}.'
),
),
] = None,
@@ -4859,9 +4919,14 @@ async def drawing_settings(
"""Read or change common AutoCAD drawing settings by friendly name.
A convenience facade over the system variables (INSUNITS, LUPREC, LTSCALE,
DIMSCALE, TEXTSIZE, OSMODE, …) so the user can say "set units to mm and
dimension scale to 1" without memorising sysvar names. Call with no argument
to get a full snapshot of the current settings.
DIMSCALE, DIMTXT, DIMASZ, DIMDEC, DIMDSEP, DIMZIN, TEXTSIZE, OSMODE, …) so
the user can say "set units to mm and dimension text to 3.5" without
memorising sysvar names. Call with no argument to get a full snapshot of the
current settings.
`dim_text_height` / `dim_arrow_size` / `dim_decimals` / `decimal_separator`
/ `zero_suppression` shape the *dimension* — `text_size` is TEXTSIZE, the
height of a standalone TEXT entity, and does not touch dimensions.
"""
if settings:
await ctx.info(f"Applying drawing settings: {', '.join(settings)}")
+50
View File
@@ -431,3 +431,53 @@ async def test_a_simple_polyline_is_not_flagged():
backend = _measure_backend(entity)
assert (await backend.entity_measure("8A5"))["self_intersecting"] is False
# ── the member profile, measured on a live AutoCAD 2026 (2026-08-06) ────────
#
# Probed on a fresh seat with a throwaway drawing, nothing saved:
# AcDbCircle Area 314.1593 Circumference 62.8319 (no Length, no Coordinates)
# AcDbPolyline Area 1600.0 Length 160.0 Coordinates present
# AcDbHatch Area 942.4778 -- r20 with an r10 island, so pi*(400-100):
# AutoCAD's hatch area DOES subtract islands
# AcDbRegion Area 1600.0 Perimeter 160.0
# AcDb3dSolid Area ABSENT Volume 1000.0
#
# The last row is the one that matters: ActiveX exposes no `.Area` on a 3DSOLID
# at all, so "REGION / 3DSOLID area" was half a claim.
@pytest.mark.asyncio
async def test_a_circle_uses_circumference_for_its_perimeter():
"""Measured: AcDbCircle has `.Circumference` and no `.Length`."""
backend = _measure_backend(_Entity("AcDbCircle", Area=314.1593, Circumference=62.8319))
result = await backend.entity_measure("C01")
assert result["area"] == pytest.approx(314.1593)
assert result["perimeter"] == pytest.approx(62.8319)
assert result["perimeter_exact"] is True
@pytest.mark.asyncio
async def test_a_region_uses_perimeter():
"""Measured: AcDbRegion has `.Perimeter` and no `.Length`."""
backend = _measure_backend(_Entity("AcDbRegion", Area=1600.0, Perimeter=160.0))
result = await backend.entity_measure("R01")
assert result["area"] == pytest.approx(1600.0)
assert result["perimeter"] == pytest.approx(160.0)
@pytest.mark.asyncio
async def test_a_3dsolid_is_refused_by_name_because_activex_has_no_area_for_it():
"""Measured: AcDb3dSolid exposes Volume and no Area at all.
The generic "neither an area nor usable vertices" would send the reader
hunting for geometry that was never the problem.
"""
backend = _measure_backend(_Entity("AcDb3dSolid", Volume=1000.0))
with pytest.raises(RuntimeError, match="no surface-area member"):
await backend.entity_measure("S01")
+74
View File
@@ -117,6 +117,80 @@ async def test_genuine_deadline_still_rebuilds_the_executor(com_backend, monkeyp
assert await asyncio.wait_for(com_backend._run(lambda: "fresh"), timeout=3) == "fresh"
# ── R32 — what the timeout message must admit, and the undo mark it must record ──
async def test_com_timeout_says_the_call_may_still_land(com_backend, monkeypatch):
"""AutoCAD's own command processor arbitrates the abandoned call, not us.
Unlike the ezdxf backend there is no ``self._doc`` to quarantine — the
drawing belongs to the operator and lives in AutoCAD's process. What the
server *can* do is stop the message implying the operation is dead: the
worker is still blocked inside an uncancellable COM call, so a blind retry
double-applies it.
"""
monkeypatch.setattr(config.settings, "com_call_timeout", 0.2)
release = threading.Event()
finished = threading.Event()
def _hang():
try:
release.wait(5.0)
finally:
finished.set()
try:
with pytest.raises(RuntimeError) as excinfo:
await com_backend._run(_hang)
finally:
release.set()
assert finished.wait(5)
message = str(excinfo.value).lower()
assert "may still" in message, "the abandoned call can still execute in AutoCAD"
assert "retry" in message and "double" in message
async def test_timed_out_transaction_begin_does_not_hide_an_open_undo_mark(
com_backend, monkeypatch
):
"""RED: ``_transaction_active`` was set only after ``_run`` returned.
A timed-out ``transaction_begin`` therefore left ``StartUndoMark`` open in
AutoCAD while the server recorded no transaction, and the next
``transaction_begin`` nested a second mark nobody could see — the exact
inverse of the R16 fix ``transaction_commit`` already carries.
"""
monkeypatch.setattr(config.settings, "com_call_timeout", 0.2)
release = threading.Event()
finished = threading.Event()
marks = []
class _FakeDoc:
def StartUndoMark(self):
marks.append("start")
try:
release.wait(5.0)
finally:
finished.set()
monkeypatch.setattr(cb, "_acad_doc", lambda: _FakeDoc())
try:
with pytest.raises(RuntimeError, match="did not respond"):
await com_backend.transaction_begin()
assert com_backend._transaction_active is True, (
"the mark may already be set in AutoCAD; recording nothing is the unsafe direction"
)
again = await com_backend.transaction_begin()
assert again == {"ok": False, "error": "A transaction is already active"}
assert marks == ["start"], "the second begin must not nest another undo mark"
finally:
release.set()
assert finished.wait(5)
def _raise_value_error():
raise ValueError("nope")
+342
View File
@@ -0,0 +1,342 @@
"""DIM* header variables have to reach the dimension, not just the header.
v1.5.1 fixed exactly one of them. `system_set_variable("DIMSCALE", 4)` grew a
fold that carried `$DIMSCALE` onto the per-dimension override, because ezdxf
renders a DIMENSION from the dimstyle table entry plus that override and never
reads the document header. Every *other* DIM* variable kept the original defect:
drawing_new()
system_set_variable("DIMTXT", 5.0) -> {"ok": True}
system_set_variable("DIMASZ", 6.0) -> {"ok": True}
system_set_variable("DIMDEC", 1) -> {"ok": True}
system_set_variable("DIMDSEP", 44) -> {"ok": True}
system_set_variable("DIMZIN", 0) -> {"ok": True}
dimension_linear(0, 0, 33.333, 0, 16, -30)
Measured before the fix: five `ok: True` results, a header that reads back every
one of the five values, and a dimension still rendered at 2.5 mm text with 2.5
arrows and "33.33" — byte-identical to the one drawn before the writes. A live
AutoCAD seat builds a dimension from the current dimvars, so the same five calls
did change the drawing there: one tool call, one reported success, two engines
drawing different sheets.
These tests read the *rendered* geometry block rather than the header, because
the header was never the thing that was broken.
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
def _rendered(backend, handle):
"""(text, text height, arrow scales) out of the dimension's geometry block.
The override lands in the dimension's XDATA, not on `dim.dxf`, so the block
ezdxf renders is the only honest witness. Arrow size shows up as the xscale
of the `_CLOSEDFILLED` arrowhead INSERT — the one non-text observable here,
which is what keeps these tests from passing on a text-only patch.
"""
dim = backend._doc.entitydb.get(handle)
block = backend._doc.blocks.get(dim.dxf.geometry)
text = None
height = None
arrows = []
for entity in block:
if entity.dxftype() == "MTEXT" and text is None:
text, height = entity.text, round(float(entity.dxf.char_height), 4)
elif entity.dxftype() == "TEXT" and text is None:
text, height = entity.dxf.text, round(float(entity.dxf.height), 4)
elif entity.dxftype() == "INSERT":
arrows.append(round(float(entity.dxf.get("xscale", 1.0)), 4))
if text is None:
raise AssertionError("the dimension block carries no text")
return text, height, sorted(set(arrows))
# ── text height ─────────────────────────────────────────────────────────────
async def test_dimtxt_reaches_the_next_dimension(backend):
"""Asserted as a ratio, for the reason recorded in the DIMSCALE tests.
Hardcoding 5.0 would hide the day the base height moves; hardcoding 2.5
would make the test depend on the ISO default it is not measuring.
"""
plain = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30)
_t, base, _a = _rendered(backend, plain.handle)
await backend.system_set_variable("DIMTXT", base * 2)
after = await backend.dimension_linear(0, 0, 33.333, 0, 16, -60)
_t, height, _a = _rendered(backend, after.handle)
assert height == pytest.approx(base * 2), (
f"DIMTXT must letter the next dimension: {base} -> {height}"
)
async def test_dimtxt_and_dimscale_still_multiply(backend):
"""`text_height = get_char_height(...) * dimscale` (ezdxf dim_base.py).
Both fold into the same override dict, so the two must compose rather than
the later one winning.
"""
plain = await backend.dimension_linear(0, 0, 100, 0, 50, -30)
_t, base, _a = _rendered(backend, plain.handle)
await backend.system_set_variable("DIMTXT", base * 2)
await backend.system_set_variable("DIMSCALE", 3.0)
after = await backend.dimension_linear(0, 0, 100, 0, 50, -60)
_t, height, _a = _rendered(backend, after.handle)
assert height == pytest.approx(base * 6)
async def test_a_fixed_height_text_style_still_defeats_dimtxt(backend):
"""A limitation this fold cannot repair, pinned so nobody assumes it can.
`get_char_height` reads the *text style's* height first and only falls back
to DIMTXT when it is 0 (ezdxf dim_base.py). Measured: with the `Standard`
text style at 3.0 mm, DIMTXT 5.0 still renders 3.0 — the header write
reports success and the sheet does not move. Only a height-0 (variable)
text style lets DIMTXT through, which is what `drawing_new` ships.
"""
backend._doc.styles.get("Standard").dxf.height = 3.0
await backend.system_set_variable("DIMTXT", 5.0)
dim = await backend.dimension_linear(0, 0, 100, 0, 50, -30)
_t, height, _a = _rendered(backend, dim.handle)
assert height == pytest.approx(3.0), "a fixed-height text style outranks DIMTXT"
# ── arrows: the one non-text observable ─────────────────────────────────────
async def test_dimasz_scales_the_arrowheads(backend):
plain = await backend.dimension_linear(0, 0, 100, 0, 50, -30)
_t, _h, base = _rendered(backend, plain.handle)
assert base, "no arrowhead INSERT to measure"
await backend.system_set_variable("DIMASZ", 6.0)
after = await backend.dimension_linear(0, 0, 100, 0, 50, -60)
_t, _h, arrows = _rendered(backend, after.handle)
assert arrows == pytest.approx([6.0]), f"DIMASZ must size the arrows: {base} -> {arrows}"
# ── number formatting ───────────────────────────────────────────────────────
@pytest.mark.parametrize(("decimals", "expected"), [(1, "33.3"), (0, "33")])
async def test_dimdec_sets_the_decimal_places(backend, decimals, expected):
await backend.system_set_variable("DIMDEC", decimals)
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30)
text, _h, _a = _rendered(backend, dim.handle)
assert text == expected
async def test_dimdsep_switches_the_decimal_marker(backend):
"""44 is the char code for a comma; ezdxf refuses a string in the header."""
await backend.system_set_variable("DIMDSEP", 44)
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30)
text, _h, _a = _rendered(backend, dim.handle)
assert text == "33,33"
async def test_an_untouched_drawing_still_uses_a_point(backend):
"""The converse regression: ISO 129 wants a point, and ezdxf's own renderer
fallback for `dimdsep` is 0 -> a comma. A fold keyed on the wrong baseline
would reintroduce the comma on every dimension."""
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30)
text, _h, _a = _rendered(backend, dim.handle)
assert text == "33.33"
@pytest.mark.parametrize(("dimzin", "expected"), [(0, "100.000"), (8, "100")])
async def test_dimzin_controls_trailing_zeros(backend, dimzin, expected):
await backend.system_set_variable("DIMDEC", 3)
await backend.system_set_variable("DIMZIN", dimzin)
dim = await backend.dimension_linear(0, 0, 100, 0, 50, -30)
text, _h, _a = _rendered(backend, dim.handle)
assert text == expected
# ── all five tools, not just linear ─────────────────────────────────────────
async def test_dimtxt_reaches_all_five_dimension_tools(backend):
"""aligned and angular do not go through `build_dim_override`.
That is exactly how they were missed when DIMSCALE was folded the first
time (see `test_dimscale_reaches_aligned_and_angular_too`), so every tool
is measured here rather than trusting the shared helper.
"""
plain = {
"linear": await backend.dimension_linear(0, 0, 100, 0, 50, -30),
"aligned": await backend.dimension_aligned(0, 0, 60, 60, 40, 70),
"angular": await backend.dimension_angular(0, 0, 50, 0, -30, 50, 30, 30),
"radius": await backend.dimension_radius(0, 0, 20, 0, 10),
"diameter": await backend.dimension_diameter(0, 0, 40, 0, 10),
}
base = {name: _rendered(backend, info.handle)[1] for name, info in plain.items()}
await backend.system_set_variable("DIMTXT", 5.0)
scaled = {
"linear": await backend.dimension_linear(0, 0, 100, 0, 50, -60),
"aligned": await backend.dimension_aligned(0, 0, 60, 60, 40, 90),
"angular": await backend.dimension_angular(0, 0, 50, 0, -30, 50, 50, 50),
"radius": await backend.dimension_radius(0, 0, 20, 0, 30),
"diameter": await backend.dimension_diameter(0, 0, 40, 0, 30),
}
for name, info in scaled.items():
_t, height, _a = _rendered(backend, info.handle)
assert height == pytest.approx(5.0), f"{name}: {base[name]} -> {height}, wanted 5.0"
async def test_angular_text_is_a_different_knob_and_is_deliberately_not_folded(backend):
"""DIMDEC does not format an angular dimension — DIMADEC does.
Pinned because $DIMADEC and $DIMAZIN sit at 0 in a fresh header while
ezdxf's renderer falls back to 2 and 2. Adding them to the fold table to
make it look "complete" would silently reformat every angular dimension
this server has ever drawn, from 239.04 deg to 239 deg. The allowlist is
measured, not exhaustive.
"""
await backend.system_set_variable("DIMDEC", 0)
dim = await backend.dimension_angular(0, 0, 50, 0, -30, 50, 30, 30)
text, _h, _a = _rendered(backend, dim.handle)
assert text.startswith("239.04"), f"angular text must keep DIMADEC's 2 places, got {text!r}"
# ── the fold must not litter ────────────────────────────────────────────────
async def test_a_fresh_drawing_carries_no_dimension_override(backend):
"""Generalises `test_the_default_dimscale_adds_no_override`.
After `drawing_new` the header and the `Standard` dimstyle agree on every
folded variable ($DIMTXT 2.5/2.5, $DIMASZ 2.5/2.5, $DIMDEC 2/2, $DIMDSEP
46/46, $DIMSCALE and $DIMZIN unset on the style and equal to the renderer
fallback), so the fold has nothing to say and the dimension must carry no
XDATA override at all.
"""
dim = await backend.dimension_linear(0, 0, 100, 0, 50, -30)
entity = backend._doc.entitydb.get(dim.handle)
style = backend._doc.dimstyles.get("Standard")
assert entity.get_acad_dstyle(style) == {}
async def test_the_basic_tolerance_box_survives_the_fold(backend):
"""`tol_mode="basic"` sets dimgap=-1.0 to draw the theoretically-exact box.
The fold runs *after* `build_dim_override` and must only `setdefault`, so a
caller's explicit value always outranks the header. dimgap is not in the
table today, but this pins the ordering that protects it if it ever is.
"""
await backend.system_set_variable("DIMTXT", 5.0)
dim = await backend.dimension_linear(0, 0, 100, 0, 50, -30, tol_mode="basic")
entity = backend._doc.entitydb.get(dim.handle)
style = backend._doc.dimstyles.get("Standard")
overrides = entity.get_acad_dstyle(style)
assert overrides.get("dimgap") == pytest.approx(-1.0)
assert overrides.get("dimtxt") == pytest.approx(5.0)
# ── precedence, both directions ─────────────────────────────────────────────
async def test_setting_the_variable_back_reverts_the_next_dimension(backend):
plain = await backend.dimension_linear(0, 0, 100, 0, 50, -30)
_t, base, _a = _rendered(backend, plain.handle)
await backend.system_set_variable("DIMTXT", 5.0)
await backend.dimension_linear(0, 0, 100, 0, 50, -60)
await backend.system_set_variable("DIMTXT", base)
reverted = await backend.dimension_linear(0, 0, 100, 0, 50, -90)
_t, height, _a = _rendered(backend, reverted.handle)
assert height == pytest.approx(base)
async def test_a_rollback_restores_the_header_and_the_next_dimension(backend):
"""Transactions snapshot the whole DXF, header included."""
plain = await backend.dimension_linear(0, 0, 100, 0, 50, -30)
_t, base, _a = _rendered(backend, plain.handle)
await backend.transaction_begin()
await backend.system_set_variable("DIMTXT", 5.0)
await backend.transaction_rollback()
after = await backend.dimension_linear(0, 0, 100, 0, 50, -60)
_t, height, _a = _rendered(backend, after.handle)
assert height == pytest.approx(base)
# ── opened files ────────────────────────────────────────────────────────────
async def test_an_opened_file_honours_its_own_header(backend, tmp_path):
"""A file whose header disagrees with its dimstyle used to draw the style.
`drawing_open` never runs `_apply_iso_dimstyle`, so before the fold the
header of an opened template was decorative: it could claim $DIMTXT 5.0
and every dimension still came out at the style's 2.5. AutoCAD builds a
dimension from the current dimvars, so the file stops lying about itself.
"""
import ezdxf
doc = ezdxf.new(dxfversion="R2010")
doc.dimstyles.get("Standard").dxf.dimtxt = 2.5
doc.header["$DIMTXT"] = 5.0
path = tmp_path / "header_says_five.dxf"
doc.saveas(str(path))
await backend.drawing_open(str(path))
dim = await backend.dimension_linear(0, 0, 100, 0, 50, -30)
_t, height, _a = _rendered(backend, dim.handle)
assert height == pytest.approx(5.0)
# ── the fold itself ─────────────────────────────────────────────────────────
async def test_a_header_without_the_variable_does_not_break_the_fold():
"""An opened file may be missing a DIM* var, or carry junk in it."""
from backends.ezdxf_backend import _with_header_dimvars
class _Header:
def get(self, name, default=None):
return {"$DIMTXT": "not a number"}.get(name, default)
class _Doc:
header = _Header()
dimstyles = None # resolving the style must not be required either
assert _with_header_dimvars(_Doc(), None) is None
assert _with_header_dimvars(_Doc(), {"dimgap": -1.0}) == {"dimgap": -1.0}
async def test_the_dollar_prefixed_name_is_still_rejected(backend):
"""`system_set_variable` prefixes '$' unconditionally, so '$DIMTXT' becomes
'$$DIMTXT' and ezdxf refuses it. Pinned because the original bug report
reached for the dollar form and read the resulting exception as the no-op:
a test written that way would fail for the wrong reason forever."""
with pytest.raises(Exception, match=r"\$\$DIMTXT"):
await backend.system_set_variable("$DIMTXT", 5.0)
+244
View File
@@ -0,0 +1,244 @@
"""Headless dimensions must be readable ISO drawings, not ezdxf defaults.
`drawing_new` called `ezdxf.new(dxfversion=...)` without `setup=True`, and
nothing in the repo ever created or configured a dimstyle. So every headless
dimension rendered through ezdxf's bare `Standard` style: **1.0 mm** text with a
**comma** decimal marker. ISO 3098 puts the minimum lettering height at 2.5 mm,
and ISO 129 wants a point.
Two things made that worse than a cosmetic default:
* The document's own header already said ``$DIMTXT 2.5``. ezdxf renders from the
dimstyle table entry and never reads the header, so the file asserted one
height and drew another.
* A live AutoCAD seat renders from whatever its template carries, so the two
engines printed a different text height *and* a different decimal marker for
byte-identical tool calls, with no capability key or test recording it.
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
#: ISO 3098 minimum lettering height for technical drawings.
ISO_MIN_TEXT_MM = 2.5
def _rendered(backend, handle):
"""The text and height a drafter actually sees, out of the dimension block."""
dim = backend._doc.entitydb.get(handle)
block = backend._doc.blocks.get(dim.dxf.geometry)
for entity in block:
if entity.dxftype() == "MTEXT":
return entity.text, float(entity.dxf.char_height)
if entity.dxftype() == "TEXT":
return entity.dxf.text, float(entity.dxf.height)
raise AssertionError("the dimension block carries no text")
async def test_a_headless_dimension_is_lettered_at_iso_height(backend):
dim = await backend.dimension_linear(0, 0, 100, 0, 50, -20, layer="DIM")
_text, height = _rendered(backend, dim.handle)
assert height >= ISO_MIN_TEXT_MM, (
f"rendered at {height} mm; ISO 3098 minimum is {ISO_MIN_TEXT_MM} mm"
)
async def test_the_decimal_marker_is_a_point(backend):
"""ISO 129 wants a point. ezdxf's bare default is a comma."""
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -20, layer="DIM")
text, _height = _rendered(backend, dim.handle)
assert "," not in text, f"rendered {text!r} with a comma decimal marker"
assert "33.33" in text
async def test_the_header_and_the_dimstyle_agree_about_text_height(backend):
"""The file used to assert $DIMTXT 2.5 and draw 1.0."""
header = float(backend._doc.header.get("$DIMTXT"))
style = backend._doc.dimstyles.get("Standard")
assert float(style.dxf.dimtxt) == pytest.approx(header)
async def test_a_reopened_drawing_keeps_the_style(backend, tmp_path):
"""The style has to be in the file, not applied in memory on the way out."""
import ezdxf
dim = await backend.dimension_linear(0, 0, 100, 0, 50, -20, layer="DIM")
path = tmp_path / "styled.dxf"
await backend.drawing_save_as(str(path))
reopened = ezdxf.readfile(str(path))
style = reopened.dimstyles.get("Standard")
assert float(style.dxf.dimtxt) >= ISO_MIN_TEXT_MM
assert int(style.dxf.dimdsep) == ord(".")
assert reopened.entitydb.get(dim.handle) is not None
# ── the gate has to be able to see it ───────────────────────────────────────
async def test_the_iso_critique_catches_sub_iso_dimension_text(backend):
"""A drawing whose dimensions are 1.0 mm used to critique clean.
Fixing the default stops this server *producing* such a drawing, but the
gate still has to catch one — a template, an opened file, or a caller who
sets the style back can all reintroduce it, and `drawing_critique`
returning [] is the claim that the sheet is ISO-conformant.
Both the style and the header are pushed down here. Since the DIM* header
fold, the header outranks the style at render time, so setting the style
alone no longer produces a 1.0 mm drawing — it would leave this test
asserting a defect that is not on the sheet.
"""
from engineering.critique import run_critique
backend._doc.dimstyles.get("Standard").dxf.dimtxt = 1.0
await backend.system_set_variable("DIMTXT", 1.0)
await backend.dimension_linear(0, 0, 100, 0, 50, -20, layer="DIM")
issues = await run_critique(backend, focus=["iso128"])
assert issues, "1.0 mm dimension text is below the ISO 3098 minimum"
assert any("2.5" in issue.message for issue in issues)
async def test_the_iso_critique_catches_a_comma_decimal_marker(backend):
from engineering.critique import run_critique
backend._doc.dimstyles.get("Standard").dxf.dimdsep = ord(",")
await backend.system_set_variable("DIMDSEP", ord(","))
await backend.dimension_linear(0, 0, 33.333, 0, 16, -20, layer="DIM")
issues = await run_critique(backend, focus=["iso128"])
assert any("decimal" in issue.message.lower() for issue in issues)
async def test_a_conformant_drawing_still_critiques_clean(backend):
"""The rule must not fire on the styles this server now ships."""
from engineering.critique import run_critique
await backend.drawing_apply_iso_layers("mech")
await backend.dimension_linear(0, 0, 100, 0, 50, -20, layer="DIM")
assert await run_critique(backend, focus=["iso128"]) == []
async def test_the_critique_grades_what_will_actually_be_drawn(backend):
"""When the header and the style disagree, the header is what gets drawn.
Reading the style alone made `drawing_critique` able to be wrong in both
directions once DIM* header variables started reaching the renderer: it
would report a 1.0 mm defect on a sheet lettered at 2.5, and clear a sheet
lettered at 1.0 because the style still said 2.5. The gate has to resolve
the same header-then-style order the renderer does.
"""
from engineering.critique import run_critique
# Style below the minimum, header above it -> the sheet is conformant.
backend._doc.dimstyles.get("Standard").dxf.dimtxt = 1.0
await backend.system_set_variable("DIMTXT", 2.5)
dim = await backend.dimension_linear(0, 0, 100, 0, 50, -20, layer="DIM")
assert _rendered(backend, dim.handle)[1] == pytest.approx(2.5)
assert await run_critique(backend, focus=["iso128"]) == []
# Style above it, header below -> the sheet is not.
backend._doc.dimstyles.get("Standard").dxf.dimtxt = 2.5
await backend.system_set_variable("DIMTXT", 1.0)
dim = await backend.dimension_linear(0, 0, 100, 0, 50, -60, layer="DIM")
assert _rendered(backend, dim.handle)[1] == pytest.approx(1.0)
assert await run_critique(backend, focus=["iso128"])
# ── the DIM* header fold must not fight the ISO defaults ────────────────────
async def test_the_iso_defaults_survive_the_header_fold(backend):
"""The regression that fails if the fold is keyed on the wrong baseline.
The fold skips a variable when the header already agrees with what the
renderer would use. That baseline is ezdxf's *renderer* fallback (dimtxt
1.0, dimasz 0.25, dimdsep 0 -> comma), not the DXF schema default (2.5,
2.5, 44). Key it on the schema defaults and a fresh drawing starts folding
values it should leave alone — most visibly reintroducing the comma this
file exists to keep out.
"""
from engineering.critique import run_critique
await backend.drawing_apply_iso_layers("mech")
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -20, layer="DIM")
text, height = _rendered(backend, dim.handle)
assert height == pytest.approx(ISO_MIN_TEXT_MM)
assert text == "33.33"
block = backend._doc.blocks.get(backend._doc.entitydb.get(dim.handle).dxf.geometry)
arrows = {round(float(e.dxf.get("xscale", 1.0)), 3) for e in block if e.dxftype() == "INSERT"}
assert arrows == {2.5}
assert await run_critique(backend, focus=["iso128"]) == []
# ── DIMRND 0.0 means "no rounding", not "round to whole units" ──────────────
async def _reopened(backend, tmp_path):
path = tmp_path / "roundtrip.dxf"
await backend.drawing_save_as(str(path))
await backend.drawing_open(str(path))
return path
def _text(backend, handle) -> str:
dim = backend._doc.entitydb.get(handle)
for entity in backend._doc.blocks.get(dim.dxf.geometry):
if entity.dxftype() == "MTEXT":
return entity.text
if entity.dxftype() == "TEXT":
return entity.dxf.text
raise AssertionError("the dimension block carries no text")
async def test_a_reopened_drawing_does_not_round_every_dimension_to_a_whole_unit(backend, tmp_path):
"""A saved file stores `dimrnd = 0.0`, and ezdxf rounds on it.
AutoCAD's DIMRND 0 means *no* rounding. ezdxf applies `xround(value,
dimrnd)` whenever the attribute is present at all, and `xround(33.333, 0.0)`
is 33 — so every dimension on any reopened file was silently rounded to a
whole unit *before* DIMDEC ever formatted it. Measured on a file this
server saved itself: a 12.75 mm feature dimensioned as 13.
"""
await _reopened(backend, tmp_path)
await backend.drawing_settings({"dim_decimals": 2})
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30)
assert _text(backend, dim.handle) == "33.33"
async def test_dim_decimals_still_works_after_a_reopen(backend, tmp_path):
await _reopened(backend, tmp_path)
for decimals, expected in ((0, "33"), (1, "33.3"), (3, "33.333")):
await backend.drawing_settings({"dim_decimals": decimals})
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30 * (decimals + 2))
assert _text(backend, dim.handle) == expected
async def test_a_real_rounding_value_is_left_alone(backend, tmp_path):
"""Only the 0.0 sentinel is discarded — a drafter who asked for 0.5 mm
rounding must keep it."""
backend._doc.dimstyles.get("Standard").dxf.dimrnd = 0.5
await _reopened(backend, tmp_path)
assert float(backend._doc.dimstyles.get("Standard").dxf.dimrnd) == 0.5
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30)
# 33.5, not 33.33: the rounding survived. The trailing zero is gone because
# DIMZIN defaults to 8, which suppresses it — that is formatting, and it is
# a different knob from the rounding this test is about.
assert _text(backend, dim.handle) == "33.5"
+82 -6
View File
@@ -130,10 +130,86 @@ async def test_the_default_dimscale_adds_no_override(backend):
async def test_dimscale_reaches_aligned_and_angular_too(backend):
"""Those two do not go through `build_dim_override`, so they were missed."""
await backend.drawing_settings({"dimscale": 3.0})
aligned = await backend.dimension_aligned(0, 0, 60, 60, 40, 70)
angular = await backend.dimension_angular(0, 0, 50, 0, 0, 50, 30, 30)
"""Those two do not go through `build_dim_override`, so they were missed.
for handle in (aligned.handle, angular.handle):
assert _dim_text_heights(backend, handle) == pytest.approx([3.0])
Asserted as a *ratio* against the same dimension drawn unscaled. This used
to hardcode 3.0, which only held because the base text height was ezdxf's
bare 1.0 mm — so the assertion silently depended on the very defect that
made every headless drawing non-conformant, and moving the base to the ISO
2.5 mm broke a test that was measuring the right thing by accident.
"""
plain_aligned = await backend.dimension_aligned(0, 0, 60, 60, 40, 70)
plain_angular = await backend.dimension_angular(0, 0, 50, 0, 0, 50, 30, 30)
base_aligned = _dim_text_heights(backend, plain_aligned.handle)
base_angular = _dim_text_heights(backend, plain_angular.handle)
await backend.drawing_settings({"dimscale": 3.0})
aligned = await backend.dimension_aligned(0, 0, 60, 60, 40, 90)
angular = await backend.dimension_angular(0, 0, 50, 0, 0, 50, 50, 50)
assert _dim_text_heights(backend, aligned.handle) == pytest.approx(
[h * 3.0 for h in base_aligned]
)
assert _dim_text_heights(backend, angular.handle) == pytest.approx(
[h * 3.0 for h in base_angular]
)
# ── the rest of the dimension variables need friendly names too ─────────────
def _dim_text(backend, handle) -> str:
dim = backend._doc.entitydb.get(handle)
for entity in backend._doc.blocks.get(dim.dxf.geometry):
if entity.dxftype() == "MTEXT":
return entity.text
if entity.dxftype() == "TEXT":
return entity.dxf.text
raise AssertionError("the dimension block carries no text")
async def test_the_dimension_variables_have_friendly_names(backend):
"""DIMTXT / DIMASZ / DIMDEC / DIMDSEP / DIMZIN had no friendly key at all.
`text_size` is TEXTSIZE — the height of a standalone TEXT entity, not of a
dimension — so the only way to reach dimension lettering was raw
`system_set_variable`, which is exactly the path that silently did nothing
headlessly. A drafter should not have to know a sysvar name to set the
height of the numbers on their own drawing.
"""
res = await backend.drawing_settings(
{
"dim_text_height": 5.0,
"dim_arrow_size": 6.0,
"dim_decimals": 1,
"decimal_separator": ",",
"zero_suppression": 0,
}
)
assert res["ok"] is True, res.get("errors")
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30)
assert _dim_text(backend, dim.handle) == "33,3"
assert _dim_text_heights(backend, dim.handle) == pytest.approx([5.0])
async def test_the_decimal_separator_reads_back_as_a_character(backend):
"""DIMDSEP is an int char code in the header — ezdxf refuses a string
there. The friendly key is the place to translate, so a snapshot says
',' rather than 44."""
await backend.drawing_settings({"decimal_separator": ","})
snap = (await backend.drawing_settings())["settings"]
assert snap["decimal_separator"] == ","
assert snap["dim_text_height"] == 2.5
async def test_a_bad_decimal_separator_is_reported_and_not_written(backend):
before = await backend.system_get_variable("DIMDSEP")
res = await backend.drawing_settings({"decimal_separator": "x"})
assert "decimal_separator" in res["errors"]
assert res["ok"] is False
assert await backend.system_get_variable("DIMDSEP") == before
+42 -16
View File
@@ -29,6 +29,7 @@ import pytest
import config
from backends.com_backend import ComBackend
from backends.ezdxf_backend import EzdxfBackend
from backends.quarantine import DocumentQuarantineError
pytestmark = pytest.mark.asyncio
@@ -113,31 +114,45 @@ async def test_both_backends_declare_the_same_capability_keys():
async def test_slow_call_times_out_and_backend_stays_usable(backend, monkeypatch):
"""The anti-deadlock guarantee, restated after R32.
This used to assert that the next ``drawing_info`` / ``entity_create_line``
*succeeded*, on the strength of a ``_hang`` that only slept and touched no
document. Both assertions were the defect written down as a requirement: the
lock swap that stops the deadlock is exactly what drops mutual exclusion on
the one shared ``Drawing``, so "succeeded" meant "ran against a document
another thread may still be writing". The guarantee that actually holds is
that the next call returns *immediately* — refused, not queued.
"""
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 0.2)
entered = threading.Event()
release = threading.Event()
def _hang():
entered.set()
time.sleep(2.0)
release.wait(5.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"
try:
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
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
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 10)
started = time.monotonic()
with pytest.raises(DocumentQuarantineError):
await asyncio.wait_for(backend.drawing_info(), timeout=3)
with pytest.raises(DocumentQuarantineError):
await asyncio.wait_for(backend.entity_create_line(0, 0, 1, 1), timeout=3)
assert time.monotonic() - started < 1.0, "refused up front, never queued"
finally:
release.set()
async def test_waiting_on_a_busy_backend_times_out_without_stealing_the_lock(backend, monkeypatch):
@@ -151,6 +166,10 @@ async def test_waiting_on_a_busy_backend_times_out_without_stealing_the_lock(bac
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"
# R32: nothing was abandoned here, so nothing may be quarantined — that
# lock is held by a live call which will release it normally.
assert backend._quarantine is None
assert backend._abandoned_calls == []
finally:
live.release()
@@ -166,6 +185,10 @@ async def test_zero_timeout_disables_the_check(backend, monkeypatch):
return "finished"
assert await backend._async(_slow) == "finished"
# R32: with the deadline off there is no abandon branch at all, so there is
# nothing that could quarantine the document.
assert backend._quarantine is None
assert backend._abandoned_calls == []
async def test_async_still_serialises_calls(backend):
@@ -202,6 +225,9 @@ async def test_a_call_raising_TimeoutError_is_not_reported_as_the_deadline(backe
await backend._async(_boom)
assert backend._lock is live, "a call that merely failed must not abandon its lock"
# R32: not our deadline, so no thread was abandoned and the document is fine.
assert backend._quarantine is None
assert backend._abandoned_calls == []
assert await asyncio.wait_for(backend._async(lambda: "next"), timeout=2) == "next"
+415
View File
@@ -0,0 +1,415 @@
"""R32 — a timed-out ezdxf call must quarantine the DOCUMENT, not just the lock.
``EzdxfBackend._async`` cannot cancel an ``asyncio.to_thread`` worker. When a
call overruns ``EZDXF_CALL_TIMEOUT`` the handler swaps in a fresh
``asyncio.Lock`` so later callers do not deadlock behind the orphaned one — and
that swap is precisely what removes the mutual exclusion the lock existed for.
The runaway thread still holds the one shared ``ezdxf.Drawing``, which is
explicitly not thread-safe.
Measured against the unfixed backend, with a runaway ``_sync`` that really
appends LINEs to ``backend._doc``:
* ``entity_create_circle`` after the abandonment returned handle ``A1`` —
full success, no warning.
* ``drawing_info`` reported ``entity_count=116`` for a modelspace observed at
113 -> 118 -> 302 under the same caller, finishing at 310.
* ``drawing_save`` returned ``{"ok": True}`` and wrote a valid DXF holding
15200 of the 60000 entities the abandoned call would go on to write. It reads
back fine. It is a considered document of a state that never existed.
* ``drawing_info`` iterating a container another thread appends to does not
tear loudly — it ran unbounded, blew its own 2.0s deadline at 2.03s and
abandoned a SECOND worker with a SECOND orphaned lock. That is why reads are
refused too.
Only the timed-out caller was ever warned. Every later caller was told nothing.
Every runaway helper here is bounded AND sets its ``finished`` Event in a
``finally``: asyncio joins the default ``to_thread`` executor at loop shutdown,
so a still-spinning worker hangs the pytest process rather than failing a test.
"""
from __future__ import annotations
import asyncio
import logging
import threading
import time
import ezdxf
import pytest
import pytest_asyncio
from fastmcp import Client
import config
import server
from backends.ezdxf_backend import EzdxfBackend
from backends.quarantine import DocumentQuarantineError, QuarantineRecord, real_call_name
pytestmark = pytest.mark.asyncio
# ── runaway helpers ─────────────────────────────────────────────────────────
class _Runaway:
"""A ``_sync`` that outlives its deadline, with a guaranteed way to stop it."""
def __init__(self):
self.started = threading.Event()
self.finished = threading.Event()
self.stop = threading.Event()
self.written = 0
def writer(self, backend, *, delay: float = 0.0005, limit: int = 20_000):
"""Really appends LINEs to the shared Drawing, like a real ``_sync`` would.
The existing honesty test used ``time.sleep(2.0)`` as the stand-in,
which touches no document — which is exactly why the race it was
written to cover was never caught.
"""
def _sync():
try:
msp = backend._doc.modelspace()
self.started.set()
while self.written < limit and not self.stop.is_set():
msp.add_line((0, 0), (1, 1))
self.written += 1
time.sleep(delay) # releases the GIL so the event loop runs
finally:
self.finished.set()
return {"ok": True, "written": self.written}
return _sync
def slow_but_complete(self, extra: float = 0.5, result: str = "value nobody received"):
"""A legitimately slow call that finishes cleanly just past the deadline."""
def _sync():
try:
self.started.set()
self.stop.wait(extra)
finally:
self.finished.set()
return result
return _sync
def slow_then_raises(self, extra: float = 0.5):
def _sync():
try:
self.started.set()
self.stop.wait(extra)
raise ValueError("died mid-write")
finally:
self.finished.set()
return _sync
@pytest.fixture
def runaways():
"""Factory for runaway calls; every one is stopped and joined on teardown."""
made: list[_Runaway] = []
def make() -> _Runaway:
r = _Runaway()
made.append(r)
return r
yield make
for r in made:
r.stop.set()
for r in made:
if r.started.is_set():
assert r.finished.wait(15), "a runaway worker outlived its test"
async def _abandon(backend, monkeypatch, func, *, timeout: float = 0.3) -> RuntimeError:
"""Run ``func`` past a short deadline and return the deadline error."""
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", timeout)
with pytest.raises(RuntimeError) as excinfo:
await backend._async(func)
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 30)
return excinfo.value
# ── the quarantine ──────────────────────────────────────────────────────────
async def test_a_timed_out_write_quarantines_the_document(backend, monkeypatch, runaways):
"""RED: this returned handle 'A1' off a modelspace another thread was growing."""
r = runaways()
await _abandon(backend, monkeypatch, r.writer(backend))
assert r.started.is_set()
with pytest.raises(DocumentQuarantineError) as refusal:
await backend.entity_create_circle(0, 0, 5)
payload = refusal.value.to_dict()
assert payload["ok"] is False
assert payload["kind"] == "quarantined"
assert payload["quarantine"]["call"] == "writer"
assert payload["quarantine"]["thread_alive"] is True
assert "drawing_open" in payload["error"], "a refusal that names no way out is not help"
assert r.written > 0, "the abandoned thread really was mutating the document"
async def test_reads_are_refused_too(backend, monkeypatch, runaways):
"""RED: drawing_info reported entity_count=116 for a space at 302 and climbing."""
r = runaways()
await _abandon(backend, monkeypatch, r.writer(backend))
with pytest.raises(DocumentQuarantineError):
await backend.drawing_info()
with pytest.raises(DocumentQuarantineError):
await backend.entity_list()
with pytest.raises(DocumentQuarantineError):
await backend.analysis_stats()
async def test_a_read_does_not_cascade_a_second_abandonment(backend, monkeypatch, runaways, caplog):
"""RED: a read on the growing document blew its own deadline and abandoned again.
Measured at 2.03s against a 2.0s deadline, with a second lock swap. Reads on
a container another thread appends to do not tear loudly — they never finish.
"""
r = runaways()
await _abandon(backend, monkeypatch, r.writer(backend))
quarantined_lock = backend._lock
caplog.clear() # the first abandonment is expected; a second one is the defect
with caplog.at_level(logging.ERROR, logger="backends.ezdxf_backend"):
with pytest.raises(DocumentQuarantineError):
await backend.drawing_info()
assert backend._lock is quarantined_lock, "a refusal must not swap the lock again"
assert not [rec for rec in caplog.records if "abandoning the worker" in rec.getMessage()]
assert len(backend._abandoned_calls) == 1
async def test_save_is_refused_while_quarantined(backend, monkeypatch, runaways, tmp_path):
"""RED: this wrote a valid DXF of 15200 of 60000 entities and said {'ok': True}."""
r = runaways()
await _abandon(backend, monkeypatch, r.writer(backend))
target = tmp_path / "torn.dxf"
with pytest.raises(DocumentQuarantineError):
await backend.drawing_save(str(target))
assert not target.exists(), "a refusal must not leave a torn document on disk"
# ── the ways back ───────────────────────────────────────────────────────────
async def test_drawing_open_is_the_way_back(backend, monkeypatch, runaways, tmp_path):
good = tmp_path / "known_good.dxf"
doc = ezdxf.new("R2010")
for i in range(7):
doc.modelspace().add_line((0, i), (1, i))
doc.saveas(str(good))
r = runaways()
await _abandon(backend, monkeypatch, r.writer(backend))
# Reopen WHILE the runaway is still writing: that is the whole point.
assert not r.finished.is_set()
result = await backend.drawing_open(str(good))
assert result["ok"] is True
assert result["quarantine_cleared"]["call"] == "writer"
assert backend._quarantine is None
written_before = r.written
info = await backend.drawing_info()
assert info.entity_count == 7
# Let the runaway write more, then prove none of it landed here.
while r.written < written_before + 20 and not r.finished.is_set():
await asyncio.sleep(0.01)
assert (await backend.drawing_info()).entity_count == 7
assert (await backend.entity_create_circle(0, 0, 5)).handle
async def test_drawing_new_and_drawing_close_also_clear_it(backend, monkeypatch, runaways):
first = runaways()
await _abandon(backend, monkeypatch, first.writer(backend))
assert (await backend.drawing_new())["quarantine_cleared"]["call"] == "writer"
assert backend._quarantine is None
assert (await backend.entity_create_line(0, 0, 1, 1)).handle
second = runaways()
await _abandon(backend, monkeypatch, second.writer(backend))
closed = await backend.drawing_close(save=True)
assert closed["ok"] is True
assert closed["saved"] is False, "a quarantined document must not be saved on the way out"
assert backend._quarantine is None
await backend.drawing_new()
assert (await backend.entity_create_line(0, 0, 1, 1)).handle
async def test_undo_and_rollback_are_not_a_way_back(backend, monkeypatch, runaways):
"""They rebind ``self._doc`` too, but from a snapshot that may itself be torn.
``_snapshot_doc`` does ``self._doc.saveas(tmp)`` on the LIVE document, so any
snapshot pushed during the runaway window was serialised mid-mutation.
"""
monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 5)
r = runaways()
await _abandon(backend, monkeypatch, r.writer(backend))
with pytest.raises(DocumentQuarantineError):
await backend.drawing_undo()
with pytest.raises(DocumentQuarantineError):
await backend.drawing_redo()
with pytest.raises(DocumentQuarantineError):
await backend.transaction_rollback()
assert backend._quarantine is not None
# ── the free exit ───────────────────────────────────────────────────────────
async def test_a_slow_but_complete_call_lifts_the_quarantine(backend, monkeypatch, runaways):
"""The common real trigger is a slow drawing_audit, not a hang.
If the ``_sync`` body ran to completion the document holds exactly the state
that call intended — and everybody else was refused in the meantime, which
is the only reason that inference is sound.
"""
r = runaways()
await _abandon(backend, monkeypatch, r.slow_but_complete(0.5))
assert backend._quarantine is not None
assert r.finished.wait(5)
circle = await backend.entity_create_circle(0, 0, 5)
assert circle.handle, "no reopen should be needed for a call that simply ran long"
status = await backend.system_status()
assert status["quarantine"] is None
(record,) = status["abandoned_calls"]
assert record["outcome"] == "completed_after_deadline"
assert record["result_lost"] is True, "the caller never received the return value"
assert "value nobody received" in record["lost_return_value"]
async def test_a_call_that_raises_after_the_deadline_stays_quarantined(
backend, monkeypatch, runaways
):
r = runaways()
await _abandon(backend, monkeypatch, r.slow_then_raises(0.5))
assert r.finished.wait(5)
with pytest.raises(DocumentQuarantineError):
await backend.entity_create_circle(0, 0, 5)
status = await backend.system_status()
assert status["quarantine"]["outcome"] == "failed_after_deadline"
assert "died mid-write" in status["quarantine"]["detail"]
# ── the diagnostic channel ──────────────────────────────────────────────────
async def test_system_status_reports_the_quarantine_and_is_never_itself_refused(
backend, monkeypatch, runaways
):
"""Otherwise the quarantine's first act is to close the only way to ask why."""
r = runaways()
await _abandon(backend, monkeypatch, r.writer(backend))
status = await backend.system_status()
record = status["quarantine"]
assert record["call"] == "writer"
assert record["timeout"] == pytest.approx(0.3)
assert record["age_seconds"] >= 0
assert record["thread_alive"] is True
assert record["outcome"] == "running"
# ── the message ─────────────────────────────────────────────────────────────
async def test_the_timeout_message_names_the_real_call(backend, monkeypatch, runaways, tmp_path):
"""RED: every real backend method passes a closure literally named ``_sync``.
Measured: ``__name__='_sync'``, ``__qualname__='EzdxfBackend.drawing_save.
<locals>._sync'`` — so in production the error always read
``ezdxf call '_sync' timed out``. The qualname carries the name the user
typed and it was being discarded.
"""
r = runaways()
def _slow_saveas(path, *args, **kwargs):
r.started.set()
try:
r.stop.wait(5)
finally:
r.finished.set()
monkeypatch.setattr(backend._doc, "saveas", _slow_saveas)
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 0.3)
with pytest.raises(RuntimeError) as excinfo:
await backend.drawing_save(str(tmp_path / "part.dxf"))
message = str(excinfo.value)
assert "drawing_save" in message
assert "_sync" not in message
assert "drawing_open" in message, "the message must name the way back"
assert backend._quarantine.call == "drawing_save"
async def test_real_call_name_falls_back_when_there_is_no_enclosing_method():
assert real_call_name(len) == "len"
assert real_call_name(object()) == "call"
# ── it is not a capability refusal ──────────────────────────────────────────
async def test_the_refusal_is_not_reported_as_a_capability():
"""``_batch_capability_of`` classifies structurally, so a borrowed
``.capability`` here would tell the client the ezdxf engine cannot draw a
circle. The engine is fine; the document is not."""
record = QuarantineRecord("drawing_save", 120.0, _FinishedWatcher())
exc = DocumentQuarantineError(record, "quarantined")
assert not hasattr(exc, "capability")
assert server._batch_capability_of(exc) is None
assert server._classify_batch_error(exc)["kind"] == "quarantined"
class _FinishedWatcher:
finished = True
result = None
error = None
@pytest_asyncio.fixture
async def client(monkeypatch):
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 test_the_wire_payload_carries_kind_quarantined(client, monkeypatch):
record = QuarantineRecord("drawing_audit", 120.0, _FinishedWatcher())
async def _refuse(self, *args, **kwargs):
raise DocumentQuarantineError(record, "document quarantined; use drawing_open()")
monkeypatch.setattr(EzdxfBackend, "entity_create_circle", _refuse)
result = await client.call_tool(
"entity_create_circle", {"cx": 0, "cy": 0, "radius": 5}, raise_on_error=False
)
assert result.is_error is True
payload = result.structured_content
assert payload["kind"] == "quarantined"
assert "capability" not in payload
assert payload["quarantine"]["call"] == "drawing_audit"
assert payload["tool"] == "entity_create_circle"
assert payload["backend"] == "ezdxf"
+120
View File
@@ -0,0 +1,120 @@
"""A quarantined document must not be able to pass the quality gate.
`backends/quarantine.py` refuses every call routed through `_async` once a
timed-out write has been abandoned on the document. But `engineering/critique.py`
wraps each check in a bare ``except Exception`` and degrades to ``[]`` — so
`DocumentQuarantineError` was swallowed and `drawing_critique` returned *zero
issues* on a document the backend had declared untrusted.
CLAUDE.md premium rule 7 makes "drawing_critique must return zero issues before
drawing_finalize" the gate an agent is told to trust. A gate that reports clean
because it could not look is worse than no gate: it is the same silent-success
failure the whole quarantine exists to stop, one layer up.
The same swallow sat in `construction_clear` (step 9 of the standard workflow),
which answered ``{"ok": true, "deleted": 0}`` — "there was nothing to clear" —
on a document it could not read.
"""
from __future__ import annotations
import threading
import pytest
import config
from backends.quarantine import DocumentQuarantineError
pytestmark = pytest.mark.asyncio
async def _quarantine(backend, monkeypatch):
"""Abandon a real write, the way a real timeout does.
Hand-building a QuarantineRecord would test the gate against a fixture
rather than against the state the backend actually reaches.
"""
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 0.2)
entered = threading.Event()
release = threading.Event()
def _writer():
entered.set()
release.wait(5.0)
return "too late"
with pytest.raises(RuntimeError):
await backend._async(_writer)
assert entered.is_set()
assert backend._quarantine is not None
monkeypatch.setattr(config.settings, "ezdxf_call_timeout", 10)
return release
async def test_a_geometric_focus_refuses_rather_than_reporting_clean(backend, monkeypatch):
from engineering.critique import run_critique
await backend.entity_create_line(0, 0, 10, 0, layer="GEOMETRY")
release = await _quarantine(backend, monkeypatch)
try:
with pytest.raises(DocumentQuarantineError):
await run_critique(backend, focus=["duplicate_entities"])
finally:
release.set()
async def test_the_full_critique_refuses_too(backend, monkeypatch):
from engineering.critique import run_critique
release = await _quarantine(backend, monkeypatch)
try:
with pytest.raises(DocumentQuarantineError):
await run_critique(backend, focus=None)
finally:
release.set()
async def test_the_legibility_check_does_not_read_the_document_under_quarantine(
backend, monkeypatch
):
"""It reads `backend._doc` directly, off the event loop thread, with no
lock — the exact unsynchronised access the quarantine exists to prevent."""
from engineering.critique import _check_dimension_legibility
release = await _quarantine(backend, monkeypatch)
try:
with pytest.raises(DocumentQuarantineError):
_check_dimension_legibility(backend)
finally:
release.set()
async def test_construction_clear_does_not_report_nothing_to_clear(backend, monkeypatch):
await backend.entity_create_line(0, 0, 10, 0, layer="CONSTRUCTION")
release = await _quarantine(backend, monkeypatch)
try:
with pytest.raises(DocumentQuarantineError):
await backend.construction_clear()
finally:
release.set()
async def test_an_ordinary_failure_is_still_degraded_not_raised(backend):
"""The bare excepts exist so one broken check cannot fail the whole pass.
Only the quarantine escapes them; everything else keeps degrading.
"""
from engineering.critique import run_critique
async def _boom(*_a, **_k):
raise RuntimeError("layer_list is having a bad day")
backend.layer_list = _boom
issues = await run_critique(backend, focus=["layer_color"])
assert issues == []
+65
View File
@@ -29,6 +29,7 @@ from __future__ import annotations
import ast
import json
import re
import sys
import tomllib
from pathlib import Path
@@ -268,3 +269,67 @@ def test_windows_only_dependencies_carry_a_platform_marker() -> None:
'Windows-only distributions need `; sys_platform == "win32"` or the '
f"extra cannot be installed off Windows: {unmarked}"
)
def test_document_numbers_match_the_tree() -> None:
"""Every figure the docs quote, recomputed from its source of truth.
The A/B suite grew 24 -> 26 and three files kept saying 24: `README.md`
(fixed by hand), `benchmarks/README.md` and `CLAUDE.md` (not). Each was
edited on its own and nobody re-read the other two, so the repository
shipped three different answers to one question. That is a mechanical
failure and it deserves a mechanical gate rather than another careful
reader.
`scripts/check_doc_numbers.py` also fails when a pattern stops matching, so
rewording the sentence around a number cannot silently disarm the check.
"""
import subprocess
completed = subprocess.run(
[sys.executable, str(ROOT / "scripts" / "check_doc_numbers.py")],
capture_output=True,
text=True,
cwd=ROOT,
timeout=120,
)
assert completed.returncode == 0, completed.stdout + completed.stderr
def test_pre_commit_ruff_matches_the_project_pin() -> None:
"""A pre-commit hook that formats differently from CI is a trap, armed.
`.pre-commit-config.yaml` sat at ruff v0.8.6 while `[dependency-groups] dev`
pinned 0.16.1, and 0.16 formats Python inside Markdown where 0.8 does not.
Anyone who ran `pre-commit install` therefore produced commits that failed
CI's own `ruff format --check .` -- the same class of failure the ruff pin
was introduced to end.
"""
config = (ROOT / ".pre-commit-config.yaml").read_text(encoding="utf-8")
hook = re.search(r"ruff-pre-commit\s*\n\s*rev:\s*v?([\d.]+)", config)
assert hook, ".pre-commit-config.yaml no longer pins ruff-pre-commit"
dev_group = _pyproject()["dependency-groups"]["dev"]
pinned = next((d for d in dev_group if d.startswith("ruff==")), None)
assert pinned, "pyproject no longer pins ruff in [dependency-groups] dev"
assert hook.group(1) == pinned.split("==", 1)[1], (
f"pre-commit runs ruff {hook.group(1)} but the project pins {pinned} — "
"a contributor's hook would format differently from CI"
)
def test_the_release_gate_covers_the_platforms_ci_covers() -> None:
"""`release.yml` publishes to PyPI; a lane CI has and it lacks is a hole.
Its test job was one ubuntu/3.11 run, so a Windows-only defect could reach
PyPI with no Windows job having executed. v1.5.0 shipped exactly such a
defect (`except pywintypes.com_error` -> NameError without pywin32).
"""
release = (ROOT / ".github" / "workflows" / "release.yml").read_text(encoding="utf-8")
gate = release.split("build:", 1)[0]
assert "windows-latest" in gate, "the release test gate never runs on Windows"
assert "3.12" in gate, "the release test gate never runs on Python 3.12"
assert "uv sync --locked" in gate, "the release gate must install from the lockfile"
+44
View File
@@ -544,3 +544,47 @@ async def test_the_filter_count_matches_the_handles(backend):
result = await backend.selection_filter(layer="GEOMETRY")
assert result["count"] == len(result["handles"]) == 2
# ── min_area must agree with the measurement tool ───────────────────────────
async def test_min_area_sees_a_hatch_at_the_area_entity_measure_reports(backend):
"""`_area` read `entity.dxf.get("area", 0.0)` for SOLID/TRACE/HATCH.
Those types carry no `area` DXF attribute, so the lookup always fell to the
default and every hatch measured 0.0 — excluded by any positive threshold,
while `filtered_by` asserted the filter had run. Its own docstring says
None means "this shape has no area", not "its area is zero", and the live
COM path reads ActiveX `.Area` correctly, so the two engines returned
different selections for the same call.
"""
hatch = await backend.entity_create_hatch(
"SOLID", [[0, 0], [40, 0], [40, 40], [0, 40]], layer="HATCH"
)
measured = await backend.entity_measure(hatch.handle)
assert measured["area"] == pytest.approx(1600.0)
kept = await backend.selection_filter(entity_type="HATCH", min_area=1.0)
assert kept["handles"] == [hatch.handle], "a 1600 mm^2 hatch must survive a 1 mm^2 threshold"
async def test_min_area_excludes_a_hatch_that_is_genuinely_too_small(backend):
"""The fix must not simply admit everything."""
await backend.entity_create_hatch("SOLID", [[0, 0], [2, 0], [2, 2], [0, 2]], layer="HATCH")
kept = await backend.selection_filter(entity_type="HATCH", min_area=100.0)
assert kept["handles"] == []
async def test_min_area_and_entity_measure_cannot_disagree_on_a_solid(backend):
solid = backend._doc.modelspace().add_solid([(0, 0), (10, 0), (0, 10), (10, 10)])
measured = await backend.entity_measure(solid.dxf.handle)
just_under = await backend.selection_filter(min_area=measured["area"] - 0.5)
just_over = await backend.selection_filter(min_area=measured["area"] + 0.5)
assert solid.dxf.handle in just_under["handles"]
assert solid.dxf.handle not in just_over["handles"]
+71
View File
@@ -0,0 +1,71 @@
"""A setting the renderer cannot use must be refused, not reported applied.
Folding the DIM* header variables into the per-dimension override closed a
silent no-op and opened a louder failure: values that used to die harmlessly in
the header now reach ezdxf's formatter. Measured before this guard existed:
* ``dim_decimals=-1`` returned ``ok: True`` and the *next* dimension of four of
the five kinds died with ``ValueError: Format specifier missing precision``,
from inside the renderer, nowhere near the call that caused it.
* ``dim_decimals=40`` returned ``ok: True`` and printed
``33.3329999999999984083842718973755836486816`` onto the drawing.
* ``dim_text_height=-3.0`` returned ``ok: True`` and changed nothing.
AutoCAD's own ranges are the reference: DIMDEC 0-8, DIMZIN a 0-15 bitmask, and
the two lengths strictly positive. `applied` has to mean applied.
"""
from __future__ import annotations
import pytest
pytestmark = pytest.mark.asyncio
@pytest.mark.parametrize("value", [-1, 9, 40])
async def test_out_of_range_dim_decimals_is_refused(backend, value):
result = await backend.drawing_settings({"dim_decimals": value})
assert result["ok"] is False
assert "dim_decimals" in result["errors"]
assert "dim_decimals" not in result.get("applied", {})
async def test_a_refused_dim_decimals_cannot_break_the_next_dimension(backend):
"""The crash was the point: the bad value reached the formatter."""
await backend.drawing_settings({"dim_decimals": -1})
dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30)
assert dim.handle
@pytest.mark.parametrize("value", [0, 2, 8])
async def test_dim_decimals_in_range_is_still_accepted(backend, value):
result = await backend.drawing_settings({"dim_decimals": value})
assert result["ok"] is True, result.get("errors")
@pytest.mark.parametrize("key", ["dim_text_height", "dim_arrow_size"])
@pytest.mark.parametrize("value", [0.0, -3.0])
async def test_a_non_positive_length_is_refused(backend, key, value):
result = await backend.drawing_settings({key: value})
assert result["ok"] is False
assert key in result["errors"]
@pytest.mark.parametrize("value", [-1, 16, 999])
async def test_zero_suppression_outside_the_bitmask_is_refused(backend, value):
result = await backend.drawing_settings({"zero_suppression": value})
assert result["ok"] is False
assert "zero_suppression" in result["errors"]
async def test_one_bad_key_does_not_discard_the_good_ones(backend):
result = await backend.drawing_settings({"dim_text_height": 5.0, "dim_decimals": -1})
assert result["applied"]["dim_text_height"] == 5.0
assert "dim_decimals" in result["errors"]
+87
View File
@@ -0,0 +1,87 @@
"""SendCommand takes a whole macro, so a name interpolated into one is code.
`security.py` recorded the policy as "`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." That premise was false.
`_ensure_linetype_loaded` builds ``_-LINETYPE _LOAD {name} {lin_file}`` and
hands it to `doc.SendCommand`, and it is reached from the ``linetype=``
argument of every COM entity-creation and layer tool — plus
`ComBackend.linetype_load`, which interpolates a caller-supplied *file path*
as well. SendCommand treats a newline and ``;`` as segment separators, so a
linetype name carrying either injects further AutoCAD commands, and the 36-verb
denylist and `DANGEROUS_COMMANDS_ENABLED` never see that channel at all.
The guard here is the DXF symbol-name rule rather than a bespoke blocklist:
AutoCAD already forbids these characters in a table name, so anything this
rejects was never a loadable linetype, and the injection characters fall out of
the rule rather than having to be enumerated.
"""
from __future__ import annotations
import pytest
from fastmcp.exceptions import ToolError
from security import sanitize_symbol_name
@pytest.mark.parametrize(
"name",
[
"CENTER",
"HIDDEN2",
"DASHDOT",
"ACAD_ISO02W100",
"My-Linetype_01",
],
)
def test_ordinary_linetype_names_pass(name):
assert sanitize_symbol_name(name, kind="linetype") == name
@pytest.mark.parametrize(
"payload",
[
"CENTER\n_ERASE ALL\n", # newline ends the macro segment
"CENTER;_ERASE ALL", # so does a semicolon
"CENTER\r_QUIT",
'CENTER"',
"CENTER|X",
"CENTER<X",
"CENTER>X",
"CENTER?X",
"CENTER*X",
"CENTER,X",
"CENTER=X",
"CENTER:X",
"CENTER/X",
"CENTER" + chr(92) + "X",
],
)
def test_a_name_that_could_end_the_macro_segment_is_refused(payload):
with pytest.raises(ToolError) as excinfo:
sanitize_symbol_name(payload, kind="linetype")
assert "linetype" in str(excinfo.value).lower()
def test_an_empty_name_is_refused():
with pytest.raises(ToolError):
sanitize_symbol_name(" ", kind="linetype")
def test_the_refusal_does_not_echo_the_payload_back_verbatim():
"""An error message is a channel too."""
with pytest.raises(ToolError) as excinfo:
sanitize_symbol_name("CENTER\n_ERASE ALL\n", kind="linetype")
assert "\n_ERASE" not in str(excinfo.value)
def test_the_com_linetype_loader_refuses_before_it_reaches_sendcommand():
"""The guard has to sit in front of the macro, not inside AutoCAD."""
import backends.com_backend as cb
with pytest.raises(ToolError):
cb._ensure_linetype_loaded("CENTER\n_ERASE ALL\n")