Files
autocad-mcp-pro/tests/test_refs_contract.py
T
Umut CANandClaude Opus 5.5 cdb1063896
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
fix(server): stop stacking a tool profile's visibility on every session
What the first Linux CI run of the 1.6 tree found.

- fastmcp 3's enable()/disable() append a Visibility transform per call,
  and a tool lookup walks the transform stack as nested calls. The
  lifespan re-applies the tool profile on every client connection, so an
  in-process server grew two layers per session until every tool call,
  system_status included, raised RecursionError (CPython 3.11 counts C
  frames against the limit). The profile's transforms now replace the
  previous ones, as _apply_discovery_mode already did; a test switches
  the profile 600 times and then calls a tool. The local suite runs in
  half the time it took with the stack growing.
- 21 fake-ActiveX tests build a real pywin32 VARIANT; they now skip
  without pywin32, like the suite's other VARIANT-dependent COM tests,
  and the Windows lane keeps running them.
- A test spelled a path through a directory it never created, which only
  Windows normalises away; it creates the directory.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-25 00:49:16 +03:00

530 lines
19 KiB
Python

"""External references, raster images and DWG output on both engines.
The three capability keys are the point of this file: a refusal the feature map
never mentions is a lie by omission, so every key raised here is asserted to be
declared on both backends.
"""
from __future__ import annotations
from importlib.util import find_spec
from pathlib import Path
import pytest
from backends.base import AutoCADBackend, UnsupportedCapabilityError
from backends.com_backend import ComBackend
from backends.contracts.refs import (
DWG_VERSIONS,
XREF_ACTIONS,
XREF_KINDS,
RefsContract,
)
from backends.ezdxf_backend import EzdxfBackend
#: A path that marshals a point or an object array builds a real pywin32 VARIANT,
#: and pywin32 exists only on Windows. The Windows lane runs these; elsewhere
#: they skip, like the suite's other VARIANT-dependent COM tests.
needs_pywin32 = pytest.mark.skipif(find_spec("win32com") is None, reason="pywin32 not installed")
TRACK_G_KEYS = ("dwg_write", "xref_live", "xlsx_write")
def _features(backend) -> dict:
return backend.capabilities().to_dict()["features"]
def test_the_contract_is_composed_into_both_backends():
assert issubclass(AutoCADBackend, RefsContract)
assert issubclass(EzdxfBackend, RefsContract)
assert issubclass(ComBackend, RefsContract)
for name in ("xref_attach", "xref_manage", "image_attach", "drawing_export_dwg"):
assert callable(getattr(AutoCADBackend, name, None)), name
def test_the_three_keys_are_declared_in_both_capability_maps():
ezdxf_features = _features(EzdxfBackend())
com_features = _features(ComBackend())
for key in TRACK_G_KEYS:
assert key in ezdxf_features, f"{key} undeclared on ezdxf"
assert key in com_features, f"{key} undeclared on com"
def test_com_declares_dwg_and_live_xrefs_and_ezdxf_does_not():
com_features = _features(ComBackend())
ezdxf_features = _features(EzdxfBackend())
assert com_features["dwg_write"]["supported"] is True
assert com_features["xref_live"]["supported"] is True
assert ezdxf_features["xref_live"]["supported"] is False
assert "live" in (ezdxf_features["xref_live"]["reason"] or "")
def test_ezdxf_dwg_write_is_re_evaluated_from_the_converter_not_hardcoded():
"""The same shape as viewport_render's matplotlib check: the flag follows
the machine, and the reason names the way to change it."""
from ezdxf.addons import odafc
feature = _features(EzdxfBackend())["dwg_write"]
assert feature["supported"] is bool(odafc.is_installed())
if not feature["supported"]:
assert "oda" in (feature["reason"] or "").lower()
def test_xlsx_write_follows_openpyxl_on_both_engines():
present = find_spec("openpyxl") is not None
for backend in (EzdxfBackend(), ComBackend()):
feature = _features(backend)["xlsx_write"]
assert feature["supported"] is present, backend.name
if not present:
assert "openpyxl" in (feature["reason"] or "")
def test_the_vocabularies_are_closed():
assert XREF_KINDS == ("attach", "overlay")
assert XREF_ACTIONS == ("list", "reload", "bind", "detach", "path")
assert DWG_VERSIONS == ("R2000", "R2004", "R2007", "R2010", "R2013", "R2018")
# -- ezdxf -------------------------------------------------------------------
@pytest.fixture
def external(tmp_path: Path) -> str:
"""A real DXF on disk for the xref to point at."""
import ezdxf
doc = ezdxf.new("R2018")
doc.modelspace().add_circle((0, 0), 5)
target = tmp_path / "BASE.dxf"
doc.saveas(target)
return str(target)
@pytest.mark.asyncio
async def test_attach_creates_an_xref_block_and_an_insert(backend, external):
result = await backend.xref_attach(external, (10.0, 20.0), 2.0, 30.0, "attach")
assert result["ok"] is True
assert result["name"] == "BASE"
assert result["kind"] == "attach"
insert = await backend.entity_get(result["handle"])
assert insert.type == "INSERT"
assert insert.properties["block_name"] == "BASE"
listing = await backend.xref_manage("", "list", None)
assert [row["name"] for row in listing["xrefs"]] == ["BASE"]
assert listing["xrefs"][0]["kind"] == "attach"
assert Path(listing["xrefs"][0]["path"]).name == "BASE.dxf"
assert listing["xrefs"][0]["inserts"] == 1
@pytest.mark.asyncio
async def test_overlay_is_flagged_as_an_overlay(backend, external):
await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "overlay")
listing = await backend.xref_manage("", "list", None)
assert listing["xrefs"][0]["kind"] == "overlay"
@pytest.mark.asyncio
async def test_attaching_the_same_name_twice_is_refused_before_writing(backend, external):
await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach")
before = len(await backend.entity_list())
with pytest.raises(ValueError) as excinfo:
await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach")
assert "BASE" in str(excinfo.value)
assert len(await backend.entity_list()) == before
@pytest.mark.asyncio
async def test_a_missing_file_and_a_bad_kind_are_refused(backend, tmp_path):
with pytest.raises(ValueError) as missing:
await backend.xref_attach(str(tmp_path / "nope.dxf"), (0.0, 0.0), 1.0, 0.0, "attach")
assert "nope.dxf" in str(missing.value)
with pytest.raises(ValueError) as kind:
await backend.xref_attach(str(tmp_path), (0.0, 0.0), 1.0, 0.0, "underlay")
assert "underlay" in str(kind.value)
@pytest.mark.asyncio
async def test_path_rewrites_the_saved_path_and_detach_removes_the_block(
backend, external, tmp_path
):
await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach")
moved = str(tmp_path / "moved" / "BASE.dxf")
changed = await backend.xref_manage("BASE", "path", moved)
assert changed["ok"] is True
assert changed["path"] == moved
assert (await backend.xref_manage("", "list", None))["xrefs"][0]["path"] == moved
detached = await backend.xref_manage("BASE", "detach", None)
assert detached["ok"] is True and detached["inserts_removed"] == 1
assert (await backend.xref_manage("", "list", None))["xrefs"] == []
@pytest.mark.asyncio
async def test_an_xref_shown_on_a_sheet_is_counted_and_detached_with_it(backend, external):
"""Model space is not the drawing.
An xref is most often *shown* on a paper-space sheet. Counting or deleting
over `doc.modelspace()` alone reported 1 insert where the drawing held 2
and then removed the definition with `safe=False`, leaving the sheet's
INSERT with no BLOCK behind it -- which ezdxf's own recover reports as
`FIX 103 UNDEFINED_BLOCK` and throws the geometry away.
"""
await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach")
doc = backend._doc
doc.layout("Layout1").add_blockref("BASE", (10, 10))
listing = await backend.xref_manage("", "list", None)
assert listing["xrefs"][0]["inserts"] == 2
detached = await backend.xref_manage("BASE", "detach", None)
assert detached["inserts_removed"] == 2
assert "BASE" not in doc.blocks
assert [e for e in doc.layout("Layout1") if e.dxftype() == "INSERT"] == []
@pytest.mark.asyncio
async def test_detach_leaves_a_drawing_that_survives_a_round_trip(backend, external, tmp_path):
"""The evidence a caller actually cares about: the saved file audits clean."""
import ezdxf.recover
await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach")
backend._doc.layout("Layout1").add_blockref("BASE", (10, 10))
await backend.xref_manage("BASE", "detach", None)
out = tmp_path / "detached.dxf"
backend._doc.saveas(out)
_, auditor = ezdxf.recover.readfile(str(out))
assert [f.message for f in auditor.fixes] == []
assert [e.message for e in auditor.errors] == []
@pytest.mark.asyncio
async def test_the_definition_is_dropped_with_the_in_use_check_on(backend, external):
"""`safe=True` is the seatbelt, not decoration.
The broken version passed `safe=False`, the flag that *suppresses* the
in-use check, so a missed insert became a corrupt file instead of an
error. This pins the check: with the model-space insert gone but the
sheet's still there -- exactly the state the old victim scan produced --
`delete_block` refuses.
"""
from ezdxf.lldxf.const import DXFBlockInUseError
await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach")
doc = backend._doc
doc.layout("Layout1").add_blockref("BASE", (10, 10))
msp = doc.modelspace()
for entity in [e for e in msp if e.dxftype() == "INSERT"]:
msp.delete_entity(entity)
with pytest.raises(DXFBlockInUseError):
doc.blocks.delete_block("BASE", safe=True)
assert "BASE" in doc.blocks
@pytest.mark.asyncio
async def test_reload_and_bind_refuse_headlessly_with_the_xref_live_key(backend, external):
await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach")
for action in ("reload", "bind"):
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await backend.xref_manage("BASE", action, None)
assert excinfo.value.capability == "xref_live"
assert "com" in str(excinfo.value).lower()
@pytest.mark.asyncio
async def test_an_unknown_action_and_an_unknown_name_are_refused(backend, external):
await backend.xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach")
with pytest.raises(ValueError) as action:
await backend.xref_manage("BASE", "unload", None)
assert "unload" in str(action.value)
with pytest.raises(ValueError) as name:
await backend.xref_manage("NOPE", "detach", None)
assert "NOPE" in str(name.value)
@pytest.mark.asyncio
async def test_image_attach_places_the_raster_at_its_pixel_aspect(backend, tmp_path):
from PIL import Image
png = tmp_path / "logo.png"
Image.new("RGB", (800, 400), "white").save(png)
result = await backend.image_attach(str(png), (5.0, 5.0), 0.1, 0.0)
assert result["ok"] is True
assert result["pixels"] == [800, 400]
assert result["size_mm"] == [80.0, 40.0]
image = await backend.entity_get(result["handle"])
assert image.type == "IMAGE"
@pytest.mark.asyncio
async def test_a_non_image_file_is_refused_before_writing(backend, external):
before = len(await backend.entity_list())
with pytest.raises(ValueError) as excinfo:
await backend.image_attach(external, (0.0, 0.0), 1.0, 0.0)
assert "BASE.dxf" in str(excinfo.value)
assert len(await backend.entity_list()) == before
@pytest.mark.asyncio
async def test_headless_dwg_export_refuses_with_dwg_write_when_the_converter_is_absent(
backend, tmp_path
):
from ezdxf.addons import odafc
if odafc.is_installed():
pytest.skip("the ODA File Converter is installed on this machine")
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await backend.drawing_export_dwg(str(tmp_path / "out.dwg"), "R2018")
assert excinfo.value.capability == "dwg_write"
assert "oda" in str(excinfo.value).lower()
assert not (tmp_path / "out.dwg").exists()
@pytest.mark.asyncio
async def test_an_unknown_dwg_version_is_refused_on_both_engines(backend, tmp_path):
with pytest.raises(ValueError) as excinfo:
await backend.drawing_export_dwg(str(tmp_path / "out.dwg"), "R14")
message = str(excinfo.value)
assert "R14" in message
assert "R2018" in message
# -- COM, against fakes that model the measured ActiveX members --------------
def _com_backend():
b = ComBackend()
async def _run(func, *args, **kwargs): # bypass the single-thread executor
return func(*args, **kwargs)
b._run = _run
return b
def _point(variant):
"""The doubles a VARIANT point carries.
``_apoint`` hands ActiveX a ``win32com.client.VARIANT``, and that object is
*not* iterable -- measured here:
``tuple(VARIANT(VT_ARRAY | VT_R8, [1.0, 2.0, 0.0]))`` raises
``TypeError: 'VARIANT' object is not iterable``. The real ModelSpace reads
the array behind it, so the fakes read ``.value`` rather than pretending
the argument is a plain sequence.
"""
return tuple(getattr(variant, "value", variant))
class _Block:
"""Measured members of an AcadBlock that is an xref (AutoCAD 2026)."""
def __init__(self, name, path="", is_xref=True):
self.Name = name
self.Path = path
self.IsXRef = is_xref
self.calls = []
def Reload(self):
self.calls.append("Reload")
def Bind(self, bPrefixName):
self.calls.append(("Bind", bPrefixName))
def Detach(self):
self.calls.append("Detach")
class _Blocks:
def __init__(self, blocks):
self._blocks = list(blocks)
def Item(self, index):
if isinstance(index, int):
return self._blocks[index]
for block in self._blocks:
if block.Name == index:
return block
raise KeyError(index)
@property
def Count(self):
return len(self._blocks)
def __iter__(self):
return iter(self._blocks)
class _Ref:
def __init__(self, handle="7F"):
self.Handle = handle
class _ModelSpace:
def __init__(self):
self.attached = []
self.rasters = []
def AttachExternalReference(
self, PathName, Name, InsertionPoint, Xscale, Yscale, Zscale, Rotation, bOverlay
):
self.attached.append(
(PathName, Name, _point(InsertionPoint), Xscale, Yscale, Zscale, Rotation, bOverlay)
)
return _Ref("A1")
def AddRaster(self, imageFileName, InsertionPoint, ScaleFactor, RotationAngle):
self.rasters.append((imageFileName, _point(InsertionPoint), ScaleFactor, RotationAngle))
return _Ref("B2")
class _ComDoc:
def __init__(self, blocks=()):
self.ModelSpace = _ModelSpace()
self.Blocks = _Blocks(blocks)
self.saved = []
def SaveAs(self, FullFileName, SaveAsType):
self.saved.append((FullFileName, SaveAsType))
@pytest.mark.asyncio
@needs_pywin32
async def test_com_attach_calls_the_measured_member_with_radians(monkeypatch, external):
import math
import backends.com_backend as cb
doc = _ComDoc()
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
result = await _com_backend().xref_attach(external, (10.0, 20.0), 2.0, 90.0, "overlay")
assert result["handle"] == "A1"
path, name, insert, sx, sy, sz, rotation, overlay = doc.ModelSpace.attached[0]
assert name == "BASE" and overlay is True
assert (sx, sy, sz) == (2.0, 2.0, 2.0)
assert rotation == pytest.approx(math.pi / 2)
assert insert[:2] == (10.0, 20.0)
@pytest.mark.asyncio
async def test_com_manage_uses_reload_bind_detach_on_the_block(monkeypatch):
import backends.com_backend as cb
block = _Block("BASE", path="C:/refs/BASE.dwg")
doc = _ComDoc([block])
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
b = _com_backend()
assert (await b.xref_manage("BASE", "reload", None))["ok"] is True
assert (await b.xref_manage("BASE", "bind", None))["ok"] is True
assert (await b.xref_manage("BASE", "detach", None))["ok"] is True
assert block.calls == ["Reload", ("Bind", False), "Detach"]
listing = await b.xref_manage("", "list", None)
assert listing["xrefs"][0]["name"] == "BASE"
assert listing["xrefs"][0]["path"] == "C:/refs/BASE.dwg"
@pytest.mark.asyncio
async def test_com_list_reports_kind_as_unknown_rather_than_inventing_attach(monkeypatch):
"""IAcadBlock has no overlay indicator.
Measured from the seat's own registered type library (acax25*.tlb,
"AutoCAD 2025 Type Library", installed with AutoCAD 2026): the xref
members are IsXRef, Path, Name, Reload, Unload, Bind, Detach,
XRefDatabase -- nothing says attach-vs-overlay. The fake therefore has no
such member either (a fake that models one enshrines the bug), and the
honest row says `None`, the way `inserts` already does.
"""
import backends.com_backend as cb
doc = _ComDoc([_Block("BASE", path="C:/refs/BASE.dwg")])
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
row = (await _com_backend().xref_manage("", "list", None))["xrefs"][0]
assert row["kind"] is None
assert row["inserts"] is None
for invented in ("IsOverlay", "Overlay", "XRefType"):
assert not hasattr(doc.Blocks.Item("BASE"), invented)
@pytest.mark.asyncio
async def test_com_attach_refuses_a_name_the_drawing_already_holds(monkeypatch, external):
"""The refusal the contract and the tool docstring both promise.
It has to be on this engine too, and it has to fire *before*
AttachExternalReference is dispatched -- a second attach under a held name
re-points or half-writes the definition while the payload still claims the
new path was attached.
"""
import backends.com_backend as cb
doc = _ComDoc([_Block("BASE", path="C:/other/BASE.dwg")])
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
with pytest.raises(ValueError) as excinfo:
await _com_backend().xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach")
assert "BASE" in str(excinfo.value)
assert "detach" in str(excinfo.value)
assert doc.ModelSpace.attached == []
@pytest.mark.asyncio
async def test_com_attach_refuses_a_plain_block_of_the_same_name_case_insensitively(
monkeypatch, external
):
"""AutoCAD symbol-table names are case-insensitive and a collision is a
collision whether or not the block in the way is itself an xref."""
import backends.com_backend as cb
doc = _ComDoc([_Block("Base", is_xref=False)])
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
with pytest.raises(ValueError):
await _com_backend().xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach")
assert doc.ModelSpace.attached == []
@pytest.mark.asyncio
@needs_pywin32
async def test_com_attach_still_works_when_no_block_of_that_name_exists(monkeypatch, external):
import backends.com_backend as cb
doc = _ComDoc([_Block("OTHER", is_xref=True)])
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
result = await _com_backend().xref_attach(external, (0.0, 0.0), 1.0, 0.0, "attach")
assert result["ok"] is True and result["name"] == "BASE"
assert len(doc.ModelSpace.attached) == 1
@pytest.mark.asyncio
@needs_pywin32
async def test_com_raster_calls_addraster_with_radians(monkeypatch, tmp_path):
import math
from PIL import Image
import backends.com_backend as cb
png = tmp_path / "logo.png"
Image.new("RGB", (400, 200), "white").save(png)
doc = _ComDoc()
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
result = await _com_backend().image_attach(str(png), (1.0, 2.0), 0.5, 180.0)
assert result["handle"] == "B2"
name, insert, scale, rotation = doc.ModelSpace.rasters[0]
assert Path(name).name == "logo.png"
assert insert[:2] == (1.0, 2.0)
assert scale == 0.5
assert rotation == pytest.approx(math.pi)
@pytest.mark.asyncio
async def test_com_dwg_export_uses_the_measured_acsaveastype_values(monkeypatch, tmp_path):
import backends.com_backend as cb
doc = _ComDoc()
monkeypatch.setattr(cb, "_acad_doc", lambda: doc)
b = _com_backend()
await b.drawing_export_dwg(str(tmp_path / "a.dwg"), "R2018")
await b.drawing_export_dwg(str(tmp_path / "b.dwg"), "R2000")
assert [entry[1] for entry in doc.saved] == [64, 12]