diff --git a/backends/com_backend.py b/backends/com_backend.py index 7cbc75a..72aea53 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -199,6 +199,27 @@ _BUILTIN_LINETYPES = {"continuous", "bylayer", "byblock"} #: `drawing_properties_*` field → `IAcadSummaryInfo` property. _SUMMARY_ATTRS = {field: field.capitalize() for field in SUMMARY_FIELDS} +#: `IAcadSummaryInfo` failure codes, as the `scode` in a `com_error`'s +#: excepinfo (measured on AutoCAD 2026; the description next to them is +#: English while the HRESULT text is localised, so the code is matched first). +_SUMMARYINFO_DUPLICATE_KEY = -2145386475 # AddCustomInfo: 'Duplicate key' +_SUMMARYINFO_KEY_NOT_FOUND = -2145386476 # Set/RemoveCustomByKey: 'Key not found' + + +def _summaryinfo_error_is(exc: BaseException, scode: int, description: str) -> bool: + """True when `exc` is the `IAcadSummaryInfo` failure `scode` / `description`. + + `com_error.args[2]` is the excepinfo tuple ``(wCode, source, description, + helpfile, helpcontext, scode)``; a missing or foreign excepinfo is never a + match, so an unrelated COM failure keeps propagating. + """ + info = exc.args[2] if len(exc.args) > 2 else None + if not isinstance(info, tuple): + return False + code = info[5] if len(info) > 5 else None + desc = str(info[2] or "") if len(info) > 2 else "" + return code == scode or desc.strip().casefold() == description.casefold() + def _ensure_linetype_loaded(name: str) -> None: """If `name` is not already loaded, load it via -LINETYPE behind FILEDIA=0. @@ -4293,31 +4314,43 @@ class ComBackend(AutoCADBackend): def _sync(): info = _acad_doc().SummaryInfo - # AutoCAD's custom keys are case-insensitive (measured on 2026: - # AddCustomInfo("PROJECT") over an existing "Project" raises - # 'Duplicate key'; SetCustomByKey / RemoveCustomByKey match either - # spelling and the stored spelling is kept), so existence is - # checked casefolded — a case-sensitive check routed "PROJECT" to - # AddCustomInfo and failed mid-write after the summary fields and - # the earlier keys were already applied. + # AutoCAD matches custom keys by its own simple per-character case + # compare (measured on 2026: AddCustomInfo("PROJECT") over an + # existing "Project" is 'Duplicate key', yet "Straße" and + # "STRASSE" are two keys -- Python's casefold() merges them, and a + # routing built on it sent "STRASSE" to SetCustomByKey, which + # failed 'Key not found' after the summary fields and the earlier + # keys were already applied). So only an exact spelling is routed + # from here; for everything else AutoCAD decides: AddCustomInfo's + # 'Duplicate key' means the key is there under another spelling + # and it is set instead, RemoveCustomByKey's 'Key not found' means + # it is not there and the delete is reported as not done. No + # routing guess can then half-write. existing: set[str] = set() for index in range(int(info.NumCustomInfo())): key, _value = info.GetCustomByIndex(index) - existing.add(str(key).casefold()) + existing.add(str(key)) for field, value in written.items(): setattr(info, _SUMMARY_ATTRS[field], value) for key, value in to_write.items(): - # AddCustomInfo on an existing key raises in AutoCAD; SetCustomByKey - # on a missing one does too — route by what is really there. - if key.casefold() in existing: + if key in existing: info.SetCustomByKey(key, value) - else: + continue + try: info.AddCustomInfo(key, value) + except _COM_ERROR as exc: + if not _summaryinfo_error_is(exc, _SUMMARYINFO_DUPLICATE_KEY, "Duplicate key"): + raise + info.SetCustomByKey(key, value) deleted = [] for key in to_delete: - if key.casefold() in existing: + try: info.RemoveCustomByKey(key) - deleted.append(key) + except _COM_ERROR as exc: + if not _summaryinfo_error_is(exc, _SUMMARYINFO_KEY_NOT_FOUND, "Key not found"): + raise + continue + deleted.append(key) return { "ok": True, "summary_written": sorted(written), diff --git a/backends/contracts/settings.py b/backends/contracts/settings.py index 0321cdd..a621b59 100644 --- a/backends/contracts/settings.py +++ b/backends/contracts/settings.py @@ -525,6 +525,43 @@ SUMMARY_FIELDS = ("title", "subject", "author", "keywords", "comments") _CUSTOM_KEY_FORBIDDEN = frozenset('"*,/:;<=>?\\`|') +def custom_key_fold(key: str) -> str: + """Fold a custom-property key the way AutoCAD compares them. + + AutoCAD's ``AddCustomInfo`` / ``SetCustomByKey`` / ``RemoveCustomByKey`` + match keys by a *simple* one-to-one case compare, not Unicode full case + folding. Measured on AutoCAD 2026 (``AddCustomInfo`` of the second + spelling over the first): ``Project``/``PROJECT``, ``Grün``/``GRÜN``, + ``é``/``É``, ``ÿ``/``Ÿ``, ``ł``/``Ł``, ``σ``/``Σ``, ``я``/``Я`` and + ``dž``/``DŽ`` are the same key (``Duplicate key``), while ``Straße``/ + ``STRASSE``, ``ẞ``/``ß``, ``kelvin``/``Kelvin`` (Kelvin sign), ``µ``/``Μ``, + ``σς``/``ΣΣ`` and ``Dž``/``DŽ`` are distinct (both stored) — every one of + which Python's ``str.casefold()`` calls equal. So this folds each character + to its lowercase only when the mapping is a single character that + round-trips (``lower().upper() == upper()``, ``upper().lower() == lower()`` + and the character is one of the two); anything else — ``ß`` (uppercases + to ``SS``), the final sigma ``ς``, the micro sign ``µ``, the Kelvin sign, + the title-case digraph ``Dž``, dotless ``ı`` and dotted ``İ`` — is kept as + is. The result never changes length. This is what the headless engine and + the validator match on; the live engine lets AutoCAD decide (see + ``ComBackend.drawing_properties_set``). + """ + out: list[str] = [] + for c in key: + lo, up = c.lower(), c.upper() + if ( + len(lo) == 1 + and len(up) == 1 + and lo.upper() == up + and up.lower() == lo + and c in (lo, up) + ): + out.append(lo) + else: + out.append(c) + return "".join(out) + + def _check_custom_text(key: str, text: str, what: str) -> None: """A newline in a custom key or value corrupts the DXF on save. @@ -593,11 +630,12 @@ def validate_drawing_properties( them from the DXF. A *delete* (``None``) is exempt: ``RemoveCustomByKey`` never validates syntax, and such keys do reach drawings by other routes, so they must stay removable; - * two keys in one request that differ only by case — AutoCAD's custom keys - are case-insensitive (measured: ``AddCustomInfo("PROJECT")`` over an - existing ``Project`` raises ``Duplicate key``; ``SetCustomByKey`` / - ``RemoveCustomByKey`` match either spelling), so the second would fail - mid-write on the live engine and write a second tag headlessly; + * two keys in one request that AutoCAD would treat as the same key — its + key compare is a simple one-to-one case compare (``custom_key_fold``; + measured: ``AddCustomInfo("PROJECT")`` over an existing ``Project`` + raises ``Duplicate key``, while ``Straße`` and ``STRASSE`` are two + keys), so the second would fail mid-write on the live engine and write + a second tag headlessly; * a key or value containing a line break (LF or CR) — ezdxf emits custom properties unescaped, one header line per text, so the saved DXF is corrupt and the whole drawing fails to reopen (``DXFStructureError``), @@ -618,16 +656,17 @@ def validate_drawing_properties( raise TypeError("custom: must be an object of {key: text | null}") to_write: dict[str, str] = {} to_delete: list[str] = [] - seen: dict[str, str] = {} # casefolded key -> the spelling seen first + seen: dict[str, str] = {} # folded key (AutoCAD's rule) -> the spelling seen first for key, value in (custom or {}).items(): if not isinstance(key, str) or not key.strip(): raise TypeError(f"custom: key {key!r} must be a non-empty string") _check_custom_text(key, key, "key") - folded = key.casefold() + folded = custom_key_fold(key) if folded in seen: raise ValueError( f"custom: keys {seen[folded]!r} and {key!r} differ only by case — " - "AutoCAD's custom keys are case-insensitive (AddCustomInfo raises " + "AutoCAD compares custom keys with a simple per-character case " + "compare and calls these the same key (AddCustomInfo raises " "'Duplicate key'). Mention the key once." ) seen[folded] = key diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index de1c5b7..b17eed4 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -41,6 +41,7 @@ from .base import ( ) from .contracts.settings import ( SUMMARY_FIELDS, + custom_key_fold, parse_scale, scale_value, validate_drawing_properties, @@ -6142,24 +6143,26 @@ class EzdxfBackend(AutoCADBackend): "Save as R2004 or newer first." ) custom_vars = doc.header.custom_vars - # AutoCAD's custom keys are case-insensitive (measured on 2026: - # SetCustomByKey("PROJECT") updates an existing "Project" and keeps - # that spelling; a second AddCustomInfo is 'Duplicate key'), and - # ezdxf's CustomVars are plain case-sensitive tuples, so the match - # is done here: an existing tag is updated in place under its + # AutoCAD compares custom keys with a simple per-character case + # compare (measured on 2026: SetCustomByKey("PROJECT") updates an + # existing "Project" and keeps that spelling, a second AddCustomInfo + # is 'Duplicate key' -- but 'Straße' and 'STRASSE' are two keys, + # which str.casefold() would merge), and ezdxf's CustomVars are + # plain case-sensitive tuples, so the match is done here with + # custom_key_fold: an existing tag is updated in place under its # stored spelling, never duplicated under a second one. props = custom_vars.properties for key, value in to_write.items(): - folded = key.casefold() - hits = [i for i, (tag, _v) in enumerate(props) if tag.casefold() == folded] + folded = custom_key_fold(key) + hits = [i for i, (tag, _v) in enumerate(props) if custom_key_fold(tag) == folded] if hits: props[hits[0]] = (props[hits[0]][0], value) else: custom_vars.append(key, value) deleted = [] for key in to_delete: - folded = key.casefold() - kept = [(tag, v) for tag, v in props if tag.casefold() != folded] + folded = custom_key_fold(key) + kept = [(tag, v) for tag, v in props if custom_key_fold(tag) != folded] if len(kept) != len(props): props[:] = kept deleted.append(key) diff --git a/server.py b/server.py index de11684..7478fcb 100644 --- a/server.py +++ b/server.py @@ -6969,14 +6969,17 @@ async def drawing_properties_set( and unicode are fine — `ValueError` naming the key, on both engines; a delete is exempt because RemoveCustomByKey never validates syntax, so a key that reached the drawing another way stays removable), two keys in - one request that differ only by case (custom keys are case-insensitive in - AutoCAD), a line break in a custom key or value (it corrupts the DXF on - save), and headlessly a document older than R2004 (`ValueError` — ezdxf - only writes the custom-property header pairs for AC1018+, so they would - vanish at save; save as R2004 or newer first). A key is matched to the - drawing case-insensitively on both engines and keeps its stored spelling - when updated. On the live engine a custom key is added with AddCustomInfo - when new and changed with SetCustomByKey when present. + one request that AutoCAD would call the same key (its key compare is a + simple per-character case compare: `Project`/`PROJECT` and `Grün`/`GRÜN` + are one key, `Straße`/`STRASSE` are two), a line break in a custom key or + value (it corrupts the DXF on save), and headlessly a document older than + R2004 (`ValueError` — ezdxf only writes the custom-property header pairs + for AC1018+, so they would vanish at save; save as R2004 or newer first). + A key is matched to the drawing by that same rule on both engines and + keeps its stored spelling when updated. On the live engine an exact + spelling is changed with SetCustomByKey; otherwise AutoCAD decides — + AddCustomInfo, and on its 'Duplicate key' SetCustomByKey; a delete is + RemoveCustomByKey, and its 'Key not found' is reported as not deleted. """ summary = { "title": title, diff --git a/tests/test_drawing_properties.py b/tests/test_drawing_properties.py index 92e3bbb..b625c7c 100644 --- a/tests/test_drawing_properties.py +++ b/tests/test_drawing_properties.py @@ -14,8 +14,10 @@ Custom keys and values are validated once, by name, against what would break character through a scratch document: leading/trailing whitespace and exactly the thirteen characters `" * , / : ; < = > ? \\ ` |` anywhere in the key -- internal spaces, tabs, control characters, a no-break space and unicode are -accepted), case-variant duplicates (AutoCAD's keys are case-insensitive: -`Duplicate key`), and line breaks, which ezdxf writes unescaped and which +accepted), two keys AutoCAD would call one key (its key compare is a simple +per-character case compare -- `Project`/`PROJECT` collide with `Duplicate +key`, `Stra\u00dfe`/`STRASSE` are both stored; `custom_key_fold` pins the +measured table), and line breaks, which ezdxf writes unescaped and which corrupt the whole DXF. A delete is exempt from the AddCustomInfo syntax rules (RemoveCustomByKey never validates: `Key not found`, never `Invalid key`). """ @@ -30,6 +32,7 @@ from backends.base import UnsupportedCapabilityError from backends.contracts.settings import ( _CUSTOM_KEY_FORBIDDEN, SUMMARY_FIELDS, + custom_key_fold, validate_drawing_properties, ) @@ -136,6 +139,64 @@ def test_validation_exempts_a_delete_from_the_addcustominfo_syntax_rules(key): assert validate_drawing_properties(None, {key: None}) == ({}, {}, [key]) +# Measured on AutoCAD 2026 (AddCustomInfo of the second spelling over the +# first on a scratch Documents.Add() document, Close(False)): SAME is +# 'Duplicate key', DISTINCT is both stored. Every DISTINCT pair is equal under +# Python's str.casefold(), which is why a casefold() routing sent 'STRASSE' to +# SetCustomByKey over 'Stra\u00dfe' and failed 'Key not found' mid-write. +SAME_KEY_PAIRS = [ + ("Project", "PROJECT"), + ("Gr\u00fcn", "GR\u00dcN"), + ("\u00e9", "\u00c9"), # é / É + ("\u00ff", "\u0178"), # ÿ / Ÿ + ("\u0142", "\u0141"), # ł / Ł + ("\u03c3", "\u03a3"), # σ / Σ + ("\u044f", "\u042f"), # я / Я + ("\u01c6", "\u01c4"), # dž / DŽ + ("i", "I"), +] +# The six pairs str.casefold() merges (measured DISTINCT in AutoCAD). +CASEFOLD_MERGED_PAIRS = [ + ("Stra\u00dfe", "STRASSE"), # ß uppercases to SS + ("\u1e9e", "\u00df"), # ẞ / ß + ("kelvin", "\u212aelvin"), # k / Kelvin sign + ("\u00b5", "\u039c"), # micro sign / Greek capital mu + ("\u03c3\u03c2", "\u03a3\u03a3"), # σς / ΣΣ (final sigma) + ("\u01c5", "\u01c4"), # Dž / DŽ (title-case digraph) +] +DISTINCT_KEY_PAIRS = CASEFOLD_MERGED_PAIRS + [ + ("\u0131", "I"), # dotless ı / I (the fold is not Turkish-aware either) + ("\u0130", "i"), # dotted İ / i (lower() is two code points) +] + + +@pytest.mark.parametrize("a, b", SAME_KEY_PAIRS) +def test_custom_key_fold_merges_what_autocad_calls_one_key(a, b): + assert custom_key_fold(a) == custom_key_fold(b) + assert len(custom_key_fold(a)) == len(a) and len(custom_key_fold(b)) == len(b) + + +@pytest.mark.parametrize("a, b", DISTINCT_KEY_PAIRS) +def test_custom_key_fold_keeps_what_autocad_stores_twice(a, b): + assert custom_key_fold(a) != custom_key_fold(b) + assert len(custom_key_fold(a)) == len(a) and len(custom_key_fold(b)) == len(b) + + +@pytest.mark.parametrize("a, b", CASEFOLD_MERGED_PAIRS) +def test_casefold_is_the_wrong_rule_for_these(a, b): + """Why 4fe2d96 regressed: every one of these is casefold()-equal.""" + assert a.casefold() == b.casefold() + + +@pytest.mark.parametrize("a, b", DISTINCT_KEY_PAIRS) +def test_validation_accepts_two_keys_autocad_stores_side_by_side(a, b): + """Measured: AddCustomInfo('Stra\u00dfe') then AddCustomInfo('STRASSE') + both succeed, so refusing them as 'differ only by case' was a false + refusal with a false reason. German \u00df keys are realistic title-block + metadata.""" + assert validate_drawing_properties(None, {a: "1", b: "2"}) == ({}, {a: "1", b: "2"}, []) + + # ── headless engine ───────────────────────────────────────────────────────── @@ -249,6 +310,20 @@ async def test_custom_keys_match_case_insensitively_headlessly(backend): assert (await backend.drawing_properties_get())["custom"] == {"Project": "X-2"} +@pytest.mark.parametrize("a, b", DISTINCT_KEY_PAIRS) +async def test_headless_keeps_keys_autocad_keeps_apart(backend, a, b): + """Measured: `custom={'Stra\u00dfe': 's1'}` then `{'STRASSE': 's2'}` gave + custom={'Stra\u00dfe': 's2'} headlessly -- the first value overwritten and + no 'STRASSE' tag -- where AutoCAD holds both. Same for the Kelvin sign.""" + await backend.drawing_properties_set(custom={a: "s1"}) + res = await backend.drawing_properties_set(custom={b: "s2"}) + assert res["custom_written"] == [b] + assert list(backend._doc.header.custom_vars) == [(a, "s1"), (b, "s2")] + res = await backend.drawing_properties_set(custom={b: None}) + assert res["custom_deleted"] == [b] + assert list(backend._doc.header.custom_vars) == [(a, "s1")] + + async def _backend_for_version(tmp_path, version: str): import ezdxf @@ -386,37 +461,55 @@ class _FakeSummaryInfo: self.calls.append(("GetCustomByIndex", index)) return self._custom[index] # pywin32 hands [out] BSTR pairs back as a tuple - # Measured on AutoCAD 2026: the three mutators match keys case-insensitively, - # an existing key keeps its stored spelling, a second Add is 'Duplicate key', - # a Set/Remove of an absent key is 'Key not found'. + # Measured on AutoCAD 2026: the three mutators match keys by AutoCAD's + # simple per-character case compare (`custom_key_fold` pins the table -- + # 'PROJECT' finds 'Project', 'STRASSE' does not find 'Stra\u00dfe'), an + # existing key keeps its stored spelling, a second Add is 'Duplicate key' + # (scode -2145386475), a Set/Remove of an absent key is 'Key not found' + # (scode -2145386476), and a key AddCustomInfo rejects is 'Invalid key' + # (scode -2145386465). The com_error shape is the one pywin32 raised live: + # a localised HRESULT text and the English description in the excepinfo. def _index(self, key): for i, (k, _v) in enumerate(self._custom): - if k.casefold() == key.casefold(): + if custom_key_fold(k) == custom_key_fold(key): return i return None def AddCustomInfo(self, key, value): self.calls.append(("AddCustomInfo", key, value)) + if set(key) & _CUSTOM_KEY_FORBIDDEN: + raise _com_error("Invalid key", -2145386465) if self._index(key) is not None: - raise RuntimeError("AutoCAD COM error: Duplicate key") + raise _com_error("Duplicate key", -2145386475) self._custom.append((key, value)) def SetCustomByKey(self, key, value): self.calls.append(("SetCustomByKey", key, value)) i = self._index(key) if i is None: - raise RuntimeError("AutoCAD COM error: Key not found") + raise _com_error("Key not found", -2145386476) self._custom[i] = (self._custom[i][0], value) def RemoveCustomByKey(self, key): self.calls.append(("RemoveCustomByKey", key)) i = self._index(key) if i is None: - raise RuntimeError("AutoCAD COM error: Key not found") + raise _com_error("Key not found", -2145386476) del self._custom[i] +def _com_error(description, scode): + import pywintypes + + return pywintypes.com_error( + -2147352567, + "\u00d6zel durum olu\u015ftu.", + (0, "AutoCAD.Application", description, "OLE_ERR.CHM", scode, scode), + None, + ) + + @pytest.fixture def com_backend(monkeypatch): pytest.importorskip("win32com.client", reason="pywin32 not installed") @@ -466,10 +559,12 @@ async def test_com_set_writes_summary_and_routes_custom_by_existence(com_backend assert info.Title == "Gearbox" and info.Author == "U. Can" and info.Keywords == "gear" mutations = [c for c in info.calls if c[0] not in ("NumCustomInfo", "GetCustomByIndex")] assert mutations == [ - ("SetCustomByKey", "PROJECT", "X-2"), # existed → set + ("SetCustomByKey", "PROJECT", "X-2"), # exact spelling present → set ("AddCustomInfo", "REV", "B"), # new → add - ("RemoveCustomByKey", "OLD"), # existed → remove; NOPE never touched + ("RemoveCustomByKey", "OLD"), # removed + ("RemoveCustomByKey", "NOPE"), # 'Key not found' → not reported deleted ] + assert info._custom == [("PROJECT", "X-2"), ("REV", "B")] async def test_com_validates_before_touching_activex(com_backend): @@ -512,22 +607,91 @@ async def test_com_deletes_a_legacy_invalid_syntax_key(com_backend): assert info._custom == [("PROJECT", "X-1"), ("OLD", "1")] -async def test_com_routes_case_variant_keys_to_set_and_remove(com_backend): - """Measured on AutoCAD 2026: AddCustomInfo('PROJECT') over an existing - 'Project' raises 'Duplicate key' -- a case-sensitive existence check - routed it there and failed mid-write. Existence is matched casefolded.""" +async def test_com_lets_autocad_route_a_case_variant_key(com_backend): + """Measured on AutoCAD 2026: AddCustomInfo('project') over an existing + 'PROJECT' raises 'Duplicate key' -- a case-sensitive existence check + routed it there and failed mid-write; a casefold() check then routed + 'STRASSE' over 'Stra\u00dfe' to SetCustomByKey and failed the same way. + Now only an exact spelling is routed here; otherwise AutoCAD's own + compare decides: 'Duplicate key' falls back to SetCustomByKey.""" backend, info = com_backend res = await backend.drawing_properties_set(custom={"project": "X-2", "old": None, "New": "n"}) assert res["custom_written"] == ["New", "project"] and res["custom_deleted"] == ["old"] mutations = [c for c in info.calls if c[0] not in ("NumCustomInfo", "GetCustomByIndex")] assert mutations == [ - ("SetCustomByKey", "project", "X-2"), + ("AddCustomInfo", "project", "X-2"), # AutoCAD: 'Duplicate key' + ("SetCustomByKey", "project", "X-2"), # → set under the stored spelling ("AddCustomInfo", "New", "n"), ("RemoveCustomByKey", "old"), ] assert info._custom == [("PROJECT", "X-2"), ("New", "n")], "stored spelling kept" +@pytest.mark.parametrize("stored, written", DISTINCT_KEY_PAIRS) +async def test_com_writes_a_key_autocad_keeps_apart_without_half_writing( + com_backend, stored, written +): + """Measured live: with 'Stra\u00dfe' present, `summary={'subject': 'HALF?'}, + custom={'BEFORE': 'b', 'STRASSE': 's2', 'AFTER': 'never?'}` routed + 'STRASSE' to SetCustomByKey on the casefold() match, AutoCAD answered + 'Key not found', and the request raised with subject and BEFORE applied + and AFTER never written. AddCustomInfo of both spellings succeeds in + AutoCAD; the route must not be guessed from a Python fold.""" + backend, info = com_backend + info._custom.append((stored, "s1")) + # A delete of the other spelling is 'Key not found' in AutoCAD (measured: + # RuntimeError after the request's writes were applied): reported as not + # deleted, no error, and the rest of the request still lands. + res = await backend.drawing_properties_set(custom={written: None, "OLD": None}) + assert res["custom_deleted"] == ["OLD"] and res["ok"] is True + assert info._custom == [("PROJECT", "X-1"), (stored, "s1")] + res = await backend.drawing_properties_set( + summary={"subject": "HALF?"}, + custom={"BEFORE": "b", written: "s2", "AFTER": "never?"}, + ) + assert res["custom_written"] == sorted(["BEFORE", written, "AFTER"]) + assert info.Subject == "HALF?" + assert info._custom == [ + ("PROJECT", "X-1"), + (stored, "s1"), + ("BEFORE", "b"), + (written, "s2"), + ("AFTER", "never?"), + ] + assert ("SetCustomByKey", written, "s2") not in info.calls + # Each spelling is now its own key: deleting one keeps the other. + res = await backend.drawing_properties_set(custom={written: None}) + assert res["custom_deleted"] == [written] + assert (written, "s2") not in info._custom and (stored, "s1") in info._custom + + +async def test_com_propagates_an_unrelated_summaryinfo_failure(com_backend): + """Only 'Duplicate key' is answered with SetCustomByKey and only 'Key not + found' is swallowed on a delete; any other COM failure keeps raising (the + fixture runs the COM callable inline, so the raw com_error is what + surfaces here; `_run` wraps it in RuntimeError on the real path).""" + import pywintypes + + backend, info = com_backend + + def _boom(key, value): + info.calls.append(("AddCustomInfo", key, value)) + raise _com_error("Some other failure", -2145386400) + + info.AddCustomInfo = _boom + with pytest.raises(pywintypes.com_error, match="Some other failure"): + await backend.drawing_properties_set(custom={"NEW": "x"}) + assert ("SetCustomByKey", "NEW", "x") not in info.calls + + def _boom_remove(key): + info.calls.append(("RemoveCustomByKey", key)) + raise _com_error("Some other failure", -2145386400) + + info.RemoveCustomByKey = _boom_remove + with pytest.raises(pywintypes.com_error, match="Some other failure"): + await backend.drawing_properties_set(custom={"OLD": None}) + + async def test_com_refuses_autocad_invalid_keys_before_any_write(com_backend): """Measured on AutoCAD 2026: `summary={'subject': 'PARTIAL?'}, custom={'AAA': 'first', ' BAD ': 'x', 'ZZZ': 'never'}` raised an unnamed