fix(task-11): dimensions_using_style is one filtered selection set, not a per-entity COM walk
_com_dimensions_using walked every entity of every layout and, since the un-narrowing fix, re-dispatched each one through a dynamic proxy before reading ObjectName (~8-10 ms per entity), which put dimstyle_modify past the 60 s COM_CALL_TIMEOUT on a ~6k-entity drawing after its SetVariable + CopyFrom had already landed. It is now a single SelectionSet.Select(acSelectionSetAll, (0 . "DIMENSION") (3 . <style>)), which answers from AutoCAD's own index whatever the drawing size. Measured live (AutoCAD 2026, scratch doc, 1000 LINEs + 14 dimensions across model and paper space): walk 12.7-15.8 s, selection 0.02-0.09 s, identical handle sets; the whole dimstyle_modify tool now returns in 0.07 s. The group-3 filter value is a wcmatch pattern, so the style name is backtick-escaped (_wcmatch_literal): unescaped, "A.B" also selected the dimensions of "A_B", "N#1" those of "N21", "P[1]" those of "P1", "S@X" those of "SAX" (measured). Handles are returned in ascending handle order, Select failures propagate, and the selection set is deleted either way. tests/test_styles.py: the fake document carries a SelectionSets that emulates ssget "X" over every layout with wcmatch-literal semantics and narrowed IAcadEntity items, refusing an unescaped wildcard; three tests cover no-per-entity-read, escaping, and cleanup-on-failure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
co-authored by
Claude Opus 5
parent
03a5067d83
commit
021a4eb43d
+58
-22
@@ -314,30 +314,66 @@ def _same_dimvar(old: Any, new: Any) -> bool:
|
||||
return str(old) == str(new)
|
||||
|
||||
|
||||
def _com_dimensions_using(doc, style_name: str) -> list[str]:
|
||||
"""Handles of every dimension, in every layout, whose StyleName is ``style_name``.
|
||||
#: ``SelectionSet.Select`` mode ``acSelectionSetAll`` (AcSelect enum): every
|
||||
#: entity in the database, in every layout — ``ssget "X"``.
|
||||
_AC_SELECTION_SET_ALL = 5
|
||||
|
||||
``IAcadBlock.Item`` is declared ``IAcadEntity*`` (acax25enu.tlb), so with a
|
||||
makepy cache present every member comes back narrowed to ``IAcadEntity``
|
||||
and ``.StyleName`` — a member of the dimension classes, not of the entity
|
||||
base — raises ``AttributeError``. Measured live (AutoCAD 2026): a rotated
|
||||
dimension drawn with ``PROBE-ANSI`` answered ``[]`` because a blanket
|
||||
``except`` turned that AttributeError into "no dimension uses it". Each
|
||||
item is therefore un-narrowed (``_com_unnarrow``), and nothing is
|
||||
swallowed: a read that fails is an error the caller sees, not an empty
|
||||
list reported as fact.
|
||||
#: Characters an ``ssget`` filter string treats as wildcards (AutoCAD's
|
||||
#: ``wcmatch`` grammar). Symbol names may carry most of them: a dimension
|
||||
#: style called ``A.B`` also matched ``A_B``, ``N#1`` matched ``N21``,
|
||||
#: ``P[1]`` matched ``P1`` and ``S@X`` matched ``SAX`` until the name was
|
||||
#: escaped (measured live, AutoCAD 2026).
|
||||
_WCMATCH_SPECIALS = frozenset("*?#@.~[]-,`")
|
||||
|
||||
|
||||
def _wcmatch_literal(text: str) -> str:
|
||||
"""``text`` spelled so an ``ssget`` filter matches it literally.
|
||||
|
||||
Every wildcard-significant character is escaped with a backtick, the
|
||||
grammar's own escape. A symbol name can never contain a backtick itself
|
||||
(AutoCAD refuses one), so the escaped form is unambiguous; the match stays
|
||||
case-insensitive, as any symbol-table name comparison is.
|
||||
"""
|
||||
wanted = style_name.lower()
|
||||
handles: list[str] = []
|
||||
for index in range(doc.Layouts.Count):
|
||||
block = doc.Layouts.Item(index).Block
|
||||
for position in range(block.Count):
|
||||
obj = _com_unnarrow(block.Item(position))
|
||||
if "Dimension" not in str(obj.ObjectName):
|
||||
continue
|
||||
if str(obj.StyleName).lower() == wanted:
|
||||
handles.append(str(obj.Handle))
|
||||
return handles
|
||||
return "".join("`" + char if char in _WCMATCH_SPECIALS else char for char in text)
|
||||
|
||||
|
||||
def _com_dimensions_using(doc, style_name: str) -> list[str]:
|
||||
"""Handles of every DIMENSION, in every layout, whose style is ``style_name``.
|
||||
|
||||
One filtered ``SelectionSet.Select(acSelectionSetAll)`` — the ``ssget "X"``
|
||||
route with ``(0 . "DIMENSION") (3 . <style>)`` — answers from AutoCAD's own
|
||||
index, so the cost does not grow with the drawing. The walk this replaced
|
||||
read ``ObjectName`` on every entity of every layout through a dynamic
|
||||
proxy (~8-10 ms each), which put ``dimstyle_modify`` past the 60 s
|
||||
``COM_CALL_TIMEOUT`` on a drawing of ~6k entities — after its write had
|
||||
already landed. Measured live (AutoCAD 2026, 1000 LINEs + 14 dimensions
|
||||
across model and paper space): walk 12.7-15.8 s, this 0.02-0.09 s, same
|
||||
handles. The style name is escaped as a literal (``_wcmatch_literal``)
|
||||
because the filter is a wildcard pattern; the ``DIMENSION`` group-0 name
|
||||
keeps parity with the headless engine's ``query("DIMENSION")``.
|
||||
|
||||
Handles come back in ascending handle order (database order), whatever
|
||||
order the selection set reports them in. Nothing is swallowed: a
|
||||
selection that fails is an error the caller sees, not an empty list
|
||||
reported as fact.
|
||||
"""
|
||||
ss = doc.SelectionSets.Add(f"_DIMSTYLE_{uuid.uuid4().hex[:8]}")
|
||||
try:
|
||||
filter_types = _ai([0, 3])
|
||||
filter_values = win32com.client.VARIANT(
|
||||
pythoncom.VT_ARRAY | pythoncom.VT_VARIANT,
|
||||
["DIMENSION", _wcmatch_literal(style_name)],
|
||||
)
|
||||
ss.Select(_AC_SELECTION_SET_ALL, None, None, filter_types, filter_values)
|
||||
# ``Handle`` is an IAcadObject member, so it answers on the narrowed
|
||||
# ``IAcadEntity`` wrapper ``SelectionSet.Item`` hands back.
|
||||
handles = [str(ss.Item(index).Handle) for index in range(ss.Count)]
|
||||
finally:
|
||||
try:
|
||||
ss.Delete()
|
||||
except Exception as exc:
|
||||
log.debug("SelectionSet cleanup failed: %s", exc)
|
||||
return sorted(handles, key=lambda handle: int(handle, 16))
|
||||
|
||||
|
||||
#: File suffixes AutoCAD hands to the Windows font system rather than its
|
||||
|
||||
+139
-21
@@ -621,15 +621,91 @@ class _FakeBlock:
|
||||
|
||||
def __init__(self, *entities):
|
||||
self.entities = list(entities)
|
||||
self.item_reads = 0
|
||||
|
||||
@property
|
||||
def Count(self):
|
||||
return len(self.entities)
|
||||
|
||||
def Item(self, index):
|
||||
self.item_reads += 1
|
||||
return _NarrowedToIAcadEntity(self.entities[index])
|
||||
|
||||
|
||||
class _FakeSelectionSet:
|
||||
"""An ``AcadSelectionSet`` filled by ``Select(acSelectionSetAll, ..., filter)``
|
||||
— ``ssget "X"`` over every layout's block — with the two filter groups
|
||||
the backend sends: 0 (DXF name, ``DIMENSION`` for every dimension class)
|
||||
and 3 (dimension style, a *wildcard pattern*, so the backend must hand it
|
||||
over backtick-escaped; the fake unescapes and compares literally,
|
||||
case-insensitively, the way AutoCAD does — measured live). ``Item`` is
|
||||
declared ``IAcadEntity*`` and comes back narrowed, like ``IAcadBlock.Item``."""
|
||||
|
||||
def __init__(self, name, document):
|
||||
self.Name = name
|
||||
self._document = document
|
||||
self._members: list = []
|
||||
self.deleted = False
|
||||
|
||||
@staticmethod
|
||||
def _unescape(pattern: str) -> str:
|
||||
out, escaped = [], False
|
||||
for char in pattern:
|
||||
if escaped:
|
||||
out.append(char)
|
||||
escaped = False
|
||||
elif char == "`":
|
||||
escaped = True
|
||||
elif char in "*?#@.~[],":
|
||||
raise AssertionError(
|
||||
f"unescaped wildcard {char!r} in filter {pattern!r}: a style named "
|
||||
"A.B would also select the dimensions of A_B"
|
||||
)
|
||||
else:
|
||||
out.append(char)
|
||||
return "".join(out)
|
||||
|
||||
def Select(self, mode, point1, point2, filter_types, filter_values):
|
||||
assert mode == 5, "acSelectionSetAll — every layout, not the current space"
|
||||
assert point1 is None and point2 is None
|
||||
types_ = list(filter_types.value)
|
||||
values = list(filter_values.value)
|
||||
assert types_ == [0, 3], types_
|
||||
assert values[0] == "DIMENSION"
|
||||
wanted = self._unescape(values[1]).lower()
|
||||
self._document.SelectionSets.selects.append((self.Name, values[1]))
|
||||
self._members = [
|
||||
entity
|
||||
for layout_index in range(self._document.Layouts.Count)
|
||||
for entity in self._document.Layouts.Item(layout_index).Block.entities
|
||||
if "Dimension" in entity.ObjectName and str(entity.StyleName).lower() == wanted
|
||||
]
|
||||
|
||||
@property
|
||||
def Count(self):
|
||||
return len(self._members)
|
||||
|
||||
def Item(self, index):
|
||||
return _NarrowedToIAcadEntity(self._members[index])
|
||||
|
||||
def Delete(self):
|
||||
self.deleted = True
|
||||
self._document.SelectionSets.entries.remove(self)
|
||||
|
||||
|
||||
class _FakeSelectionSets:
|
||||
def __init__(self, document):
|
||||
self._document = document
|
||||
self.entries: list[_FakeSelectionSet] = []
|
||||
self.selects: list[tuple[str, str]] = [] # (set name, group-3 filter value)
|
||||
|
||||
def Add(self, name):
|
||||
assert all(ss.Name != name for ss in self.entries), f"selection set {name!r} exists"
|
||||
ss = _FakeSelectionSet(name, self._document)
|
||||
self.entries.append(ss)
|
||||
return ss
|
||||
|
||||
|
||||
class _FakeLayouts:
|
||||
def __init__(self, *blocks):
|
||||
self.layouts = [types.SimpleNamespace(Block=block) for block in blocks]
|
||||
@@ -847,7 +923,16 @@ class _FakeDocument:
|
||||
sheet_dim = types.SimpleNamespace(
|
||||
ObjectName="AcDbAlignedDimension", StyleName="Standard", Handle="2C"
|
||||
)
|
||||
self.Layouts = _FakeLayouts(_FakeBlock(dim, line), _FakeBlock(sheet_dim))
|
||||
dotted = types.SimpleNamespace(
|
||||
ObjectName="AcDbRadialDimension", StyleName="A.B", Handle="2E"
|
||||
)
|
||||
underscored = types.SimpleNamespace(
|
||||
ObjectName="AcDbRadialDimension", StyleName="A_B", Handle="2F"
|
||||
)
|
||||
self.Layouts = _FakeLayouts(
|
||||
_FakeBlock(dim, line, dotted), _FakeBlock(sheet_dim, underscored)
|
||||
)
|
||||
self.SelectionSets = _FakeSelectionSets(self)
|
||||
self.Blocks = _FakeBlocks("*Model_Space", "*Paper_Space", "MyArrow")
|
||||
self.Dictionaries = _FakeDictionaries(
|
||||
self.calls,
|
||||
@@ -1455,33 +1540,66 @@ async def test_com_dimstyle_modify_refuses_a_missing_arrowhead_and_restores_on_f
|
||||
)
|
||||
|
||||
|
||||
async def test_com_dimensions_using_style_reads_through_the_iacadentity_narrowing(com_backend):
|
||||
"""Finding 4: `IAcadBlock.Item` is declared `IAcadEntity*`, so with a
|
||||
makepy cache `.StyleName` on a dimension raises AttributeError, and the
|
||||
old blanket `except` reported `[]` for a style a dimension used. The fake
|
||||
narrows the same way; the walker un-narrows and swallows nothing."""
|
||||
async def test_com_dimensions_using_style_is_one_filtered_selection_not_a_walk(com_backend):
|
||||
"""One ``SelectionSet.Select(acSelectionSetAll, (0 . "DIMENSION") (3 . style))``
|
||||
answers from AutoCAD's index, whatever the drawing size. The walk it
|
||||
replaced un-narrowed every entity of every layout (~8-10 ms each) and put
|
||||
`dimstyle_modify` past COM_CALL_TIMEOUT on a ~6k-entity drawing after its
|
||||
write had landed. The fake narrows ``SelectionSet.Item`` to IAcadEntity,
|
||||
as pywin32 does; ``Handle`` is an IAcadObject member and answers."""
|
||||
from backends.com_backend import _com_dimensions_using
|
||||
|
||||
backend, document = com_backend
|
||||
narrowed = document.Layouts.Item(0).Block.Item(0)
|
||||
assert narrowed.ObjectName == "AcDbRotatedDimension" and narrowed.Handle == "2A"
|
||||
with pytest.raises(AttributeError, match="'IAcadEntity' object has no attribute 'StyleName'"):
|
||||
getattr(narrowed, "StyleName") # noqa: B009 — the read itself is what raises
|
||||
assert _com_dimensions_using(document, "iso-25") == ["2A"]
|
||||
assert _com_dimensions_using(document, "Standard") == ["2C"]
|
||||
assert _com_dimensions_using(document, "iso-25") == ["2A"], "case-insensitive"
|
||||
assert _com_dimensions_using(document, "Standard") == ["2C"], "paper space too"
|
||||
assert _com_dimensions_using(document, "NOPE") == []
|
||||
blocks = [document.Layouts.Item(i).Block for i in range(document.Layouts.Count)]
|
||||
assert [block.item_reads for block in blocks] == [0, 0], "no per-entity COM read"
|
||||
assert document.SelectionSets.entries == [], "every selection set is deleted again"
|
||||
assert len(document.SelectionSets.selects) == 3 and document.calls == [], (
|
||||
"one Select per lookup, nothing else on the document"
|
||||
)
|
||||
|
||||
class _BrokenDimension:
|
||||
ObjectName = "AcDbAlignedDimension"
|
||||
Handle = "2D"
|
||||
more = types.SimpleNamespace(ObjectName="AcDbAlignedDimension", StyleName="ISO-25", Handle="A")
|
||||
document.Layouts.Item(1).Block.entities.insert(0, more)
|
||||
assert _com_dimensions_using(document, "ISO-25") == ["A", "2A"], (
|
||||
"ascending handle order, not the selection set's report order"
|
||||
)
|
||||
|
||||
@property
|
||||
def StyleName(self):
|
||||
raise RuntimeError("RPC_E_CALL_REJECTED")
|
||||
|
||||
document.Layouts.Item(1).Block.entities.append(_BrokenDimension())
|
||||
with pytest.raises(RuntimeError, match="RPC_E_CALL_REJECTED"):
|
||||
_com_dimensions_using(document, "Standard")
|
||||
async def test_com_dimensions_using_style_escapes_wildcards_in_the_style_name(com_backend):
|
||||
"""The group-3 filter value is a wcmatch pattern: measured live, ``A.B``
|
||||
selected the dimensions of ``A_B`` too, ``N#1`` those of ``N21``,
|
||||
``P[1]`` those of ``P1``, ``S@X`` those of ``SAX``. Backtick-escaping
|
||||
the name makes the match literal (and the fake refuses a raw wildcard)."""
|
||||
from backends.com_backend import _com_dimensions_using, _wcmatch_literal
|
||||
|
||||
backend, document = com_backend
|
||||
assert _wcmatch_literal("ISO-25") == "ISO`-25"
|
||||
assert _wcmatch_literal("A.B") == "A`.B"
|
||||
assert _wcmatch_literal("N#1 P[1] S@X T~ *?,") == "N`#1 P`[1`] S`@X T`~ `*`?`,"
|
||||
assert _wcmatch_literal("Plain_Name 2") == "Plain_Name 2"
|
||||
assert _com_dimensions_using(document, "A.B") == ["2E"], "not A_B's dimension"
|
||||
assert _com_dimensions_using(document, "a_b") == ["2F"]
|
||||
assert [value for _, value in document.SelectionSets.selects[-2:]] == ["A`.B", "a_b"]
|
||||
|
||||
|
||||
async def test_com_dimensions_using_style_swallows_nothing_and_still_cleans_up(com_backend):
|
||||
from backends.com_backend import _com_dimensions_using
|
||||
|
||||
backend, document = com_backend
|
||||
|
||||
def rejected(self, *args):
|
||||
raise RuntimeError("RPC_E_CALL_REJECTED")
|
||||
|
||||
original = _FakeSelectionSet.Select
|
||||
_FakeSelectionSet.Select = rejected
|
||||
try:
|
||||
with pytest.raises(RuntimeError, match="RPC_E_CALL_REJECTED"):
|
||||
_com_dimensions_using(document, "Standard")
|
||||
finally:
|
||||
_FakeSelectionSet.Select = original
|
||||
assert document.SelectionSets.entries == [], "the selection set is deleted on failure too"
|
||||
|
||||
|
||||
async def test_truetype_face_reads_family_and_subfamily(tmp_path):
|
||||
|
||||
Reference in New Issue
Block a user