diff --git a/README.md b/README.md index a90ac14..34486fd 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ refine quality issues inside transactions, and deliver hashed artifacts with validation evidence. > **v1.5 release snapshot:** 154 tools · 6 resources · 5 prompt templates · -> 1243 collected tests. Runtime discovery through `system_about` is authoritative. +> 1245 collected tests. Runtime discovery through `system_about` is authoritative. ## Install in 10 seconds diff --git a/backends/com_backend.py b/backends/com_backend.py index cffd363..2298f57 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -59,6 +59,18 @@ if _WIN32_AVAILABLE: else: _COM_IMPORTS_OK = False +#: What `_run` may catch as a COM failure. A *tuple*, because where pywin32 is +#: absent the right value is the empty one: `except ()` catches nothing, which +#: is exactly correct when no `com_error` type exists to be raised. +#: +#: `except pywintypes.com_error` used to be written directly, and Python +#: evaluates that name on the way out of every failed call — not only COM ones. +#: On any host without pywin32 (all of Linux, and Windows installed without the +#: `[com]` extra) it raised `NameError: name 'pywintypes' is not defined` and +#: buried the real exception. A placeholder Exception subclass would be worse +#: than the bug: it would silently swallow unrelated errors. +_COM_ERROR: tuple[type[BaseException], ...] = (pywintypes.com_error,) if _COM_IMPORTS_OK else () + try: from PIL import Image as PILImage @@ -682,7 +694,7 @@ class ComBackend(AutoCADBackend): "(press ESC in AutoCAD). Increase COM_CALL_TIMEOUT if a long " "operation is expected." ) from e - except pywintypes.com_error as e: + except _COM_ERROR as e: hr = e.args[0] if e.args else 0 if hr in (-2147221246, -2147221005, -2147417842): _COM_STATE.pop("app", None) diff --git a/tests/test_com_timeout.py b/tests/test_com_timeout.py index 446ecb5..3275de7 100644 --- a/tests/test_com_timeout.py +++ b/tests/test_com_timeout.py @@ -117,6 +117,10 @@ async def test_genuine_deadline_still_rebuilds_the_executor(com_backend, monkeyp assert await asyncio.wait_for(com_backend._run(lambda: "fresh"), timeout=3) == "fresh" +def _raise_value_error(): + raise ValueError("nope") + + async def test_ordinary_errors_are_unchanged(com_backend, monkeypatch): """Sanity: non-timeout failures never touched the executor and still don't.""" monkeypatch.setattr(config.settings, "com_call_timeout", 5) @@ -130,3 +134,36 @@ async def test_ordinary_errors_are_unchanged(com_backend, monkeypatch): assert com_backend._executor is live assert await com_backend._run(lambda x: x * 2, 21) == 42 + + +# ── the handler must be inert where pywin32 is absent ─────────────────────── + + +async def test_an_ordinary_error_survives_a_host_without_pywin32(com_backend, monkeypatch): + """`except pywintypes.com_error` is evaluated on the way out of *every* + failed call, not only COM ones. + + `pywintypes` is bound only when `sys.platform == "win32"` and the import + succeeded, so on Linux — and on a Windows box that installed without the + `[com]` extra — that clause raised `NameError: name 'pywintypes' is not + defined` and buried the real exception. Every Linux CI leg failed on + exactly this; the timeout tests survived only because they matched an + earlier `except`. + + Deleting the module attribute reproduces the absent import faithfully: the + clause resolves the name through the module globals either way. + """ + monkeypatch.delattr(cb, "pywintypes", raising=False) + monkeypatch.setattr(config.settings, "com_call_timeout", 5) + + with pytest.raises(ValueError, match="nope"): + await com_backend._run(_raise_value_error) + + +async def test_the_com_error_clause_is_a_tuple_so_it_can_be_empty(): + """An empty tuple catches nothing, which is the correct behaviour when no + `com_error` type exists to be raised — a placeholder Exception subclass + would swallow unrelated errors instead.""" + assert isinstance(cb._COM_ERROR, tuple) + if not cb._COM_IMPORTS_OK: + assert cb._COM_ERROR == ()