fix(com): four live defects the settings smoke measured on AutoCAD 2026 — rejected calls after a document switch, typelib spelling of color/Lineweight, narrowed layer-state dictionary, layer 0 Freeze put

Measured 2026-09-22 by scripts/smoke_settings_com.py --build-dwt against the
running AutoCAD 2026 (25.1s), each on a scratch Documents.Add() document:

1. RPC_E_CALL_REJECTED (-2147418111) on Documents.Count straight after
   Documents.Open, after Close(False) inside document_close, and in
   _ensure_document_state: AutoCAD's own message filter refuses incoming
   calls while it is still switching documents. pywin32 exposes no
   CoRegisterMessageFilter, so _wait_out_rejected_call waits the window out
   for read-only callables (0.25 s -> 1 s pauses, 20 s budget, inside
   COM_CALL_TIMEOUT) at those sites and after Documents.Add/Open; any other
   com_error is not retried.
2. layer_list raised "'IAcadLayer' object has no attribute 'Color'. Did you
   mean: 'color'?": acax25enu.tlb spells IAcadEntity/IAcadLayer `color` and
   `Lineweight`. Late-bound dispatch resolved .Color/.LineWeight
   case-insensitively; with a makepy cache present (this machine's gen_py was
   regenerated on 2026-09-17, and every object a method returns is then a
   generated wrapper) the lookup is exact. All 17 sites now use the typelib
   spelling, which works on both wrappers; the fakes model the measured names.
3. layer_state_restore raised "'IAcadObject' object has no attribute
   'Count'": Dictionaries.Item and IAcadDictionary.Item are declared
   IAcadObject* (the narrowing _com_unnarrow already handles for MLEADER
   styles). The save path went through the typed Dictionaries.Add, so only
   the reopen paths failed. Both now un-narrow; the fakes narrow like pywin32.
