fix(task-10): un-narrow the ACAD_MLEADERSTYLE dictionary route so mleaderstyle_list/create work on the live engine

acax25enu.tlb declares IAcadDictionaries.Item, IAcadDictionary.Item and
IAcadDictionary.AddObject as returning IAcadObject*. With a makepy cache for
the AutoCAD type library present (it is, on any machine where gencache ever
ran against AutoCAD), pywin32 wraps each result in the gen_py class of the
declared type, which exposes only IAcadObject members: .Count/.Item/.GetName
on the dictionary and .ArrowSize/.LandingGap/.TextHeight/.TextStyle on a
style all raised AttributeError, so the previous round's removal of the
@capability refusal turned a typed UnsupportedCapabilityError into a raw
AttributeError on AutoCAD 2026.

`_com_unnarrow` re-dispatches an object on its raw IDispatch through
win32com.client.dynamic.Dispatch (no gencache side effect, unlike CastTo),
and the dictionary route applies it to the dictionary, to every Item(i) and
to the AddObject result. Verified live on AutoCAD 2026 (scratch document,
closed afterwards): list reads Standard = 0.18/0.09/0.18/Standard, create
writes ISO-LDR = 2.5/1.0/2.5/ISOCP, the duplicate is refused, and an
independent late-bound read of the dictionary agrees.

