fix(task-14): detach an xref out of every space, and stop the live engine inventing facts

Three blocking findings on the refs group, all of them the same shape: a
number or a refusal the caller was entitled to read as measured.

1. ezdxf xref_manage: the victim scan and the `inserts` count both walked
   `doc.modelspace()` only, then dropped the definition with
   `delete_block(safe=False)` -- the flag that suppresses the in-use check.
   An xref shown on a paper-space sheet was reported as 1 insert where the
   drawing held 2, and detaching it left that sheet INSERT with no BLOCK
   behind it: `ezdxf.recover` reports `FIX 103 UNDEFINED_BLOCK: Deleted
   entity INSERT(#34) without required BLOCK definition` and the repo's own
   drawing_audit silently deletes the sheet geometry. The scan now walks
   `doc.blocks`, which is every entity space (model space, each paper-space
   tab, every block definition) and is exactly what ezdxf's own safe check
   searches with `doc.query('INSERT[name==...]i')` -- hence the
   case-insensitive name match. `delete_block` is now `safe=True`, as the
   plan's Interfaces block pins it, so a future miss raises instead of
   writing a corrupt file.

2. COM xref_manage(action='list') reported `kind: "attach"` for every row,
   a literal constant. Re-measured on this seat's registered type library
   (C:\Program Files\Common Files\Autodesk Shared\acax25*.tlb, "AutoCAD
   2025 Type Library", installed with AutoCAD 2026): IAcadBlock's xref
   members are exactly IsXRef, Path, Name, Reload, Unload, Bind, Detach,
   XRefDatabase -- no overlay indicator exists on the interface, so an
   overlay was reported to the client as an attachment. The row now says
   `kind: None`, the same honesty the adjacent `inserts` field already had,
   and the contract plus the tool docstring say so.

3. COM xref_attach had no duplicate-block-name guard, although
   contracts/refs.py and the tool docstring both promise the refusal and
   ezdxf implements it. Two different part.dwg files, or a re-attach after a
   repath, silently re-pointed or half-wrote the definition while the
   payload claimed the new path. Added `_block_names(doc)` (Blocks.Count /
   Blocks.Item(index) / block.Name -- the members xref_manage already walks)
   and the refusal fires before AttachExternalReference is dispatched, with
   the same message the headless engine gives. Lower-cased, because AutoCAD
   symbol-table names are case-insensitive.

Tests: five new ones, each failing against the unfixed backends -- an xref
shown on Layout1 is counted and detached with the drawing, the detached file
audits clean through ezdxf.recover, delete_block's in-use check is pinned,
the live listing reports kind None (against a fake that models only the
measured members, so it cannot enshrine an invented one), and the live attach
refuses a held name (xref and plain block, either case) without dispatching.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-22 23:35:44 +03:00
co-authored by Claude Opus 5
parent 3ef316d520
commit 301610620c
5 changed files with 219 additions and 21 deletions
+131
View File
@@ -155,6 +155,69 @@ async def test_path_rewrites_the_saved_path_and_detach_removes_the_block(
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")
@@ -357,6 +420,74 @@ async def test_com_manage_uses_reload_bind_detach_on_the_block(monkeypatch):
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
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
async def test_com_raster_calls_addraster_with_radians(monkeypatch, tmp_path):
import math