4. The Freeze put on layer 0 raises 'Invalid layer' whatever the value, even
   False while thawed (color/Linetype/Lineweight/Plottable/LayerOn/Lock/
   ActiveLayer all fine on it), so a state saved with 0 current restored with
   two warnings and 0 unapplied. Freeze is written only when it changes the
   layer.

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 13:07:31 +03:00
co-authored by Claude Opus 5
parent 0514f8f15d
commit b54271f4ac
9 changed files with 291 additions and 61 deletions
+1 -1
View File
@@ -529,7 +529,7 @@ def _blockref(name, blocks, insertion=(0.0, 0.0, 0.0), sx=1.0, sy=1.0, rotation=
Rotation=rotation,
Handle="B1",
Layer="0",
Color=256,
color=256,
Linetype="ByLayer",
Visible=True,
**extra,
+92
View File
@@ -815,3 +815,95 @@ async def test_com_drawing_close_refuses_the_untitled_save_dialog(com_backend):
with pytest.raises(ValueError, match="Save dialog"):
await backend.drawing_close(save=True)
assert app.Documents._docs[2].calls == []
# ── the busy window after a document switch ──────────────────────────────────
class _BusyDocuments(_FakeDocuments):
"""``Documents`` that answers ``RPC_E_CALL_REJECTED`` a fixed number of
times before ``Count`` works — AutoCAD's own message filter refusing the
incoming call while it is still switching documents (measured 2026-09-22
by ``scripts/smoke_settings_com.py --build-dwt`` on AutoCAD 2026, twice:
``Documents.Count`` right after ``Documents.Open`` and after ``Close``)."""
def __init__(self, rejections: int):
super().__init__()
self.rejections_left = rejections
self.rejected = 0
@property
def Count(self):
if self.rejections_left > 0:
self.rejections_left -= 1
self.rejected += 1
pywintypes = pytest.importorskip("pywintypes")
raise pywintypes.com_error(-2147418111, "Call was rejected by callee.", None, None)
return super().Count
async def test_com_ensure_document_state_waits_out_a_rejected_call(com_backend, monkeypatch):
backend, app = com_backend
busy = _BusyDocuments(rejections=3)
busy._docs.extend(app.Documents._docs)
app.Documents = busy
monkeypatch.setattr(backend, "_REJECTED_RETRY_FIRST_PAUSE_S", 0.001)
await backend._ensure_document_state()
assert busy.rejected == 3
assert backend._document_scope_key == ("First.dwg", "C:/work/First.dwg")
async def test_com_ensure_document_state_gives_up_after_its_budget(com_backend, monkeypatch):
"""A seat that keeps rejecting (an operator mid-command) still surfaces
the error — the retry is a window, not a wait forever."""
backend, app = com_backend
busy = _BusyDocuments(rejections=10_000)
busy._docs.extend(app.Documents._docs)
app.Documents = busy
monkeypatch.setattr(backend, "_REJECTED_RETRY_FIRST_PAUSE_S", 0.001)
monkeypatch.setattr(backend, "_REJECTED_RETRY_BUDGET_S", 0.02)
pywintypes = pytest.importorskip("pywintypes")
with pytest.raises(pywintypes.com_error) as excinfo:
await backend._ensure_document_state()
assert excinfo.value.args[0] == -2147418111
assert 1 < busy.rejected < 100, "bounded by the budget, not by the rejection count"
async def test_com_ensure_document_state_does_not_retry_other_errors(com_backend):
backend, app = com_backend
pywintypes = pytest.importorskip("pywintypes")
class _Broken(_FakeDocuments):
calls = 0
@property
def Count(self):
_Broken.calls += 1
raise pywintypes.com_error(-2147417848, "disconnected", None, None)
app.Documents = _Broken()
with pytest.raises(pywintypes.com_error):
await backend._ensure_document_state()
assert _Broken.calls == 1
async def test_com_document_close_waits_out_the_rejection_after_close(com_backend, monkeypatch):
"""The third measured site: ``Documents.Count`` straight after
``doc.Close(False)`` inside ``document_close`` itself, before the
``_ensure_document_state`` read is even reached."""
backend, app = com_backend
busy = _BusyDocuments(rejections=0)
busy._docs.extend(app.Documents._docs)
app.Documents = busy
monkeypatch.setattr(backend, "_REJECTED_RETRY_FIRST_PAUSE_S", 0.001)
untitled = busy._docs[2]
original_close = untitled.Close
def _close_then_go_busy(save_changes):
original_close(save_changes)
busy.rejections_left = 2
untitled.Close = _close_then_go_busy
result = await backend.document_close("Drawing1.dwg", discard=True)
assert busy.rejected == 2
assert result["open_documents"] == 2 and result["active"] == "Second.dwg"
+54 -10
View File
@@ -290,6 +290,22 @@ class _FakeXRecord:
self.deleted = True
class _Narrowed:
"""What pywin32 hands back for ``IAcadDictionaries.Item`` and
``IAcadDictionary.Item`` once a makepy cache exists: the gen_py
``IAcadObject`` wrapper of the *declared* return type — no ``Count``, no
``GetXRecordData`` — with the live object reachable only through
``_oleobj_`` (measured 2026-09-22, AutoCAD 2026, ``layer_state_restore``
in ``scripts/smoke_settings_com.py``: ``AttributeError: 'IAcadObject'
object has no attribute 'Count'``)."""
def __init__(self, obj):
self._oleobj_ = obj
def __getattr__(self, name):
raise AttributeError(f"'IAcadObject' object has no attribute {name!r}")
class _FakeDict:
def __init__(self, name):
self.Name = name
@@ -301,7 +317,7 @@ class _FakeDict:
return len(self.entries)
def Item(self, index):
return self.entries[index]
return _Narrowed(self.entries[index])
def GetName(self, obj):
return obj.name
@@ -328,7 +344,7 @@ class _FakeDictionaries:
self.calls.append(("Item", (name,)))
if name not in self.dicts:
raise RuntimeError(f"no dictionary {name}")
return self.dicts[name]
return _Narrowed(self.dicts[name])
def Add(self, name):
self.calls.append(("Add", (name,)))
@@ -337,14 +353,19 @@ class _FakeDictionaries:
class _FakeLayer:
"""Models the two refusals measured live (AutoCAD 2026): the active layer
cannot be frozen, and a frozen layer cannot be made active."""
"""Models the three refusals measured live (AutoCAD 2026): the active layer
cannot be frozen, a frozen layer cannot be made active, and layer ``0``
refuses the ``Freeze`` put with 'Invalid layer' whatever the value — even
``False`` on the already-thawed layer (2026-09-22, scratch document:
``Freeze=False`` on ``0`` → 'Invalid layer'; ``color`` / ``Linetype`` /
``Lineweight`` / ``Plottable`` / ``LayerOn`` / ``Lock`` / ``ActiveLayer``
all fine on it)."""
def __init__(self, name, **props):
self.Name = name
self.Color = props.get("color", 7)
self.color = props.get("color", 7)
self.Linetype = props.get("linetype", "Continuous")
self.LineWeight = props.get("lineweight", -3)
self.Lineweight = props.get("lineweight", -3)
self.LayerOn = props.get("on", True)
self._frozen = props.get("frozen", False)
self.Lock = props.get("locked", False)
@@ -357,6 +378,8 @@ class _FakeLayer:
@Freeze.setter
def Freeze(self, value):
if self.Name == "0":
raise RuntimeError("AutoCAD COM error: Invalid layer")
if value and self.document is not None and self.document.ActiveLayer is self:
raise RuntimeError("AutoCAD COM error: cannot freeze the current layer")
self._frozen = bool(value)
@@ -447,13 +470,13 @@ async def test_com_restore_reads_the_xrecord_and_writes_layer_properties(com_bac
await backend.layer_state_save("S")
hidden = document.Layers.Item("HIDDEN")
hidden.Freeze = False
hidden.Color = 3
hidden.color = 3
hidden.Plottable = True
document.ActiveLayer = document.Layers.Item("0")
result = await backend.layer_state_restore("S")
assert result["applied"]["layers"] == 3
assert result["applied"]["current_layer"] == "GEOMETRY"
assert hidden.Freeze is True and hidden.Color == 8 and hidden.Plottable is False
assert hidden.Freeze is True and hidden.color == 8 and hidden.Plottable is False
assert document.ActiveLayer.Name == "GEOMETRY"
assert result["missing_layers"] == [] and result["new_layers"] == []
@@ -488,17 +511,38 @@ async def test_com_restore_reports_a_refused_current_layer_instead_of_failing(co
await backend.layer_state_save("PLOT")
geometry = document.Layers.Item("GEOMETRY")
hidden = document.Layers.Item("HIDDEN")
hidden.Color = 3
hidden.color = 3
document.ActiveLayer = document.Layers.Item("0")
geometry.Freeze = True
result = await backend.layer_state_restore("PLOT", properties=["color", "current"])
assert result["applied"]["current_layer"] is None
assert result["applied"]["layers"] == 3 and hidden.Color == 8
assert result["applied"]["layers"] == 3 and hidden.color == 8
assert geometry.Freeze is True and document.ActiveLayer.Name == "0"
(warning,) = result["warnings"]
assert warning.startswith("GEOMETRY: cannot be made current: ")
async def test_com_restore_never_writes_freeze_on_layer_zero(com_backend):
"""Measured live (see ``_FakeLayer``): the ``Freeze`` put on layer ``0``
raises 'Invalid layer' even for ``False``, so a state saved with ``0``
current used to come back with two warnings and ``0`` unapplied. A
``Freeze`` write only happens when it would change the layer."""
backend, document = com_backend
document.ActiveLayer = document.Layers.Item("0")
await backend.layer_state_save("ZERO")
hidden = document.Layers.Item("HIDDEN")
hidden.color = 3
document.ActiveLayer = document.Layers.Item("GEOMETRY")
result = await backend.layer_state_restore("ZERO")
assert "warnings" not in result, result
assert result["applied"] == {
"layers": 3,
"properties": list(PROPERTIES),
"current_layer": "0",
}
assert document.ActiveLayer.Name == "0" and hidden.color == 8
async def test_com_list_and_delete(com_backend):
backend, document = com_backend
await backend.layer_state_save("A", description="one")
+1 -1
View File
@@ -262,7 +262,7 @@ def _fake_lwpolyline(coords, normal):
Elevation=0.0,
Handle="2F",
Layer="0",
Color=256,
color=256,
Linetype="ByLayer",
Visible=True,
Closed=False,
+3 -3
View File
@@ -582,7 +582,7 @@ def test_com_polyline_bulges_follow_points_into_wcs():
Elevation=0.0,
Handle="2F",
Layer="PROCESS-PIPING-MAIN",
Color=256,
color=256,
Linetype="ByLayer",
Visible=True,
Closed=False,
@@ -620,7 +620,7 @@ def test_com_polyline_bulges_are_read_only_when_length_says_an_arc_exists():
Elevation=0.0,
Handle="2F",
Layer="0",
Color=256,
color=256,
Linetype="ByLayer",
Visible=True,
Closed=False,
@@ -885,7 +885,7 @@ def test_com_block_reference_reports_mirrored_from_its_normal():
Rotation=0.0,
Handle="B1",
Layer="0",
Color=256,
color=256,
Linetype="ByLayer",
Visible=True,
Document=SimpleNamespace(Blocks=SimpleNamespace(Item=refuse)),
+1 -1
View File
@@ -234,7 +234,7 @@ def _com_pid_polyline(handle, coords, payload):
"AcDbPolyline",
Handle=handle,
Layer="PROCESS-PIPING-MAIN",
Color=256,
color=256,
Linetype="ByLayer",
Visible=True,
Closed=False,
+1 -1
View File
@@ -795,7 +795,7 @@ class _NarrowedToIAcadEntity(_NarrowedToIAcadObject):
IACADENTITY_MEMBERS = frozenset(
{
"Color",
"color",
"EntityTransparency",
"Hyperlinks",
"Layer",
+4 -4
View File
@@ -483,7 +483,7 @@ async def test_com_explode_carries_the_attrib_frame_and_style_onto_the_text(com)
UpsideDown=False,
Alignment=4,
TextAlignmentPoint=(-20.0, 13.0, 0.0),
Color=3,
color=3,
)
ref = _FakeObject(
"AcDbBlockReference",
@@ -537,7 +537,7 @@ async def test_com_explode_carries_the_attrib_frame_and_style_onto_the_text(com)
"Alignment": 4,
"TextAlignmentPoint": (-20.0, 13.0, 0.0),
"Layer": "PID-TAG",
"Color": 3,
"color": 3,
}
@@ -963,7 +963,7 @@ async def test_com_explode_turns_a_multi_line_attrib_into_mtext(com):
Rotation=0.5236,
StyleName="ISO",
Layer="PID-TAG",
Color=3,
color=3,
Invisible=False,
)
plain = _FakeObject(
@@ -1010,7 +1010,7 @@ async def test_com_explode_turns_a_multi_line_attrib_into_mtext(com):
("AttachmentPoint", 9), # acAttachmentPointBottomRight
("InsertionPoint", (7.0, 15.2, 0.0)), # re-asserted after the attachment moved it
("Layer", "PID-TAG"),
("Color", 3),
("color", 3),
]
assert tuple(space.texts[0].InsertionPoint.value) == (7.0, 15.2, 0.0), (
"the MTEXT ends up anchored at the ATTRIB's alignment point"