The fake in tests/test_styles.py now models the narrowing: Dictionaries.Item,
Dictionary.Item and Dictionary.AddObject hand back a wrapper that answers only
IAcadObject members and raises on everything else (reads and writes alike)
until unwrapped through _oleobj_. Five COM tests fail against the previous
backend and pass now; a new test pins the missing-dictionary path
(Dictionaries.Add before AddObject).

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-21 18:54:43 +03:00
co-authored by Claude Opus 5
parent e204f227b4
commit 266b6fb838
2 changed files with 186 additions and 16 deletions
+49 -8
View File
@@ -345,6 +345,37 @@ _MLEADERSTYLE_DICTIONARY = "ACAD_MLEADERSTYLE"
_MLEADERSTYLE_CLASS = "AcDbMLeaderStyle" _MLEADERSTYLE_CLASS = "AcDbMLeaderStyle"
def _com_unnarrow(obj):
"""Re-dispatch ``obj`` on its raw ``IDispatch`` so its *runtime* class is reachable.
acax25enu.tlb declares ``IAcadDictionaries.Item``, ``IAcadDictionary.Item``
and ``IAcadDictionary.AddObject`` as returning ``IAcadObject*``. Whenever a
makepy cache for the AutoCAD type library exists (it does on any machine
where ``win32com.client.gencache`` ever ran against AutoCAD), pywin32 wraps
such a return in the gen_py class of the *declared* type, and that wrapper
exposes only ``IAcadObject`` members: ``.Count`` / ``.Item`` / ``.GetName``
on the dictionary and ``.ArrowSize`` / ``.TextStyle`` on a leader style all
raise ``AttributeError`` even though the live object answers them. (The
rest of this backend never meets the problem: ``HandleToObject`` is declared
``IDispatch*``, which keeps the concrete class, and ``ModelSpace.Item`` is
read only through ``IAcadEntity`` members.)
``win32com.client.dynamic.Dispatch`` on the raw interface builds a
late-bound proxy from the object's own type info, so every member of the
concrete class resolves — verified live on AutoCAD 2026 — and, unlike
``CastTo``, it never calls ``gencache.EnsureDispatch`` and never writes the
makepy cache. A wrapper whose ``_oleobj_`` is not a COM interface (the
fake in tests/test_styles.py) is unwrapped to that object as-is; anything
without ``_oleobj_`` is returned unchanged.
"""
ole = getattr(obj, "_oleobj_", None)
if ole is None:
return obj
if _COM_IMPORTS_OK and isinstance(ole, pythoncom.TypeIIDs[pythoncom.IID_IDispatch]):
return win32com.client.dynamic.Dispatch(ole)
return ole
def _com_mleaderstyle_dictionary(doc, *, create: bool = False): def _com_mleaderstyle_dictionary(doc, *, create: bool = False):
"""The ``ACAD_MLEADERSTYLE`` dictionary, or None when the drawing has none. """The ``ACAD_MLEADERSTYLE`` dictionary, or None when the drawing has none.
@@ -353,23 +384,31 @@ def _com_mleaderstyle_dictionary(doc, *, create: bool = False):
``ArrowSize``, ``LandingGap``, ``TextHeight``, ``TextStyle``, ...), and ``ArrowSize``, ``LandingGap``, ``TextHeight``, ``TextStyle``, ...), and
``IAcadDictionary.AddObject(keyword, "AcDbMLeaderStyle")`` — the ``IAcadDictionary.AddObject(keyword, "AcDbMLeaderStyle")`` — the
documented VBA route — creates one. Every drawing AutoCAD makes carries documented VBA route — creates one. Every drawing AutoCAD makes carries
the dictionary; ``create`` adds it to a foreign file that does not. The the dictionary; ``create`` adds it to a foreign file that does not.
route is verified against the AutoCAD 2026 type library and fake-tested;
its live execution is scripts/smoke_settings_com.py's job. Every object this route hands over goes through ``_com_unnarrow`` (see
there): ``Dictionaries.Item`` is declared ``IAcadObject*`` and comes back
as a wrapper with no ``Count``. Verified live on AutoCAD 2026 (list and
create on a scratch document); scripts/smoke_settings_com.py re-runs it.
""" """
try: try:
return doc.Dictionaries.Item(_MLEADERSTYLE_DICTIONARY) dictionary = doc.Dictionaries.Item(_MLEADERSTYLE_DICTIONARY)
except Exception: except Exception:
if not create: if not create:
return None return None
return doc.Dictionaries.Add(_MLEADERSTYLE_DICTIONARY) dictionary = doc.Dictionaries.Add(_MLEADERSTYLE_DICTIONARY)
return _com_unnarrow(dictionary)
def _com_mleaderstyle_rows(dictionary) -> list[tuple[str, Any]]: def _com_mleaderstyle_rows(dictionary) -> list[tuple[str, Any]]:
"""``(name, AcadMLeaderStyle)`` for every item, named by the dictionary key.""" """``(name, AcadMLeaderStyle)`` for every item, named by the dictionary key.
Each ``Item(i)`` is un-narrowed: declared ``IAcadObject*``, it would
otherwise carry no ``ArrowSize``.
"""
rows = [] rows = []
for index in range(dictionary.Count): for index in range(dictionary.Count):
style = dictionary.Item(index) style = _com_unnarrow(dictionary.Item(index))
rows.append((str(dictionary.GetName(style)), style)) rows.append((str(dictionary.GetName(style)), style))
return rows return rows
@@ -4486,7 +4525,9 @@ class ComBackend(AutoCADBackend):
text_style_name, textstyle_created = _ensure_com_textstyle( text_style_name, textstyle_created = _ensure_com_textstyle(
doc, typed["text_style"], refusal_key="text_style" doc, typed["text_style"], refusal_key="text_style"
) )
style = dictionary.AddObject(clean, _MLEADERSTYLE_CLASS) # AddObject is declared IAcadObject* too: un-narrowed, or the
# first property write below raises AttributeError.
style = _com_unnarrow(dictionary.AddObject(clean, _MLEADERSTYLE_CLASS))
style.ArrowSize = typed["arrow_size"] style.ArrowSize = typed["arrow_size"]
style.LandingGap = typed["landing_gap"] style.LandingGap = typed["landing_gap"]
style.TextHeight = typed["text_height"] style.TextHeight = typed["text_height"]
+137 -8
View File
@@ -550,12 +550,64 @@ class _FakeMLeaderStyle:
super().__setattr__(key, value) super().__setattr__(key, value)
class _NarrowedToIAcadObject:
"""What pywin32 hands back for ``Dictionaries.Item``, ``Dictionary.Item``
and ``Dictionary.AddObject`` once a makepy cache for the AutoCAD type
library exists: acax25enu.tlb declares all three as returning
``IAcadObject*``, so the result is the gen_py ``IAcadObject`` class, which
exposes only IAcadObject members and raises AttributeError for everything
the runtime class (``IAcadDictionary``, ``IAcadMLeaderStyle``) adds — on a
read *and* on a property write. The full object is reachable only through
``_oleobj_``, which is how the backend must un-narrow it (this is exactly
what happened live on AutoCAD 2026 before the fix)."""
IACADOBJECT_MEMBERS = frozenset(
{
"Application",
"Database",
"Document",
"Handle",
"HasExtensionDictionary",
"ObjectID",
"ObjectName",
"OwnerID",
"Delete",
"Erase",
"GetExtensionDictionary",
"GetXData",
"SetXData",
}
)
def __init__(self, raw):
object.__setattr__(self, "_oleobj_", raw)
def __getattr__(self, key):
if key in self.IACADOBJECT_MEMBERS:
return getattr(self._oleobj_, key)
raise AttributeError(
"'<win32com.gen_py.AutoCAD 2025 Type Library.IAcadObject instance>' "
f"object has no attribute {key!r}"
)
def __setattr__(self, key, value):
# gen_py's DispatchBaseClass.__setattr__ consults _prop_map_put_, and
# IAcadObject's is empty: every property write raises.
raise AttributeError(
"'<win32com.gen_py.AutoCAD 2025 Type Library.IAcadObject instance>' "
f"object has no attribute {key!r}"
)
class _FakeDictionary: class _FakeDictionary:
"""The ACAD_MLEADERSTYLE dictionary: Count/Item/GetName over its objects """The ACAD_MLEADERSTYLE dictionary: Count/Item/GetName over its objects
and AddObject(keyword, class_name), the documented VBA route.""" and AddObject(keyword, class_name), the documented VBA route. Item and
AddObject hand their result over narrowed to IAcadObject, as pywin32
does."""
def __init__(self, calls, *names): def __init__(self, calls, *names):
self._calls = calls self._calls = calls
self.ObjectName = "AcDbDictionary"
self.objects = [_FakeMLeaderStyle(name, calls) for name in names] self.objects = [_FakeMLeaderStyle(name, calls) for name in names]
@property @property
@@ -563,10 +615,14 @@ class _FakeDictionary:
return len(self.objects) return len(self.objects)
def Item(self, index): def Item(self, index):
return self.objects[index] return _NarrowedToIAcadObject(self.objects[index])
def GetName(self, obj): def GetName(self, obj):
return obj.Name # ActiveX takes the object itself; a narrowed wrapper resolves to the
# same object, so both spellings name it.
raw = getattr(obj, "_oleobj_", obj)
assert raw in self.objects, "GetName: object is not in this dictionary"
return raw.Name
def AddObject(self, keyword, class_name): def AddObject(self, keyword, class_name):
self._calls.append(("Dictionary.AddObject", keyword, class_name)) self._calls.append(("Dictionary.AddObject", keyword, class_name))
@@ -574,16 +630,27 @@ class _FakeDictionary:
raise RuntimeError(f"cannot create {class_name} here") raise RuntimeError(f"cannot create {class_name} here")
style = _FakeMLeaderStyle(keyword, self._calls) style = _FakeMLeaderStyle(keyword, self._calls)
self.objects.append(style) self.objects.append(style)
return style return _NarrowedToIAcadObject(style)
class _FakeDictionaries: class _FakeDictionaries:
def __init__(self, entries): """``doc.Dictionaries``: Item is declared IAcadObject* (narrowed); Add is
declared IAcadDictionary* and comes back with its full surface."""
def __init__(self, calls, entries):
self._calls = calls
self.entries = entries self.entries = entries
def Item(self, name): def Item(self, name):
if name not in self.entries: if name not in self.entries:
raise RuntimeError(f"no dictionary {name}") raise RuntimeError(f"no dictionary {name}")
return _NarrowedToIAcadObject(self.entries[name])
def Add(self, name):
self._calls.append(("Dictionaries.Add", name))
if name in self.entries:
raise RuntimeError(f"dictionary {name} already exists")
self.entries[name] = _FakeDictionary(self._calls)
return self.entries[name] return self.entries[name]
@@ -626,9 +693,16 @@ class _FakeDocument:
) )
self.Layouts = _FakeLayouts(_FakeBlock(dim, line), _FakeBlock(sheet_dim)) self.Layouts = _FakeLayouts(_FakeBlock(dim, line), _FakeBlock(sheet_dim))
self.Dictionaries = _FakeDictionaries( self.Dictionaries = _FakeDictionaries(
{"ACAD_MLEADERSTYLE": _FakeDictionary(self.calls, "Standard", "Annotative")} self.calls,
{"ACAD_MLEADERSTYLE": _FakeDictionary(self.calls, "Standard", "Annotative")},
) )
@property
def mleader_dictionary(self) -> _FakeDictionary:
"""The raw ACAD_MLEADERSTYLE dictionary, for test setup and assertions
(the backend only ever sees it narrowed through ``Dictionaries.Item``)."""
return self.Dictionaries.entries["ACAD_MLEADERSTYLE"]
def GetVariable(self, name): def GetVariable(self, name):
return self.variables[name] return self.variables[name]
@@ -809,7 +883,7 @@ async def test_com_textstyle_create_sets_font_width_oblique_height(com_backend):
async def test_com_mleaderstyle_list_reads_the_values_off_the_dictionary_objects(com_backend): async def test_com_mleaderstyle_list_reads_the_values_off_the_dictionary_objects(com_backend):
backend, document = com_backend backend, document = com_backend
dictionary = document.Dictionaries.Item("ACAD_MLEADERSTYLE") dictionary = document.mleader_dictionary
dictionary.objects[1].ArrowSize = 3.0 dictionary.objects[1].ArrowSize = 3.0
dictionary.objects[1].TextStyle = "ISOCP" dictionary.objects[1].TextStyle = "ISOCP"
document.calls.clear() # fixture setup, not backend calls document.calls.clear() # fixture setup, not backend calls
@@ -867,7 +941,7 @@ async def test_com_mleaderstyle_create_adds_the_object_then_writes_its_propertie
async def test_com_mleaderstyle_refusals_write_nothing(com_backend): async def test_com_mleaderstyle_refusals_write_nothing(com_backend):
backend, document = com_backend backend, document = com_backend
dictionary = document.Dictionaries.Item("ACAD_MLEADERSTYLE") dictionary = document.mleader_dictionary
with pytest.raises(ValueError, match="already exists"): with pytest.raises(ValueError, match="already exists"):
await backend.mleaderstyle_create("standard", resolve_mleaderstyle("iso", None)) await backend.mleaderstyle_create("standard", resolve_mleaderstyle("iso", None))
with pytest.raises(ValueError, match="text_style"): with pytest.raises(ValueError, match="text_style"):
@@ -878,3 +952,58 @@ async def test_com_mleaderstyle_refusals_write_nothing(com_backend):
await backend.mleaderstyle_create("X", {**resolve_mleaderstyle("iso"), "arrow_size": 0}) await backend.mleaderstyle_create("X", {**resolve_mleaderstyle("iso"), "arrow_size": 0})
assert document.calls == [], "nothing reached ActiveX" assert document.calls == [], "nothing reached ActiveX"
assert [obj.Name for obj in dictionary.objects] == ["Standard", "Annotative"] assert [obj.Name for obj in dictionary.objects] == ["Standard", "Annotative"]
async def test_com_dictionary_route_is_narrowed_to_iacadobject_until_unwrapped(com_backend):
"""The failure mode as it happened live (AutoCAD 2026, pywin32 with a
makepy cache): every object the dictionary route returns is wrapped as
the DECLARED type, IAcadObject, so Count / Item / GetName on the
dictionary and ArrowSize on a style raise AttributeError. The fake
models that narrowing, and `_com_unnarrow` is the one door through it —
a raw non-COM `_oleobj_` is handed back as-is, an object without one is
returned unchanged."""
from backends.com_backend import _com_unnarrow
backend, document = com_backend
narrowed = document.Dictionaries.Item("ACAD_MLEADERSTYLE")
assert narrowed.ObjectName == "AcDbDictionary", "IAcadObject members still answer"
with pytest.raises(AttributeError, match="no attribute 'Count'"):
getattr(narrowed, "Count") # noqa: B009 — the read itself is what raises
with pytest.raises(AttributeError, match="no attribute 'Item'"):
narrowed.Item(0)
raw = document.mleader_dictionary
assert _com_unnarrow(narrowed) is raw
assert _com_unnarrow(raw) is raw, "an object without _oleobj_ is returned unchanged"
item = raw.Item(0)
assert item.ObjectName == "AcDbMLeaderStyle"
with pytest.raises(AttributeError, match="no attribute 'ArrowSize'"):
getattr(item, "ArrowSize") # noqa: B009
with pytest.raises(AttributeError, match="no attribute 'ArrowSize'"):
item.ArrowSize = 2.5
assert _com_unnarrow(item) is raw.objects[0]
added = raw.AddObject("LDR-A", "AcDbMLeaderStyle")
with pytest.raises(AttributeError, match="no attribute 'TextStyle'"):
added.TextStyle = "ISOCP"
assert _com_unnarrow(added) is raw.objects[-1]
assert raw.GetName(added) == raw.GetName(raw.objects[-1]) == "LDR-A"
async def test_com_mleaderstyle_create_adds_the_dictionary_to_a_drawing_without_one(com_backend):
"""A foreign drawing may lack ACAD_MLEADERSTYLE: list reports no styles
(and never adds the dictionary), create adds it through Dictionaries.Add
— whose IAcadDictionary* result is un-narrowed like the rest — and then
the style."""
backend, document = com_backend
del document.Dictionaries.entries["ACAD_MLEADERSTYLE"]
assert await backend.mleaderstyle_list() == []
assert document.calls == [] and "ACAD_MLEADERSTYLE" not in document.Dictionaries.entries
result = await backend.mleaderstyle_create("ISO", resolve_mleaderstyle("iso", None))
assert result["ok"] is True and result["name"] == "ISO"
names = [call[0] for call in document.calls]
assert names.index("Dictionaries.Add") < names.index("Dictionary.AddObject")
assert ("Dictionaries.Add", "ACAD_MLEADERSTYLE") in document.calls
assert [obj.Name for obj in document.mleader_dictionary.objects] == ["ISO"]
rows = await backend.mleaderstyle_list()
assert [row["name"] for row in rows] == ["ISO"] and rows[0]["arrow_size"] == 2.5