From 266b6fb838b32ab3e9beb3221382aa4771fd8f5e Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Mon, 21 Sep 2026 18:54:43 +0300 Subject: [PATCH] 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) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/com_backend.py | 57 +++++++++++++--- tests/test_styles.py | 145 +++++++++++++++++++++++++++++++++++++--- 2 files changed, 186 insertions(+), 16 deletions(-) diff --git a/backends/com_backend.py b/backends/com_backend.py index e2f54d9..76fe4bc 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -345,6 +345,37 @@ _MLEADERSTYLE_DICTIONARY = "ACAD_MLEADERSTYLE" _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): """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 ``IAcadDictionary.AddObject(keyword, "AcDbMLeaderStyle")`` — the documented VBA route — creates one. Every drawing AutoCAD makes carries - the dictionary; ``create`` adds it to a foreign file that does not. The - route is verified against the AutoCAD 2026 type library and fake-tested; - its live execution is scripts/smoke_settings_com.py's job. + the dictionary; ``create`` adds it to a foreign file that does not. + + 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: - return doc.Dictionaries.Item(_MLEADERSTYLE_DICTIONARY) + dictionary = doc.Dictionaries.Item(_MLEADERSTYLE_DICTIONARY) except Exception: if not create: 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]]: - """``(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 = [] for index in range(dictionary.Count): - style = dictionary.Item(index) + style = _com_unnarrow(dictionary.Item(index)) rows.append((str(dictionary.GetName(style)), style)) return rows @@ -4486,7 +4525,9 @@ class ComBackend(AutoCADBackend): text_style_name, textstyle_created = _ensure_com_textstyle( 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.LandingGap = typed["landing_gap"] style.TextHeight = typed["text_height"] diff --git a/tests/test_styles.py b/tests/test_styles.py index 8fd8c7c..82594ba 100644 --- a/tests/test_styles.py +++ b/tests/test_styles.py @@ -550,12 +550,64 @@ class _FakeMLeaderStyle: 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( + "'' " + 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( + "'' " + f"object has no attribute {key!r}" + ) + + class _FakeDictionary: """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): self._calls = calls + self.ObjectName = "AcDbDictionary" self.objects = [_FakeMLeaderStyle(name, calls) for name in names] @property @@ -563,10 +615,14 @@ class _FakeDictionary: return len(self.objects) def Item(self, index): - return self.objects[index] + return _NarrowedToIAcadObject(self.objects[index]) 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): self._calls.append(("Dictionary.AddObject", keyword, class_name)) @@ -574,16 +630,27 @@ class _FakeDictionary: raise RuntimeError(f"cannot create {class_name} here") style = _FakeMLeaderStyle(keyword, self._calls) self.objects.append(style) - return style + return _NarrowedToIAcadObject(style) 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 def Item(self, name): if name not in self.entries: 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] @@ -626,9 +693,16 @@ class _FakeDocument: ) self.Layouts = _FakeLayouts(_FakeBlock(dim, line), _FakeBlock(sheet_dim)) 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): 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): backend, document = com_backend - dictionary = document.Dictionaries.Item("ACAD_MLEADERSTYLE") + dictionary = document.mleader_dictionary dictionary.objects[1].ArrowSize = 3.0 dictionary.objects[1].TextStyle = "ISOCP" 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): backend, document = com_backend - dictionary = document.Dictionaries.Item("ACAD_MLEADERSTYLE") + dictionary = document.mleader_dictionary with pytest.raises(ValueError, match="already exists"): await backend.mleaderstyle_create("standard", resolve_mleaderstyle("iso", None)) 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}) assert document.calls == [], "nothing reached ActiveX" 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