fix(task-16): custom keys mirror AutoCAD's full AddCustomInfo rule, deletes are exempt, keys match case-insensitively

The 2b9791f fix mirrored only the characters the first review happened to
measure ('=' and ';'), so `custom={'AAA': 'first', 'A:B': 'x', 'ZZZ': 'never'}`
still raised from AddCustomInfo('A:B') on the live engine after subject and
AAA were applied -- the same half-write for 11 other keys. Re-measured on
AutoCAD 2026 by sweeping every character 0x20-0x7E through a scratch
document: exactly thirteen printable characters are 'Invalid key' anywhere
in a key (" * , / : ; < = > ? \ ` |); control characters, internal spaces,
a no-break space and unicode are accepted; leading/trailing whitespace
(space, tab, NBSP) is refused. `_CUSTOM_KEY_FORBIDDEN` is now that set,
the internal-NBSP refusal (wrong: measured accepted) is gone, and the
validator / tool docstrings state the measured rule instead of a closed
enumeration that was false.

A delete (`{key: None}`) is exempt from the AddCustomInfo syntax rules:
RemoveCustomByKey never validates syntax (measured: 'Key not found', never
'Invalid key'), and 2b9791f ran the check before the None branch, so a key
an ezdxf script wrote ('A=B', ' PAD ') showed in drawing_properties_get and
could never be removed. The line-break rule still applies both ways.

Also measured: custom keys are case-insensitive in AutoCAD (AddCustomInfo
over an existing case-variant is 'Duplicate key'; SetCustomByKey and
RemoveCustomByKey match either spelling and keep the stored one). The COM
routing was case-sensitive, so `{'PROJECT': ...}` over 'Project' went to
AddCustomInfo and failed mid-write -- the same class. Both engines now
match existing keys casefolded (update in place under the stored spelling,
never a second tag), and a request naming two case-variants is refused.

Tests: parametrized validator sweep over all thirteen characters, the
accepted set (control chars, NBSP, unicode, the other printables), the
delete exemption headlessly on a DXF carrying such keys and on the fake
SummaryInfo, case-variant refusal, and case-insensitive routing against a
fake that raises 'Duplicate key' / 'Key not found' like AutoCAD. Confirmed
live through the real ComBackend on a scratch document (Close(False)).

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-21 21:37:44 +03:00
co-authored by Claude Opus 5
parent 2b9791fbcd
commit 4fe2d963c0
5 changed files with 277 additions and 46 deletions
+10 -3
View File
@@ -4293,22 +4293,29 @@ class ComBackend(AutoCADBackend):
def _sync(): def _sync():
info = _acad_doc().SummaryInfo 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.
existing: set[str] = set() existing: set[str] = set()
for index in range(int(info.NumCustomInfo())): for index in range(int(info.NumCustomInfo())):
key, _value = info.GetCustomByIndex(index) key, _value = info.GetCustomByIndex(index)
existing.add(str(key)) existing.add(str(key).casefold())
for field, value in written.items(): for field, value in written.items():
setattr(info, _SUMMARY_ATTRS[field], value) setattr(info, _SUMMARY_ATTRS[field], value)
for key, value in to_write.items(): for key, value in to_write.items():
# AddCustomInfo on an existing key raises in AutoCAD; SetCustomByKey # AddCustomInfo on an existing key raises in AutoCAD; SetCustomByKey
# on a missing one does too — route by what is really there. # on a missing one does too — route by what is really there.
if key in existing: if key.casefold() in existing:
info.SetCustomByKey(key, value) info.SetCustomByKey(key, value)
else: else:
info.AddCustomInfo(key, value) info.AddCustomInfo(key, value)
deleted = [] deleted = []
for key in to_delete: for key in to_delete:
if key in existing: if key.casefold() in existing:
info.RemoveCustomByKey(key) info.RemoveCustomByKey(key)
deleted.append(key) deleted.append(key)
return { return {
+52 -23
View File
@@ -515,10 +515,14 @@ def _decode_setting(key: str, kind: str, raw: Any) -> Any:
SUMMARY_FIELDS = ("title", "subject", "author", "keywords", "comments") SUMMARY_FIELDS = ("title", "subject", "author", "keywords", "comments")
#: Characters AutoCAD's ``SummaryInfo.AddCustomInfo`` rejects inside a custom #: The printable characters AutoCAD's ``SummaryInfo.AddCustomInfo`` rejects
#: key with ``Invalid key`` (measured on AutoCAD 2026; internal spaces, tabs #: anywhere inside a custom key with ``Invalid key``. Measured on AutoCAD 2026
#: and other unicode are accepted). ``\xa0`` is the no-break space. #: by sweeping every character 0x20-0x7E through ``AddCustomInfo`` on a scratch
_CUSTOM_KEY_FORBIDDEN = {"=": "'='", ";": "';'", "\xa0": "a no-break space (U+00A0)"} #: document: exactly these thirteen are refused; every other printable ASCII
#: character, every control character (0x01-0x1F, including tab), internal
#: spaces, a no-break space and other unicode are accepted. Leading or
#: trailing whitespace (space, tab or no-break space) is refused separately.
_CUSTOM_KEY_FORBIDDEN = frozenset('"*,/:;<=>?\\`|')
def _check_custom_text(key: str, text: str, what: str) -> None: def _check_custom_text(key: str, text: str, what: str) -> None:
@@ -538,24 +542,30 @@ def _check_custom_text(key: str, text: str, what: str) -> None:
) )
def _check_custom_key(key: str) -> None: def _check_custom_key_for_write(key: str) -> None:
"""Mirror AutoCAD's own custom-key rules so the live engine cannot refuse """Mirror AutoCAD's ``AddCustomInfo`` key syntax so the live engine cannot
mid-write. Measured: ``AddCustomInfo`` raises ``Invalid key`` for a key refuse mid-write. Measured on AutoCAD 2026: ``Invalid key`` for a key with
with leading/trailing whitespace and for ``=``, ``;`` or a no-break space leading/trailing whitespace (space, tab, no-break space) or any of
anywhere in it — after the summary fields and earlier keys were already ``_CUSTOM_KEY_FORBIDDEN`` anywhere in it — raised after the summary fields
applied — while the headless engine happily writes the same key.""" and the earlier keys were already applied, while the headless engine
writes the same key without complaint. Only a *write* goes through
``AddCustomInfo``; ``RemoveCustomByKey`` does not validate syntax (measured:
``Key not found``, never ``Invalid key``), so a delete is not held to these
rules — a key that reached the drawing by other means (a DXF written by
ezdxf, say) must stay removable."""
if key != key.strip(): if key != key.strip():
raise ValueError( raise ValueError(
f"custom: key {key!r} has leading or trailing whitespace — AutoCAD's " f"custom: key {key!r} has leading or trailing whitespace — AutoCAD's "
"AddCustomInfo rejects it as 'Invalid key'. Strip the key." "AddCustomInfo rejects it as 'Invalid key'. Strip the key."
) )
for char, name in _CUSTOM_KEY_FORBIDDEN.items(): bad = sorted(set(key) & _CUSTOM_KEY_FORBIDDEN)
if char in key: if bad:
raise ValueError( shown = " ".join(repr(c) for c in bad)
f"custom: key {key!r} contains {name} — AutoCAD's AddCustomInfo rejects " raise ValueError(
"it as 'Invalid key'. Use another separator." f"custom: key {key!r} contains {shown} — AutoCAD's AddCustomInfo rejects "
) f"it as 'Invalid key' (refused characters: {''.join(sorted(_CUSTOM_KEY_FORBIDDEN))}). "
_check_custom_text(key, key, "key") "Use another separator."
)
def validate_drawing_properties( def validate_drawing_properties(
@@ -574,11 +584,20 @@ def validate_drawing_properties(
enforce or the file format would break on, so the same call cannot succeed enforce or the file format would break on, so the same call cannot succeed
on one engine and half-apply on the other (``ValueError`` naming the key): on one engine and half-apply on the other (``ValueError`` naming the key):
* a key with leading or trailing whitespace, or containing ``=``, ``;`` or * a key to *write* with leading or trailing whitespace, or containing any
a no-break space — AutoCAD's ``AddCustomInfo`` rejects these as of the thirteen characters ``" * , / : ; < = > ? \\ ` |`` — AutoCAD's
``Invalid key`` mid-write (measured on AutoCAD 2026), after the summary ``AddCustomInfo`` rejects exactly these as ``Invalid key`` mid-write
fields and the earlier keys were already applied; the headless engine (measured on AutoCAD 2026 over every printable ASCII character), after
writes them without complaint, and AutoCAD then loads them from the DXF; the summary fields and the earlier keys were already applied; the
headless engine writes them without complaint, and AutoCAD then loads
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;
* a key or value containing a line break (LF or CR) — ezdxf emits custom * 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 properties unescaped, one header line per text, so the saved DXF is
corrupt and the whole drawing fails to reopen (``DXFStructureError``), corrupt and the whole drawing fails to reopen (``DXFStructureError``),
@@ -599,13 +618,23 @@ def validate_drawing_properties(
raise TypeError("custom: must be an object of {key: text | null}") raise TypeError("custom: must be an object of {key: text | null}")
to_write: dict[str, str] = {} to_write: dict[str, str] = {}
to_delete: list[str] = [] to_delete: list[str] = []
seen: dict[str, str] = {} # casefolded key -> the spelling seen first
for key, value in (custom or {}).items(): for key, value in (custom or {}).items():
if not isinstance(key, str) or not key.strip(): if not isinstance(key, str) or not key.strip():
raise TypeError(f"custom: key {key!r} must be a non-empty string") raise TypeError(f"custom: key {key!r} must be a non-empty string")
_check_custom_key(key) _check_custom_text(key, key, "key")
folded = key.casefold()
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 "
"'Duplicate key'). Mention the key once."
)
seen[folded] = key
if value is None: if value is None:
to_delete.append(key) to_delete.append(key)
elif isinstance(value, str): elif isinstance(value, str):
_check_custom_key_for_write(key)
_check_custom_text(key, value, "value") _check_custom_text(key, value, "value")
to_write[key] = value to_write[key] = value
else: else:
+15 -4
View File
@@ -6142,15 +6142,26 @@ class EzdxfBackend(AutoCADBackend):
"Save as R2004 or newer first." "Save as R2004 or newer first."
) )
custom_vars = doc.header.custom_vars 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
# stored spelling, never duplicated under a second one.
props = custom_vars.properties
for key, value in to_write.items(): for key, value in to_write.items():
if custom_vars.has_tag(key): folded = key.casefold()
custom_vars.replace(key, value) hits = [i for i, (tag, _v) in enumerate(props) if tag.casefold() == folded]
if hits:
props[hits[0]] = (props[hits[0]][0], value)
else: else:
custom_vars.append(key, value) custom_vars.append(key, value)
deleted = [] deleted = []
for key in to_delete: for key in to_delete:
if custom_vars.has_tag(key): folded = key.casefold()
custom_vars.remove(key, all=True) kept = [(tag, v) for tag, v in props if tag.casefold() != folded]
if len(kept) != len(props):
props[:] = kept
deleted.append(key) deleted.append(key)
if to_write or deleted: if to_write or deleted:
self._mark_dirty() self._mark_dirty()
+14 -7
View File
@@ -6962,13 +6962,20 @@ async def drawing_properties_set(
engine (`capability: dwgprops` — SummaryInfo lives in the DWG, a DXF has engine (`capability: dwgprops` — SummaryInfo lives in the DWG, a DXF has
no slot for it; custom properties still work headlessly), a non-string no slot for it; custom properties still work headlessly), a non-string
value (`TypeError` naming the field or key — nothing is coerced with value (`TypeError` naming the field or key — nothing is coerced with
`str()`), an empty custom key, a custom key AutoCAD's AddCustomInfo would `str()`), an empty custom key, a custom key to *write* that AutoCAD's
reject mid-write (leading/trailing whitespace, `=`, `;`, no-break space — AddCustomInfo would reject mid-write (measured on AutoCAD 2026: leading or
`ValueError` naming the key, on both engines), a line break in a custom trailing whitespace, or any of the thirteen characters
key or value (it corrupts the DXF on save), and headlessly a document `" * , / : ; < = > ? \\ ` |` anywhere in the key; internal spaces, tabs
older than R2004 (`ValueError` — ezdxf only writes the custom-property and unicode are fine — `ValueError` naming the key, on both engines; a
header pairs for AC1018+, so they would vanish at save; save as R2004 or delete is exempt because RemoveCustomByKey never validates syntax, so a
newer first). On the live engine a custom key is added with AddCustomInfo 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. when new and changed with SetCustomByKey when present.
""" """
summary = { summary = {
+186 -9
View File
@@ -10,9 +10,14 @@ instead of losing the write at save).
Custom keys and values are validated once, by name, against what would break Custom keys and values are validated once, by name, against what would break
*either* engine: keys AutoCAD's AddCustomInfo rejects mid-write as *either* engine: keys AutoCAD's AddCustomInfo rejects mid-write as
`Invalid key` (measured on AutoCAD 2026: leading/trailing whitespace, `=`, `Invalid key` (measured on AutoCAD 2026 by sweeping every printable ASCII
`;`, no-break space) and line breaks, which ezdxf writes unescaped and character through a scratch document: leading/trailing whitespace and exactly
which corrupt the whole DXF. 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
corrupt the whole DXF. A delete is exempt from the AddCustomInfo syntax rules
(RemoveCustomByKey never validates: `Key not found`, never `Invalid key`).
""" """
from __future__ import annotations from __future__ import annotations
@@ -22,7 +27,11 @@ import types
import pytest import pytest
from backends.base import UnsupportedCapabilityError from backends.base import UnsupportedCapabilityError
from backends.contracts.settings import SUMMARY_FIELDS, validate_drawing_properties from backends.contracts.settings import (
_CUSTOM_KEY_FORBIDDEN,
SUMMARY_FIELDS,
validate_drawing_properties,
)
pytestmark = pytest.mark.asyncio pytestmark = pytest.mark.asyncio
@@ -55,9 +64,15 @@ def test_validation_splits_writes_and_deletes():
# mid-write after the summary and earlier keys were already applied. # mid-write after the summary and earlier keys were already applied.
(None, {" BAD ": "x"}, ValueError, "' BAD '"), (None, {" BAD ": "x"}, ValueError, "' BAD '"),
(None, {"PROJECT ": "x"}, ValueError, "trailing whitespace"), (None, {"PROJECT ": "x"}, ValueError, "trailing whitespace"),
(None, {"\tTAB": "x"}, ValueError, "leading or trailing whitespace"),
(None, {"NB\xa0": "x"}, ValueError, "leading or trailing whitespace"),
(None, {"A=B": "x"}, ValueError, "'A=B' contains '='"), (None, {"A=B": "x"}, ValueError, "'A=B' contains '='"),
(None, {"A;B": "x"}, ValueError, "'A;B' contains ';'"), (None, {"A;B": "x"}, ValueError, "'A;B' contains ';'"),
(None, {"NB\xa0SP": "x"}, ValueError, "no-break space"), (None, {"A:B/C": "x"}, ValueError, "'A:B/C' contains '/' ':'"),
# Case-variant duplicates: AutoCAD raises 'Duplicate key' on the second.
(None, {"Project": "a", "PROJECT": "b"}, ValueError, "differ only by case"),
(None, {"Project": "a", "PROJECT": None}, ValueError, "differ only by case"),
(None, {"Gr\u00fcn": "a", "GR\u00dcN": "b"}, ValueError, "differ only by case"),
# A line break is written unescaped by ezdxf and corrupts the DXF. # A line break is written unescaped by ezdxf and corrupts the DXF.
(None, {"NOTE": "line1\nline2"}, ValueError, "custom['NOTE']: the value"), (None, {"NOTE": "line1\nline2"}, ValueError, "custom['NOTE']: the value"),
(None, {"NOTE": "line1\rline2"}, ValueError, "line break"), (None, {"NOTE": "line1\rline2"}, ValueError, "line break"),
@@ -71,13 +86,56 @@ def test_validation_refuses_by_name(summary, custom, exc, fragment):
assert fragment in str(excinfo.value) assert fragment in str(excinfo.value)
@pytest.mark.parametrize("key", ["IN SP", "T\tAB", "\u00dcn\u00efcode", "X" * 300]) INVALID_KEY_CHARS = '"*,/:;<=>?\\`|'
def test_forbidden_set_is_exactly_the_thirteen_measured_characters():
assert _CUSTOM_KEY_FORBIDDEN == frozenset(INVALID_KEY_CHARS)
assert len(_CUSTOM_KEY_FORBIDDEN) == 13
@pytest.mark.parametrize("char", sorted(INVALID_KEY_CHARS))
def test_validation_refuses_every_addcustominfo_invalid_key_character(char):
"""Measured on AutoCAD 2026 (`AddCustomInfo(f"A{c}B", "v")` for every
c in 0x20-0x7E on a scratch document): exactly these thirteen raise
`Invalid key`. The earlier fix mirrored only `=` and `;`, so 'A:B',
'REV/2', 'SIZE, mm', 'A"B' ... still half-applied on the live engine."""
key = f"A{char}B"
with pytest.raises(ValueError, match="Invalid key") as excinfo:
validate_drawing_properties(None, {key: "x"})
assert repr(key) in str(excinfo.value) and repr(char) in str(excinfo.value)
@pytest.mark.parametrize(
"key",
[
"IN SP",
"T\tAB",
"\u00dcn\u00efcode",
"X" * 300,
"NB\xa0SP",
"A\x01B",
"A\x1fB",
"A!#$%&'()+-.@[]^_{}~B",
"\u4e2d\u2014\u200b",
],
)
def test_validation_accepts_keys_autocad_accepts(key): def test_validation_accepts_keys_autocad_accepts(key):
"""Measured: internal spaces, tabs, unicode and 300-char keys pass """Measured: internal spaces, tabs, control characters, a no-break space,
unicode, every other printable ASCII character and 300-char keys pass
AddCustomInfo; the validator must not be stricter than AutoCAD.""" AddCustomInfo; the validator must not be stricter than AutoCAD."""
assert validate_drawing_properties(None, {key: "v"}) == ({}, {key: "v"}, []) assert validate_drawing_properties(None, {key: "v"}) == ({}, {key: "v"}, [])
@pytest.mark.parametrize("key", ["A=B", " PAD ", "A:B", "NB\xa0", 'A"B'])
def test_validation_exempts_a_delete_from_the_addcustominfo_syntax_rules(key):
"""A delete never calls AddCustomInfo, and RemoveCustomByKey does not
validate syntax (measured: 'Key not found', never 'Invalid key'), so a key
a DXF script wrote must stay removable. The line-break rule still holds
for both directions (a delete of such a key is pinned refused above)."""
assert validate_drawing_properties(None, {key: None}) == ({}, {}, [key])
# ── headless engine ───────────────────────────────────────────────────────── # ── headless engine ─────────────────────────────────────────────────────────
@@ -137,6 +195,60 @@ async def test_autocad_invalid_keys_are_refused_headlessly_too(backend):
assert (await backend.drawing_properties_get())["custom"] == {} assert (await backend.drawing_properties_get())["custom"] == {}
async def test_legacy_invalid_syntax_keys_stay_deletable_headlessly(tmp_path):
"""Regression of 2b9791f: `_check_custom_key` ran before the `value is
None` branch, so `{'A=B': None}` on a document carrying 'A=B' (any ezdxf
script writes such keys; AutoCAD loads them from the DXF) was refused with
a reason that only applies to AddCustomInfo -- the tool showed a key it
could never remove."""
import ezdxf
from backends.ezdxf_backend import EzdxfBackend
path = tmp_path / "legacy.dxf"
doc = ezdxf.new("R2010")
for tag in ("A=B", "NB\xa0SP", " PAD ", "A:B"):
doc.header.custom_vars.append(tag, "legacy")
doc.saveas(str(path))
backend = EzdxfBackend()
await backend.connect()
try:
await backend.drawing_open(str(path))
assert (await backend.drawing_properties_get())["custom"] == {
"A=B": "legacy",
"NB\xa0SP": "legacy",
" PAD ": "legacy",
"A:B": "legacy",
}
res = await backend.drawing_properties_set(custom={"A=B": None, " PAD ": None})
assert res["custom_deleted"] == ["A=B", " PAD "]
assert (await backend.drawing_properties_get())["custom"] == {
"NB\xa0SP": "legacy",
"A:B": "legacy",
}
# ...but writing such a key is still refused, before anything is touched.
with pytest.raises(ValueError, match="'A:B' contains ':'"):
await backend.drawing_properties_set(custom={"A:B": "new", "OK": "x"})
assert (await backend.drawing_properties_get())["custom"] == {
"NB\xa0SP": "legacy",
"A:B": "legacy",
}
finally:
await backend.disconnect()
async def test_custom_keys_match_case_insensitively_headlessly(backend):
"""Mirrors AutoCAD (measured on 2026): a write to 'PROJECT' over an
existing 'Project' updates it under the stored spelling instead of adding
a second tag AutoCAD would call a duplicate; a delete matches either
spelling."""
await backend.drawing_properties_set(custom={"Project": "X-1", "Rev": "A"})
res = await backend.drawing_properties_set(custom={"PROJECT": "X-2", "rev": None})
assert res["custom_written"] == ["PROJECT"] and res["custom_deleted"] == ["rev"]
assert list(backend._doc.header.custom_vars) == [("Project", "X-2")]
assert (await backend.drawing_properties_get())["custom"] == {"Project": "X-2"}
async def _backend_for_version(tmp_path, version: str): async def _backend_for_version(tmp_path, version: str):
import ezdxf import ezdxf
@@ -274,17 +386,35 @@ class _FakeSummaryInfo:
self.calls.append(("GetCustomByIndex", index)) self.calls.append(("GetCustomByIndex", index))
return self._custom[index] # pywin32 hands [out] BSTR pairs back as a tuple 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'.
def _index(self, key):
for i, (k, _v) in enumerate(self._custom):
if k.casefold() == key.casefold():
return i
return None
def AddCustomInfo(self, key, value): def AddCustomInfo(self, key, value):
self.calls.append(("AddCustomInfo", key, value)) self.calls.append(("AddCustomInfo", key, value))
if self._index(key) is not None:
raise RuntimeError("AutoCAD COM error: Duplicate key")
self._custom.append((key, value)) self._custom.append((key, value))
def SetCustomByKey(self, key, value): def SetCustomByKey(self, key, value):
self.calls.append(("SetCustomByKey", key, value)) self.calls.append(("SetCustomByKey", key, value))
self._custom = [(k, value if k == key else v) for k, v in self._custom] i = self._index(key)
if i is None:
raise RuntimeError("AutoCAD COM error: Key not found")
self._custom[i] = (self._custom[i][0], value)
def RemoveCustomByKey(self, key): def RemoveCustomByKey(self, key):
self.calls.append(("RemoveCustomByKey", key)) self.calls.append(("RemoveCustomByKey", key))
self._custom = [(k, v) for k, v in self._custom if k != key] i = self._index(key)
if i is None:
raise RuntimeError("AutoCAD COM error: Key not found")
del self._custom[i]
@pytest.fixture @pytest.fixture
@@ -351,6 +481,53 @@ async def test_com_validates_before_touching_activex(com_backend):
assert info.calls == [] and info.Title == "Old title" assert info.calls == [] and info.Title == "Old title"
@pytest.mark.parametrize(
"key", ["A:B", "REV/2", "SIZE, mm", 'A"B', "A\\B", "A<B>", "A|B", "A*B", "A?B"]
)
async def test_com_refuses_every_addcustominfo_invalid_key_before_any_write(com_backend, key):
"""Re-measured on AutoCAD 2026 after 2b9791f: `summary={'subject':
'PARTIAL?'}, custom={'AAA': 'first', 'A:B': 'x', 'ZZZ': 'never'}` still
raised from AddCustomInfo('A:B') and left subject='PARTIAL?',
custom={'AAA': 'first'} -- the same half-write for every character the
first fix did not mirror. Nothing reaches SummaryInfo now."""
backend, info = com_backend
with pytest.raises(ValueError, match="Invalid key"):
await backend.drawing_properties_set(
summary={"subject": "PARTIAL?"},
custom={"AAA": "first", key: "x", "ZZZ": "never"},
)
assert info.calls == [] and info.Subject == ""
assert info._custom == [("PROJECT", "X-1"), ("OLD", "1")]
async def test_com_deletes_a_legacy_invalid_syntax_key(com_backend):
"""RemoveCustomByKey does not validate syntax (measured: 'Key not found'
for an absent 'A=B', never 'Invalid key'), so a key AutoCAD loaded from a
DXF is removed, not refused."""
backend, info = com_backend
info._custom.append(("A=B", "legacy"))
res = await backend.drawing_properties_set(custom={"A=B": None})
assert res["custom_deleted"] == ["A=B"]
assert ("RemoveCustomByKey", "A=B") in info.calls
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."""
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", "New", "n"),
("RemoveCustomByKey", "old"),
]
assert info._custom == [("PROJECT", "X-2"), ("New", "n")], "stored spelling kept"
async def test_com_refuses_autocad_invalid_keys_before_any_write(com_backend): async def test_com_refuses_autocad_invalid_keys_before_any_write(com_backend):
"""Measured on AutoCAD 2026: `summary={'subject': 'PARTIAL?'}, """Measured on AutoCAD 2026: `summary={'subject': 'PARTIAL?'},
custom={'AAA': 'first', ' BAD ': 'x', 'ZZZ': 'never'}` raised an unnamed custom={'AAA': 'first', ' BAD ': 'x', 'ZZZ': 'never'}` raised an unnamed