Skip to content

Commit 5475ba8

Browse files
hf-kkleinhf-kkleinclaude
authored
fix: work around SAP GUI COM error 618 on collection.Item(index) (mcp#804) (#61)
* fix: work around SAP GUI COM error 618 on collection.Item(index) On some Windows/SAP GUI hosts, calling `<GuiComponentCollection>.Item(<int>)` under pywin32 late binding raises com_error 618 "Bad index type for collection access." even when the collection is healthy — Children.Count == 1 but Children.Item(0) throws, while Children(0) (default member) and Children.ElementAt(0) return the element fine. The index marshalling differs per host; identical code works elsewhere. This silently broke desktop login: wait_for_session reads conn.children[0], GuiComponentCollection.__getitem__ called Item(index), the com_error was swallowed by a broad except, and login spun to a 30s "No session available" timeout without ever entering credentials (Hochfrequenz/sapgui.mcp#804). Fix: centralise collection indexing in a new `com_collection_item(collection, index)` helper that tries Item(index) first (unchanged on working hosts — can only add success cases), then falls back to ElementAt(index), then the default member, and re-raises the original Item error if all fail. Route every integer-indexed COM collection access through it (GuiComponentCollection, GuiCollection, application connection cleanup, combobox entries, BDT probe, dump_tree). Also log the previously-swallowed poll error in wait_for_session at debug so a future occurrence is diagnosable instead of an opaque timeout. Fallbacks (ElementAt / default member) are empirically confirmed on the affected host to return the GuiSession that Item rejects. Tests: com_collection_item fallback order + re-raise semantics; collection __getitem__/iteration recovery; and an end-to-end wait_for_session regression guard driving the real GuiConnection path with a raising Item. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(_wrap): address Copilot review on com_collection_item - Preserve the original Item() traceback: use a bare `raise` instead of `raise item_error` (which reset the traceback to the re-raise line). Verified the bare raise re-raises the original Item error, not a fallback's, and keeps the Item frame in the traceback. - Fix docstring line-wrap that split "byte-for-byte" across lines. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: hf-kklein <konstantin.klein+claude@hochfrequenz.de> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 93a7def commit 5475ba8

9 files changed

Lines changed: 212 additions & 11 deletions

File tree

src/sapsucker/_wrap.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,54 @@ def wrap_com_object(com_obj: Any) -> GuiComponent:
5959
extra={"type_as_number": type_num, "com_type": getattr(com_obj, "Type", "?")},
6060
)
6161
return cls(com_obj) # type: ignore[misc, no-any-return]
62+
63+
64+
def com_collection_item(com_collection: Any, index: int) -> Any:
65+
"""Return the raw COM element at *index* from a SAP GUI COM collection.
66+
67+
Works around SAP GUI COM error 618, ``"Bad index type for collection
68+
access."``, which some hosts raise for ``collection.Item(<int>)`` under
69+
pywin32 late binding **even when the collection is perfectly healthy**.
70+
71+
Observed on a fresh Windows 11 / SAP GUI 8.0 host (Hochfrequenz/sapgui.mcp#804):
72+
``connection.Children.Count == 1``, yet ``Children.Item(0)`` raises 618 while
73+
``Children(0)`` (the collection's default member) and ``Children.ElementAt(0)``
74+
both return the element fine. The same code works on other hosts, so the
75+
difference is in how the integer index is marshalled to ``Item`` — not the
76+
collection, the element, or the server. This silently broke ``login()``:
77+
``wait_for_session`` reads ``conn.children[0]``, the ``Item`` call raised, and a
78+
broad ``except`` swallowed it, so login spun to a 30s timeout with no session.
79+
80+
Strategy, in order (first that succeeds wins):
81+
1. ``Item(index)`` — the historical path. Tried first so behaviour is
82+
byte-for-byte identical on the (majority of) hosts where it already
83+
works; this fix can only *add* success cases, never remove one.
84+
2. ``ElementAt(index)`` — SAP's documented integer accessor; accepted on
85+
hosts that reject ``Item``'s marshalled index.
86+
3. default member ``collection(index)`` — last resort; empirically accepted
87+
wherever ``ElementAt`` is.
88+
89+
If every strategy fails, the ORIGINAL ``Item`` error is re-raised, so a
90+
genuinely bad index still surfaces its real COM error rather than a masked one.
91+
"""
92+
try:
93+
return com_collection.Item(index)
94+
except Exception: # pylint: disable=broad-exception-caught
95+
# ``getattr(..., None)`` returns a live method proxy for late-bound
96+
# CDispatch objects (so this branch is reached on real SAP hosts), and
97+
# ``None`` for plain objects that genuinely lack the method.
98+
element_at = getattr(com_collection, "ElementAt", None)
99+
if element_at is not None:
100+
try:
101+
return element_at(index)
102+
except Exception: # pylint: disable=broad-exception-caught
103+
pass
104+
try:
105+
return com_collection(index) # default member: collection(index)
106+
except Exception: # pylint: disable=broad-exception-caught
107+
pass
108+
# Bare ``raise`` (not ``raise <saved exc>``) so the ORIGINAL traceback from
109+
# the failed ``Item(index)`` call is preserved. Once the inner fallback
110+
# handlers have completed, the exception being handled here is again the
111+
# original ``Item`` error, so this re-raises it — not a fallback's error.
112+
raise

src/sapsucker/components/application.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
from typing import TYPE_CHECKING, Any
88

9-
from sapsucker._wrap import wrap_com_object
9+
from sapsucker._wrap import com_collection_item, wrap_com_object
1010
from sapsucker.components.base import GuiContainer
1111

1212
if TYPE_CHECKING:
@@ -116,7 +116,7 @@ def __exit__(self, exc_type: Any, exc_val: Any, exc_tb: Any) -> None:
116116
# so closing from the end avoids skipping connections.
117117
for i in range(self._com.Children.Count - 1, -1, -1):
118118
try:
119-
self._com.Children.Item(i).CloseConnection()
119+
com_collection_item(self._com.Children, i).CloseConnection()
120120
except Exception: # noqa: BLE001
121121
pass
122122
except Exception: # noqa: BLE001

src/sapsucker/components/base.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from typing import TYPE_CHECKING, Any
1111

1212
from sapsucker._errors import ElementNotFoundError
13-
from sapsucker._wrap import wrap_com_object
13+
from sapsucker._wrap import com_collection_item, wrap_com_object
1414

1515
if TYPE_CHECKING:
1616
from sapsucker.components.collection import GuiComponentCollection
@@ -275,7 +275,7 @@ def _probe_bdt_fields(com_obj: Any) -> list[ElementInfo]:
275275
try:
276276
found = com_obj.FindAllByNameEx("*", type_num)
277277
for j in range(found.Count):
278-
child = found.Item(j)
278+
child = com_collection_item(found, j)
279279
child_id = str(_safe_com_attr(child, "Id", ""))
280280
if child_id in seen_ids:
281281
continue
@@ -506,7 +506,7 @@ def _dump_tree_recursive(com_obj: Any, depth: int, max_depth: int) -> list[Eleme
506506
if count > 0:
507507
for i in range(count):
508508
try:
509-
child = children_com.Item(i)
509+
child = com_collection_item(children_com, i)
510510
except Exception:
511511
continue
512512
is_container = bool(_safe_com_attr(child, "ContainerType", False))

src/sapsucker/components/collection.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
from typing import Any, Iterator
66

7-
from sapsucker._wrap import wrap_com_object
7+
from sapsucker._wrap import com_collection_item, wrap_com_object
88
from sapsucker.components.base import GuiComponent
99

1010
__all__ = ["GuiCollection", "GuiComponentCollection"]
@@ -27,7 +27,7 @@ def __getitem__(self, index: int) -> GuiComponent:
2727
index += length
2828
if index < 0 or index >= length:
2929
raise IndexError(f"Index {index} out of range for collection of length {length}")
30-
return wrap_com_object(self._com.Item(index))
30+
return wrap_com_object(com_collection_item(self._com, index))
3131

3232
def __iter__(self) -> Iterator[GuiComponent]:
3333
"""Iterate over all wrapped components."""
@@ -55,12 +55,12 @@ def __getitem__(self, index: int) -> Any:
5555
index += length
5656
if index < 0 or index >= length:
5757
raise IndexError(f"Index {index} out of range for collection of length {length}")
58-
return self._com.Item(index)
58+
return com_collection_item(self._com, index)
5959

6060
def __iter__(self) -> Iterator[Any]:
6161
"""Iterate over all items."""
6262
for i in range(self._com.Count):
63-
yield self._com.Item(i)
63+
yield com_collection_item(self._com, i)
6464

6565
def __repr__(self) -> str:
6666
return f"GuiCollection(count={self._com.Count})"

src/sapsucker/components/combobox.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from typing import Any
66

7+
from sapsucker._wrap import com_collection_item
78
from sapsucker.components.base import GuiVComponent
89

910
__all__ = ["GuiComboBox", "GuiComboBoxEntry"]
@@ -54,7 +55,7 @@ def entries(self) -> list[GuiComboBoxEntry]:
5455
"""Return all entries as a list of GuiComboBoxEntry."""
5556
result = []
5657
for i in range(self._com.Entries.Count):
57-
result.append(GuiComboBoxEntry(self._com.Entries.Item(i)))
58+
result.append(GuiComboBoxEntry(com_collection_item(self._com.Entries, i)))
5859
return result
5960

6061
@property

src/sapsucker/login.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -336,7 +336,12 @@ def wait_for_session(conn: Any, timeout: int = 30) -> GuiSession:
336336
if isinstance(session, GuiSessionCls):
337337
return session
338338
except Exception:
339-
pass
339+
# Tolerate transient errors while the session is still materialising,
340+
# but log them: a persistent error here (e.g. the SAP GUI COM error 618
341+
# "Bad index type" that ``conn.children[0]`` used to raise on some hosts,
342+
# sapgui.mcp#804) would otherwise silently spin to the timeout below with
343+
# no clue as to why. See ``com_collection_item`` for that specific fix.
344+
logger.debug("wait_for_session_poll_error", exc_info=True)
340345
time.sleep(0.5)
341346
raise SapGuiTimeoutError(f"No session available on connection after {timeout}s")
342347

unittests/test_collection.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,27 @@ def test_empty(self):
7171
assert len(gcc) == 0
7272
assert list(gcc) == []
7373

74+
def test_getitem_falls_back_when_item_raises_bad_index(self):
75+
"""Indexing still works when raw Item(i) raises SAP GUI error 618.
76+
77+
Regression guard for sapgui.mcp#804: on some hosts ``Item(<int>)`` raises
78+
``com_error`` 618 "Bad index type for collection access." while
79+
``ElementAt(<int>)`` returns the element fine. com_collection_item must
80+
transparently fall back so ``collection[i]`` (and, through it, iteration)
81+
keep working.
82+
"""
83+
items = _make_component_items(2)
84+
col = MagicMock()
85+
col.Count = 2
86+
col.Item.side_effect = RuntimeError("Bad index type for collection access.")
87+
col.ElementAt = lambda i: items[i]
88+
89+
gcc = GuiComponentCollection(col)
90+
assert gcc[0].com is items[0]
91+
assert gcc[1].com is items[1]
92+
# Iteration routes through __getitem__, so it recovers too.
93+
assert [r.com for r in gcc] == items
94+
7495

7596
class TestGuiCollection:
7697
def test_len(self):

unittests/test_factory.py

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
_TYPE_MAP,
1010
wrap_com_object,
1111
)
12+
from sapsucker._wrap import com_collection_item
1213
from sapsucker.components.base import GuiComponent
1314
from sapsucker.components.button import GuiButton
1415
from sapsucker.components.field import GuiTextField
@@ -94,3 +95,98 @@ def test_shell_subtype_entry(self, sub_type, cls):
9495
com = make_mock_com(type_as_number=122, type_name="GuiShell", SubType=sub_type)
9596
result = wrap_com_object(com)
9697
assert isinstance(result, cls)
98+
99+
100+
class _FakeComError(Exception):
101+
"""Stand-in for pywintypes.com_error carrying SAP GUI error 618.
102+
103+
Mirrors the real payload observed on the failing host (sapgui.mcp#804):
104+
``(-2147352567, 'Exception occurred.', (618, 'saplogon',
105+
'Bad index type for collection access.', None, 0, 0), None)``.
106+
"""
107+
108+
109+
_BAD_INDEX_ARGS = (
110+
-2147352567,
111+
"Exception occurred.",
112+
(618, "saplogon", "Bad index type for collection access.", None, 0, 0),
113+
None,
114+
)
115+
116+
117+
class _FakeCollection:
118+
"""Fake SAP GUI COM collection with independently-toggleable accessors.
119+
120+
Models the exact quirk from sapgui.mcp#804: ``Item(<int>)`` can raise COM
121+
error 618 while ``ElementAt(<int>)`` and the default member ``collection(<int>)``
122+
return the element fine.
123+
"""
124+
125+
def __init__(self, items, *, item_ok=True, element_at_ok=True, default_ok=True, has_element_at=True):
126+
self._items = items
127+
self._item_ok = item_ok
128+
self._element_at_ok = element_at_ok
129+
self._default_ok = default_ok
130+
self._has_element_at = has_element_at
131+
self.Count = len(items)
132+
133+
def Item(self, index):
134+
if not self._item_ok:
135+
raise _FakeComError(*_BAD_INDEX_ARGS)
136+
return self._items[index]
137+
138+
def __call__(self, index): # default member: collection(index)
139+
if not self._default_ok:
140+
raise _FakeComError(*_BAD_INDEX_ARGS)
141+
return self._items[index]
142+
143+
def __getattr__(self, name):
144+
# Only synthesise ElementAt; everything else is a genuine miss so that
145+
# ``getattr(col, "ElementAt", None)`` returns None when absent.
146+
if name == "ElementAt" and self.__dict__.get("_has_element_at", False):
147+
148+
def _element_at(index):
149+
if not self._element_at_ok:
150+
raise _FakeComError(*_BAD_INDEX_ARGS)
151+
return self._items[index]
152+
153+
return _element_at
154+
raise AttributeError(name)
155+
156+
157+
class TestComCollectionItem:
158+
"""Tests for com_collection_item() — the SAP GUI error 618 workaround (mcp#804)."""
159+
160+
def test_uses_item_when_it_works(self):
161+
"""When Item(index) succeeds it is used and ElementAt is never consulted."""
162+
items = ["a", "b", "c"]
163+
# ElementAt would raise if reached — proves it is not reached.
164+
col = _FakeCollection(items, item_ok=True, element_at_ok=False)
165+
assert com_collection_item(col, 1) == "b"
166+
167+
def test_falls_back_to_element_at_on_bad_index(self):
168+
"""Item raising 618 falls back to ElementAt(index)."""
169+
items = ["a", "b", "c"]
170+
col = _FakeCollection(items, item_ok=False, element_at_ok=True)
171+
assert com_collection_item(col, 2) == "c"
172+
173+
def test_falls_back_to_default_member_when_item_and_element_at_fail(self):
174+
"""Item and ElementAt both raising falls back to the default member call."""
175+
items = ["a", "b", "c"]
176+
col = _FakeCollection(items, item_ok=False, element_at_ok=False, default_ok=True)
177+
assert com_collection_item(col, 0) == "a"
178+
179+
def test_falls_back_to_default_member_when_element_at_absent(self):
180+
"""A collection without ElementAt falls straight to the default member."""
181+
items = ["a", "b"]
182+
col = _FakeCollection(items, item_ok=False, has_element_at=False, default_ok=True)
183+
assert com_collection_item(col, 1) == "b"
184+
185+
def test_reraises_original_item_error_when_all_strategies_fail(self):
186+
"""If every access strategy fails, the ORIGINAL Item error is re-raised."""
187+
col = _FakeCollection(["a"], item_ok=False, element_at_ok=False, default_ok=False)
188+
with pytest.raises(_FakeComError) as exc_info:
189+
com_collection_item(col, 0)
190+
# The re-raised error is the one from Item (carries the 618 payload), not a
191+
# masked/replaced exception from a later fallback.
192+
assert exc_info.value.args[2][0] == 618

unittests/test_login.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -597,3 +597,30 @@ def children(self):
597597

598598
with pytest.raises(SapGuiTimeoutError, match="No session available"):
599599
wait_for_session(_RaisingConn(), timeout=1)
600+
601+
def test_returns_session_when_raw_children_item_raises_bad_index(self):
602+
"""End-to-end regression guard for sapgui.mcp#804.
603+
604+
On the failing host ``connection.Children.Count == 1`` but the raw
605+
``Children.Item(0)`` raises SAP GUI error 618 "Bad index type for
606+
collection access.", which the old poll loop swallowed and spun to a
607+
timeout. Driving the REAL GuiConnection -> GuiComponentCollection ->
608+
com_collection_item path, wait_for_session must now recover via the
609+
ElementAt fallback and return the session.
610+
"""
611+
from sapsucker.components.connection import GuiConnection
612+
from sapsucker.components.session import GuiSession as GuiSessionCls
613+
from unittests.conftest import make_mock_com
614+
615+
session_com = make_mock_com(type_as_number=12, type_name="GuiSession", name="ses[0]")
616+
children = MagicMock()
617+
children.Count = 1
618+
children.Item.side_effect = RuntimeError("Bad index type for collection access.")
619+
children.ElementAt = lambda i: session_com
620+
conn_com = MagicMock()
621+
conn_com.DisabledByServer = False
622+
conn_com.Children = children
623+
624+
result = wait_for_session(GuiConnection(conn_com), timeout=2)
625+
assert isinstance(result, GuiSessionCls)
626+
assert result.com is session_com

0 commit comments

Comments
 (0)