fix(task-16): custom keys mirror AutoCAD's full AddCustomInfo rule, deletes are exempt, keys match case-insensitively
The2b9791ffix 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'), and2b9791fran 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:
co-authored by
Claude Opus 5
parent
2b9791fbcd
commit
4fe2d963c0
+10
-3
@@ -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 {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user