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
+39 -1
View File
@@ -394,6 +394,24 @@ def _require_block_defined(doc, name):
return block
def _block_names(doc) -> set[str]:
"""Every block name the drawing holds, lower-cased.
``doc.Blocks.Count`` / ``doc.Blocks.Item(index)`` / ``block.Name`` are the
measured members ``xref_manage`` already walks. The set is lower-cased
because AutoCAD symbol-table names are case-insensitive, so ``BASE`` and
``Base`` are one block and re-attaching under either name hits the same
definition.
"""
names: set[str] = set()
for index in range(doc.Blocks.Count):
try:
names.add(str(doc.Blocks.Item(index).Name).lower())
except Exception: # noqa: BLE001 - a block that cannot name itself cannot collide
continue
return names
_BUILTIN_LINETYPES = {"continuous", "bylayer", "byblock"}
#: `drawing_properties_*` field → `IAcadSummaryInfo` property.
@@ -1882,6 +1900,17 @@ class ComBackend(AutoCADBackend):
def _sync():
doc = _acad_doc()
# The contract and the tool docstring both promise this refusal,
# and the ezdxf engine has always had it (`if name in doc.blocks`).
# Without it a second attach under a name the drawing already holds
# either re-points the existing definition or fails inside AutoCAD,
# while the payload still claims the new `path` was attached.
# Block names are case-insensitive in AutoCAD, so the comparison is.
if name.lower() in _block_names(doc):
raise ValueError(
f"xref_attach: the drawing already holds a block named {name!r}; "
"detach it first with xref_manage(action='detach')"
)
reference = doc.ModelSpace.AttachExternalReference(
str(source),
name,
@@ -1929,8 +1958,17 @@ class ComBackend(AutoCADBackend):
return found
if action == "list":
# `kind` is null, not "attach". Measured from the seat's own
# registered type library (acax25*.tlb, "AutoCAD 2025 Type
# Library", shipped with AutoCAD 2026): IAcadBlock's xref
# members are exactly IsXRef, Path, Name, Reload, Unload,
# Bind, Detach, XRefDatabase -- there is no overlay indicator
# anywhere on the interface. An overlay (which xref_attach
# itself creates, kind='overlay' -> bOverlay=True) is
# therefore not knowable here, and a constant "attach" would
# read to the caller as measured. Same honesty as `inserts`.
rows = [
{"name": b.Name, "path": str(b.Path or ""), "kind": "attach", "inserts": None}
{"name": b.Name, "path": str(b.Path or ""), "kind": None, "inserts": None}
for b in _xref_blocks()
]
rows.sort(key=lambda row: row["name"])
+10 -5
View File
@@ -54,11 +54,16 @@ class RefsContract(ABC):
"""One of :data:`XREF_ACTIONS` on one external reference.
``list`` ignores `name` and returns
``{ok, xrefs: [{name, path, kind, inserts}], backend}``. ``path``
rewrites the saved path to `new_path` and returns the new one.
``detach`` removes every insert of the xref and its definition, and
reports ``inserts_removed``. ``reload`` and ``bind`` need a live seat's
xref manager: the headless engine raises
``{ok, xrefs: [{name, path, kind, inserts}], backend}``. ``kind`` and
``inserts`` are ``None`` on a live seat -- ActiveX's ``IAcadBlock``
carries no overlay indicator and no per-xref insert count (measured on
the registered type library), and a constant would read as measured.
``path`` rewrites the saved path to `new_path` and returns the new one.
``detach`` removes every insert of the xref -- in *every* entity space,
model space and each paper-space layout, because an insert left behind
with no block definition is a corrupt drawing -- then the definition,
and reports ``inserts_removed``. ``inserts`` counts the same set.
``reload`` and ``bind`` need a live seat's xref manager: the headless engine raises
``UnsupportedCapabilityError("xref_live", ...)`` rather than pretending,
which is why the key exists; ``list``/``detach``/``path`` are *not*
behind it.
+34 -13
View File
@@ -2455,6 +2455,30 @@ class EzdxfBackend(AutoCADBackend):
# misses every overlay.
xref_mask = const.BLK_XREF | const.BLK_XREF_OVERLAY
def _inserts_of(key: str):
"""Every INSERT of `key`, paired with the layout that owns it.
Model space is *not* the drawing. `doc.blocks` is the one
iterable that reaches every entity space -- it yields the
block layouts behind model space and each paper-space tab as
well as the real block definitions -- and it is exactly what
ezdxf's own `delete_block(safe=True)` searches through
`doc.query("INSERT[name==...]i")`. Counting or deleting over
`doc.modelspace()` alone under-reports an xref shown on a
sheet and leaves that INSERT behind with no BLOCK definition.
The name comparison is case-insensitive for the same reason:
the `i` on that query string.
"""
wanted = key.lower()
found = []
for layout in doc.blocks:
for entity in layout:
if entity.dxftype() != "INSERT":
continue
if str(entity.dxf.name).lower() == wanted:
found.append((layout, entity))
return found
def _rows():
rows = []
for block in doc.blocks:
@@ -2466,11 +2490,7 @@ class EzdxfBackend(AutoCADBackend):
"name": block.name,
"path": str(block.block.dxf.xref_path or ""),
"kind": "overlay" if flags & const.BLK_XREF_OVERLAY else "attach",
"inserts": sum(
1
for entity in doc.modelspace()
if entity.dxftype() == "INSERT" and entity.dxf.name == block.name
),
"inserts": len(_inserts_of(block.name)),
}
)
rows.sort(key=lambda row: row["name"])
@@ -2491,14 +2511,15 @@ class EzdxfBackend(AutoCADBackend):
self._mark_dirty()
return {"ok": True, "name": key, "path": str(new_path), "backend": self.name}
victims = [
entity
for entity in doc.modelspace()
if entity.dxftype() == "INSERT" and entity.dxf.name == key
]
for entity in victims:
doc.modelspace().delete_entity(entity)
doc.blocks.delete_block(key, safe=False)
victims = _inserts_of(key)
for layout, entity in victims:
layout.delete_entity(entity)
# safe=True, as the plan pins it: the in-use check is the only
# thing standing between a missed insert and a saved drawing whose
# INSERT has no BLOCK behind it (ezdxf's recover reports that as
# `FIX 103 UNDEFINED_BLOCK` and throws the geometry away). If the
# scan above ever misses one, this raises instead of corrupting.
doc.blocks.delete_block(key, safe=True)
self._mark_dirty()
return {
"ok": True,