fix(task-16): custom keys follow AutoCAD's simple case compare, and on COM AutoCAD routes the write
4fe2d96matched custom keys with str.casefold(), but AutoCAD's key compare is a simple one-to-one case compare, not Unicode full case folding. Measured on AutoCAD 2026 (AddCustomInfo of the second spelling over the first on a scratch document, Close(False)): Project/PROJECT, Grün/GRÜN, é/É, ÿ/Ÿ, ł/Ł, σ/Σ, я/Я and dž/DŽ are one key ('Duplicate key'), while Straße/STRASSE, ẞ/ß, kelvin/Kelvin-sign, µ/Μ, σς/ΣΣ and Dž/DŽ are two keys (both stored) -- every one of those six is casefold()-equal. So with 'Straße' present, `custom={'BEFORE': 'b', 'STRASSE': 's2', 'AFTER': ...}` was routed to SetCustomByKey, AutoCAD answered 'Key not found', and the request raised with subject and BEFORE applied and AFTER never written -- the half-write class the fix claimed to close; a delete of 'STRASSE' over 'Straße' raised the same way; headlessly 'STRASSE' overwrote the 'Straße' value instead of becoming a second tag; and the validator refused {'Straße', 'STRASSE'} with a reason AutoCAD contradicts. COM no longer routes from a Python approximation. An exact spelling goes to SetCustomByKey; anything else is AddCustomInfo, and its 'Duplicate key' (excepinfo scode -2145386475, measured) falls back to SetCustomByKey. A delete is RemoveCustomByKey, and its 'Key not found' (scode -2145386476) is reported as not deleted. Any other com_error keeps propagating. The scode is matched before the English description because the HRESULT text is localised. The headless engine and the validator match on `custom_key_fold`: a per-character fold to lowercase only when lower()/upper() are single characters that round-trip and the character is one of the two; it never changes string length and reproduces every measured pair (ß, ς, µ, the Kelvin sign, Dž, ı and İ stay as they are). Docstrings in settings.py and server.py now state that rule instead of 'case-insensitive'. Tests: parametrized fold over the measured SAME / DISTINCT table (plus a pin that casefold() merges the six DISTINCT pairs, which is why4fe2d96regressed), validator acceptance of each DISTINCT pair, headless side-by-side storage and deletion of each pair, the fake SummaryInfo now matches by the same fold and raises pywintypes.com_error with the measured excepinfo tuples, the COM half-write scenario for each pair (no SetCustomByKey guess, 'Key not found' delete tolerated, rest of the request lands), the 'Duplicate key' fallback, and an unrelated SummaryInfo failure still raising. The scodes were measured live before the change; AutoCAD stopped answering COM (blocked in another agent's command or a modal dialog) before the post-change re-run, so that run is recorded as not done. 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
4fe2d963c0
commit
dda11755cf
+47
-14
@@ -199,6 +199,27 @@ _BUILTIN_LINETYPES = {"continuous", "bylayer", "byblock"}
|
|||||||
#: `drawing_properties_*` field → `IAcadSummaryInfo` property.
|
#: `drawing_properties_*` field → `IAcadSummaryInfo` property.
|
||||||
_SUMMARY_ATTRS = {field: field.capitalize() for field in SUMMARY_FIELDS}
|
_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:
|
def _ensure_linetype_loaded(name: str) -> None:
|
||||||
"""If `name` is not already loaded, load it via -LINETYPE behind FILEDIA=0.
|
"""If `name` is not already loaded, load it via -LINETYPE behind FILEDIA=0.
|
||||||
@@ -4293,31 +4314,43 @@ 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:
|
# AutoCAD matches custom keys by its own simple per-character case
|
||||||
# AddCustomInfo("PROJECT") over an existing "Project" raises
|
# compare (measured on 2026: AddCustomInfo("PROJECT") over an
|
||||||
# 'Duplicate key'; SetCustomByKey / RemoveCustomByKey match either
|
# existing "Project" is 'Duplicate key', yet "Straße" and
|
||||||
# spelling and the stored spelling is kept), so existence is
|
# "STRASSE" are two keys -- Python's casefold() merges them, and a
|
||||||
# checked casefolded — a case-sensitive check routed "PROJECT" to
|
# routing built on it sent "STRASSE" to SetCustomByKey, which
|
||||||
# AddCustomInfo and failed mid-write after the summary fields and
|
# failed 'Key not found' after the summary fields and the earlier
|
||||||
# the earlier keys were already applied.
|
# 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()
|
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).casefold())
|
existing.add(str(key))
|
||||||
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
|
if key in existing:
|
||||||
# on a missing one does too — route by what is really there.
|
|
||||||
if key.casefold() in existing:
|
|
||||||
info.SetCustomByKey(key, value)
|
info.SetCustomByKey(key, value)
|
||||||
else:
|
continue
|
||||||
|
try:
|
||||||
info.AddCustomInfo(key, value)
|
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 = []
|
deleted = []
|
||||||
for key in to_delete:
|
for key in to_delete:
|
||||||
if key.casefold() in existing:
|
try:
|
||||||
info.RemoveCustomByKey(key)
|
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 {
|
return {
|
||||||
"ok": True,
|
"ok": True,
|
||||||
"summary_written": sorted(written),
|
"summary_written": sorted(written),
|
||||||
|
|||||||
@@ -525,6 +525,43 @@ SUMMARY_FIELDS = ("title", "subject", "author", "keywords", "comments")
|
|||||||
_CUSTOM_KEY_FORBIDDEN = frozenset('"*,/:;<=>?\\`|')
|
_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:
|
def _check_custom_text(key: str, text: str, what: str) -> None:
|
||||||
"""A newline in a custom key or value corrupts the DXF on save.
|
"""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``
|
them from the DXF. A *delete* (``None``) is exempt: ``RemoveCustomByKey``
|
||||||
never validates syntax, and such keys do reach drawings by other routes,
|
never validates syntax, and such keys do reach drawings by other routes,
|
||||||
so they must stay removable;
|
so they must stay removable;
|
||||||
* two keys in one request that differ only by case — AutoCAD's custom keys
|
* two keys in one request that AutoCAD would treat as the same key — its
|
||||||
are case-insensitive (measured: ``AddCustomInfo("PROJECT")`` over an
|
key compare is a simple one-to-one case compare (``custom_key_fold``;
|
||||||
existing ``Project`` raises ``Duplicate key``; ``SetCustomByKey`` /
|
measured: ``AddCustomInfo("PROJECT")`` over an existing ``Project``
|
||||||
``RemoveCustomByKey`` match either spelling), so the second would fail
|
raises ``Duplicate key``, while ``Straße`` and ``STRASSE`` are two
|
||||||
mid-write on the live engine and write a second tag headlessly;
|
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
|
* 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``),
|
||||||
@@ -618,16 +656,17 @@ 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
|
seen: dict[str, str] = {} # folded key (AutoCAD's rule) -> 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_text(key, key, "key")
|
_check_custom_text(key, key, "key")
|
||||||
folded = key.casefold()
|
folded = custom_key_fold(key)
|
||||||
if folded in seen:
|
if folded in seen:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"custom: keys {seen[folded]!r} and {key!r} differ only by case — "
|
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."
|
"'Duplicate key'). Mention the key once."
|
||||||
)
|
)
|
||||||
seen[folded] = key
|
seen[folded] = key
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ from .base import (
|
|||||||
)
|
)
|
||||||
from .contracts.settings import (
|
from .contracts.settings import (
|
||||||
SUMMARY_FIELDS,
|
SUMMARY_FIELDS,
|
||||||
|
custom_key_fold,
|
||||||
parse_scale,
|
parse_scale,
|
||||||
scale_value,
|
scale_value,
|
||||||
validate_drawing_properties,
|
validate_drawing_properties,
|
||||||
@@ -6142,24 +6143,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:
|
# AutoCAD compares custom keys with a simple per-character case
|
||||||
# SetCustomByKey("PROJECT") updates an existing "Project" and keeps
|
# compare (measured on 2026: SetCustomByKey("PROJECT") updates an
|
||||||
# that spelling; a second AddCustomInfo is 'Duplicate key'), and
|
# existing "Project" and keeps that spelling, a second AddCustomInfo
|
||||||
# ezdxf's CustomVars are plain case-sensitive tuples, so the match
|
# is 'Duplicate key' -- but 'Straße' and 'STRASSE' are two keys,
|
||||||
# is done here: an existing tag is updated in place under its
|
# 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.
|
# stored spelling, never duplicated under a second one.
|
||||||
props = custom_vars.properties
|
props = custom_vars.properties
|
||||||
for key, value in to_write.items():
|
for key, value in to_write.items():
|
||||||
folded = key.casefold()
|
folded = custom_key_fold(key)
|
||||||
hits = [i for i, (tag, _v) in enumerate(props) if tag.casefold() == folded]
|
hits = [i for i, (tag, _v) in enumerate(props) if custom_key_fold(tag) == folded]
|
||||||
if hits:
|
if hits:
|
||||||
props[hits[0]] = (props[hits[0]][0], value)
|
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:
|
||||||
folded = key.casefold()
|
folded = custom_key_fold(key)
|
||||||
kept = [(tag, v) for tag, v in props if tag.casefold() != folded]
|
kept = [(tag, v) for tag, v in props if custom_key_fold(tag) != folded]
|
||||||
if len(kept) != len(props):
|
if len(kept) != len(props):
|
||||||
props[:] = kept
|
props[:] = kept
|
||||||
deleted.append(key)
|
deleted.append(key)
|
||||||
|
|||||||
@@ -6969,14 +6969,17 @@ async def drawing_properties_set(
|
|||||||
and unicode are fine — `ValueError` naming the key, on both engines; a
|
and unicode are fine — `ValueError` naming the key, on both engines; a
|
||||||
delete is exempt because RemoveCustomByKey never validates syntax, so a
|
delete is exempt because RemoveCustomByKey never validates syntax, so a
|
||||||
key that reached the drawing another way stays removable), two keys in
|
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
|
one request that AutoCAD would call the same key (its key compare is a
|
||||||
AutoCAD), a line break in a custom key or value (it corrupts the DXF on
|
simple per-character case compare: `Project`/`PROJECT` and `Grün`/`GRÜN`
|
||||||
save), and headlessly a document older than R2004 (`ValueError` — ezdxf
|
are one key, `Straße`/`STRASSE` are two), a line break in a custom key or
|
||||||
only writes the custom-property header pairs for AC1018+, so they would
|
value (it corrupts the DXF on save), and headlessly a document older than
|
||||||
vanish at save; save as R2004 or newer first). A key is matched to the
|
R2004 (`ValueError` — ezdxf only writes the custom-property header pairs
|
||||||
drawing case-insensitively on both engines and keeps its stored spelling
|
for AC1018+, so they would vanish at save; save as R2004 or newer first).
|
||||||
when updated. On the live engine a custom key is added with AddCustomInfo
|
A key is matched to the drawing by that same rule on both engines and
|
||||||
when new and changed with SetCustomByKey when present.
|
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 = {
|
summary = {
|
||||||
"title": title,
|
"title": title,
|
||||||
|
|||||||
@@ -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
|
character through a scratch document: leading/trailing whitespace and exactly
|
||||||
the thirteen characters `" * , / : ; < = > ? \\ ` |` anywhere in the key --
|
the thirteen characters `" * , / : ; < = > ? \\ ` |` anywhere in the key --
|
||||||
internal spaces, tabs, control characters, a no-break space and unicode are
|
internal spaces, tabs, control characters, a no-break space and unicode are
|
||||||
accepted), case-variant duplicates (AutoCAD's keys are case-insensitive:
|
accepted), two keys AutoCAD would call one key (its key compare is a simple
|
||||||
`Duplicate key`), and line breaks, which ezdxf writes unescaped and which
|
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
|
corrupt the whole DXF. A delete is exempt from the AddCustomInfo syntax rules
|
||||||
(RemoveCustomByKey never validates: `Key not found`, never `Invalid key`).
|
(RemoveCustomByKey never validates: `Key not found`, never `Invalid key`).
|
||||||
"""
|
"""
|
||||||
@@ -30,6 +32,7 @@ from backends.base import UnsupportedCapabilityError
|
|||||||
from backends.contracts.settings import (
|
from backends.contracts.settings import (
|
||||||
_CUSTOM_KEY_FORBIDDEN,
|
_CUSTOM_KEY_FORBIDDEN,
|
||||||
SUMMARY_FIELDS,
|
SUMMARY_FIELDS,
|
||||||
|
custom_key_fold,
|
||||||
validate_drawing_properties,
|
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])
|
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 ─────────────────────────────────────────────────────────
|
# ── 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"}
|
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):
|
async def _backend_for_version(tmp_path, version: str):
|
||||||
import ezdxf
|
import ezdxf
|
||||||
|
|
||||||
@@ -386,37 +461,55 @@ 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,
|
# Measured on AutoCAD 2026: the three mutators match keys by AutoCAD's
|
||||||
# an existing key keeps its stored spelling, a second Add is 'Duplicate key',
|
# simple per-character case compare (`custom_key_fold` pins the table --
|
||||||
# a Set/Remove of an absent key is 'Key not found'.
|
# '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):
|
def _index(self, key):
|
||||||
for i, (k, _v) in enumerate(self._custom):
|
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 i
|
||||||
return None
|
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 set(key) & _CUSTOM_KEY_FORBIDDEN:
|
||||||
|
raise _com_error("Invalid key", -2145386465)
|
||||||
if self._index(key) is not None:
|
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))
|
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))
|
||||||
i = self._index(key)
|
i = self._index(key)
|
||||||
if i is None:
|
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)
|
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))
|
||||||
i = self._index(key)
|
i = self._index(key)
|
||||||
if i is None:
|
if i is None:
|
||||||
raise RuntimeError("AutoCAD COM error: Key not found")
|
raise _com_error("Key not found", -2145386476)
|
||||||
del self._custom[i]
|
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
|
@pytest.fixture
|
||||||
def com_backend(monkeypatch):
|
def com_backend(monkeypatch):
|
||||||
pytest.importorskip("win32com.client", reason="pywin32 not installed")
|
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"
|
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")]
|
mutations = [c for c in info.calls if c[0] not in ("NumCustomInfo", "GetCustomByIndex")]
|
||||||
assert mutations == [
|
assert mutations == [
|
||||||
("SetCustomByKey", "PROJECT", "X-2"), # existed → set
|
("SetCustomByKey", "PROJECT", "X-2"), # exact spelling present → set
|
||||||
("AddCustomInfo", "REV", "B"), # new → add
|
("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):
|
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")]
|
assert info._custom == [("PROJECT", "X-1"), ("OLD", "1")]
|
||||||
|
|
||||||
|
|
||||||
async def test_com_routes_case_variant_keys_to_set_and_remove(com_backend):
|
async def test_com_lets_autocad_route_a_case_variant_key(com_backend):
|
||||||
"""Measured on AutoCAD 2026: AddCustomInfo('PROJECT') over an existing
|
"""Measured on AutoCAD 2026: AddCustomInfo('project') over an existing
|
||||||
'Project' raises 'Duplicate key' -- a case-sensitive existence check
|
'PROJECT' raises 'Duplicate key' -- a case-sensitive existence check
|
||||||
routed it there and failed mid-write. Existence is matched casefolded."""
|
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
|
backend, info = com_backend
|
||||||
res = await backend.drawing_properties_set(custom={"project": "X-2", "old": None, "New": "n"})
|
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"]
|
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")]
|
mutations = [c for c in info.calls if c[0] not in ("NumCustomInfo", "GetCustomByIndex")]
|
||||||
assert mutations == [
|
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"),
|
("AddCustomInfo", "New", "n"),
|
||||||
("RemoveCustomByKey", "old"),
|
("RemoveCustomByKey", "old"),
|
||||||
]
|
]
|
||||||
assert info._custom == [("PROJECT", "X-2"), ("New", "n")], "stored spelling kept"
|
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):
|
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