Files
autocad-mcp-pro/tests/test_drawing_properties.py
T
Umut CANandClaude Opus 5 2b9791fbcd fix(task-16): custom properties refuse pre-R2004 headlessly, line breaks, and AutoCAD's invalid keys before any write
Three header-only-write / half-write classes in drawing_properties_set:

1. Measured: ezdxf emits $CUSTOMPROPERTYTAG / $CUSTOMPROPERTY only after
   $LASTSAVEDBY, which does not exist before R2004, so on an R12 (AC1009) or
   R2000 (AC1015) document the write reported ok, drawing_properties_get
   echoed it from memory, and drawing_save_as (version kept) wrote nothing --
   the class f3ff703 removed for CANNOSCALE. The headless engine now refuses
   with a ValueError naming the DXF version and "save as R2004 or newer",
   before custom_vars is touched; the dwgprops capability reason no longer
   claims custom_properties_supported unconditionally.

2. A line break in a custom key or value passed validation, ezdxf wrote it
   unescaped, and the saved DXF failed to reopen (DXFStructureError) -- the
   whole drawing lost, reported as success. Refused by name in the shared
   validator.

3. Measured on AutoCAD 2026: AddCustomInfo rejects keys with leading/trailing
   whitespace and keys containing '=', ';' or a no-break space as 'Invalid
   key' -- mid-write, after the summary and earlier keys were applied, as an
   unnamed COM error -- while the headless engine wrote them. The shared
   validator now mirrors those rules, so both engines refuse before writing
   (internal spaces, tabs, unicode and 300-char keys stay accepted, as
   AutoCAD accepts them).

Pinned by save+reopen tests on R12/R2000 (refused, file reads back empty),
R2004 (round-trips), the newline case (refused, the file still opens), and
the fake-SummaryInfo COM test (no ActiveX call reaches SummaryInfo).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
2026-09-21 18:45:02 +03:00

368 lines
16 KiB
Python

"""drawing_properties_get / set: DWG summary and custom properties on both engines.
Headlessly the five summary fields live in the DWG SummaryInfo stream, which
ezdxf cannot write, so any non-null summary field is refused with
`capability: dwgprops` before anything is touched; custom properties are
`$CUSTOMPROPERTYTAG` / `$CUSTOMPROPERTY` header pairs and work on both
engines (measured: they survive save + reopen on R2004 and newer; ezdxf
only emits the pairs for AC1018+, so an older document is refused headlessly
instead of losing the write at save).
Custom keys and values are validated once, by name, against what would break
*either* engine: keys AutoCAD's AddCustomInfo rejects mid-write as
`Invalid key` (measured on AutoCAD 2026: leading/trailing whitespace, `=`,
`;`, no-break space) and line breaks, which ezdxf writes unescaped and
which corrupt the whole DXF.
"""
from __future__ import annotations
import types
import pytest
from backends.base import UnsupportedCapabilityError
from backends.contracts.settings import SUMMARY_FIELDS, validate_drawing_properties
pytestmark = pytest.mark.asyncio
EMPTY_SUMMARY = {field: None for field in SUMMARY_FIELDS}
# ── shared validation ───────────────────────────────────────────────────────
def test_validation_splits_writes_and_deletes():
written, to_write, to_delete = validate_drawing_properties(
{"title": "Gearbox", "author": None}, {"PROJECT": "X-1", "OLD": None}
)
assert written == {"title": "Gearbox"}
assert to_write == {"PROJECT": "X-1"} and to_delete == ["OLD"]
assert validate_drawing_properties(None, None) == ({}, {}, [])
@pytest.mark.parametrize(
"summary, custom, exc, fragment",
[
({"edition": "1"}, None, ValueError, "edition"),
({"title": 3}, None, TypeError, "summary.title"),
(None, {"N": 3}, TypeError, "custom['N']"),
(None, {"": "x"}, TypeError, "non-empty"),
("Gearbox", None, TypeError, "summary"),
(None, ["PROJECT"], TypeError, "custom"),
# Keys AutoCAD's AddCustomInfo rejects as 'Invalid key' (measured on
# AutoCAD 2026) -- refused here so the live engine never fails
# mid-write after the summary and earlier keys were already applied.
(None, {" BAD ": "x"}, ValueError, "' BAD '"),
(None, {"PROJECT ": "x"}, ValueError, "trailing whitespace"),
(None, {"A=B": "x"}, ValueError, "'A=B' contains '='"),
(None, {"A;B": "x"}, ValueError, "'A;B' contains ';'"),
(None, {"NB\xa0SP": "x"}, ValueError, "no-break space"),
# A line break is written unescaped by ezdxf and corrupts the DXF.
(None, {"NOTE": "line1\nline2"}, ValueError, "custom['NOTE']: the value"),
(None, {"NOTE": "line1\rline2"}, ValueError, "line break"),
(None, {"K\nEY": "x"}, ValueError, "the key contains a line break"),
(None, {"K\nEY": None}, ValueError, "the key contains a line break"),
],
)
def test_validation_refuses_by_name(summary, custom, exc, fragment):
with pytest.raises(exc) as excinfo:
validate_drawing_properties(summary, custom)
assert fragment in str(excinfo.value)
@pytest.mark.parametrize("key", ["IN SP", "T\tAB", "\u00dcn\u00efcode", "X" * 300])
def test_validation_accepts_keys_autocad_accepts(key):
"""Measured: internal spaces, tabs, unicode and 300-char keys pass
AddCustomInfo; the validator must not be stricter than AutoCAD."""
assert validate_drawing_properties(None, {key: "v"}) == ({}, {key: "v"}, [])
# ── headless engine ─────────────────────────────────────────────────────────
async def test_get_reports_no_summary_and_empty_custom(backend):
assert await backend.drawing_properties_get() == {
"summary": EMPTY_SUMMARY,
"summary_available": False,
"custom": {},
"backend": "ezdxf",
}
async def test_custom_properties_write_replace_delete_and_survive_reopen(backend, tmp_path):
first = await backend.drawing_properties_set(custom={"PROJECT": "X-1", "REV": "B"})
assert first == {
"ok": True,
"summary_written": [],
"custom_written": ["PROJECT", "REV"],
"custom_deleted": [],
"backend": "ezdxf",
}
second = await backend.drawing_properties_set(
custom={"REV": None, "NOPE": None, "PROJECT": "X-2"}
)
assert second["custom_written"] == ["PROJECT"]
assert second["custom_deleted"] == ["REV"], "a key that was never there is not 'deleted'"
assert (await backend.drawing_properties_get())["custom"] == {"PROJECT": "X-2"}
assert [t for t, _ in backend._doc.header.custom_vars] == ["PROJECT"], "no duplicate tags"
path = str(tmp_path / "props.dxf")
await backend.drawing_save_as(path)
await backend.drawing_open(path)
assert (await backend.drawing_properties_get())["custom"] == {"PROJECT": "X-2"}
async def test_line_break_is_refused_before_any_write_and_the_file_still_opens(backend, tmp_path):
"""Measured: `custom={'NOTE': 'line1\\nline2', 'AFTER': 'ok'}` reported
`ok: True`, and the saved DXF failed to reopen with
`DXFStructureError: Invalid group code "line2"` -- the whole drawing lost,
reported as success. The request is refused as a whole, so AFTER is not
written either, and the file saved afterwards reopens."""
with pytest.raises(ValueError, match=r"custom\['NOTE'\]: the value contains a line break"):
await backend.drawing_properties_set(custom={"NOTE": "line1\nline2", "AFTER": "ok"})
assert (await backend.drawing_properties_get())["custom"] == {}
path = str(tmp_path / "clean.dxf")
await backend.drawing_save_as(path)
await backend.drawing_open(path)
assert (await backend.drawing_properties_get())["custom"] == {}
async def test_autocad_invalid_keys_are_refused_headlessly_too(backend):
"""The headless engine used to write ' BAD ', 'A=B' and 'A;B' without
complaint (and AutoCAD loaded them from the DXF) while the live engine
half-applied the same call; both now refuse before writing."""
with pytest.raises(ValueError, match="' BAD '"):
await backend.drawing_properties_set(custom={"AAA": "first", " BAD ": "x", "ZZZ": "n"})
assert (await backend.drawing_properties_get())["custom"] == {}
async def _backend_for_version(tmp_path, version: str):
import ezdxf
from backends.ezdxf_backend import EzdxfBackend
path = tmp_path / f"{version}.dxf"
ezdxf.new(version).saveas(str(path))
backend = EzdxfBackend()
await backend.connect()
await backend.drawing_open(str(path))
return backend
@pytest.mark.parametrize("version, dxfversion", [("R12", "AC1009"), ("R2000", "AC1015")])
async def test_custom_properties_before_r2004_are_refused_rather_than_lost(
tmp_path, version, dxfversion
):
"""Measured: on an R12 or R2000 document `drawing_properties_set` reported
`ok: True, custom_written: ['PROJECT']`, `drawing_properties_get` echoed
it from memory, and `drawing_save_as` (version kept) wrote nothing --
ezdxf emits $CUSTOMPROPERTYTAG / $CUSTOMPROPERTY only after $LASTSAVEDBY,
which does not exist before R2004. The header-only write that vanishes on
save, the class f3ff703 removed for CANNOSCALE. Refused up front, before
custom_vars is touched; the reopened file is read back to prove it."""
backend = await _backend_for_version(tmp_path, version)
try:
assert backend._doc.dxfversion == dxfversion
with pytest.raises(ValueError, match=dxfversion) as excinfo:
await backend.drawing_properties_set(custom={"PROJECT": "X-1", "REV": "B"})
assert "R2004 or newer" in str(excinfo.value)
assert "PROJECT" in str(excinfo.value)
assert list(backend._doc.header.custom_vars) == []
assert (await backend.drawing_properties_get())["custom"] == {}
# A delete on the same document is refused for the same reason: it
# would report a key removed that the file could never have held.
with pytest.raises(ValueError, match="R2004 or newer"):
await backend.drawing_properties_set(custom={"PROJECT": None})
# An empty request touches nothing and is not a write to refuse.
res = await backend.drawing_properties_set(custom={})
assert res["ok"] is True and res["custom_written"] == []
out = tmp_path / f"{version}_out.dxf"
await backend.drawing_save_as(str(out))
await backend.drawing_open(str(out))
assert backend._doc.dxfversion == dxfversion
assert (await backend.drawing_properties_get())["custom"] == {}
finally:
await backend.disconnect()
async def test_custom_properties_on_r2004_survive_save_and_reopen(tmp_path):
"""The oldest version that carries the pairs -- the boundary the refusal
sits on -- round-trips."""
backend = await _backend_for_version(tmp_path, "R2004")
try:
res = await backend.drawing_properties_set(custom={"PROJECT": "X-1"})
assert res["custom_written"] == ["PROJECT"]
out = tmp_path / "r2004_out.dxf"
await backend.drawing_save_as(str(out))
await backend.drawing_open(str(out))
assert backend._doc.dxfversion == "AC1018"
assert (await backend.drawing_properties_get())["custom"] == {"PROJECT": "X-1"}
finally:
await backend.disconnect()
async def test_summary_is_refused_headlessly_before_any_write(backend):
with pytest.raises(UnsupportedCapabilityError) as excinfo:
await backend.drawing_properties_set(summary={"title": "T"}, custom={"LATE": "no"})
assert excinfo.value.capability == "dwgprops"
assert "title" in str(excinfo.value)
assert (await backend.drawing_properties_get())["custom"] == {}
async def test_all_null_summary_is_not_a_summary_write(backend):
res = await backend.drawing_properties_set(summary={"title": None, "author": None})
assert res["ok"] is True and res["summary_written"] == []
async def test_malformed_custom_is_refused_by_name_before_any_write(backend):
with pytest.raises(TypeError, match=r"custom\['N'\]"):
await backend.drawing_properties_set(custom={"OK": "1", "N": 3})
assert (await backend.drawing_properties_get())["custom"] == {}
def test_both_capability_maps_declare_dwgprops():
from backends.com_backend import ComBackend
from backends.ezdxf_backend import EzdxfBackend
assert EzdxfBackend().capabilities().to_dict()["features"]["dwgprops"]["supported"] is False
assert ComBackend().capabilities().to_dict()["features"]["dwgprops"]["supported"] is True
# ── server tools ────────────────────────────────────────────────────────────
class _FakeCtx:
def __init__(self, backend):
self.lifespan_context = {"backend": backend}
async def info(self, *a, **k):
pass
async def test_server_tools_round_trip_custom_and_refuse_summary(backend):
import server
ctx = _FakeCtx(backend)
res = await server.drawing_properties_set(custom={"PROJECT": "X-9"}, ctx=ctx)
assert res["custom_written"] == ["PROJECT"]
got = await server.drawing_properties_get(ctx=ctx)
assert got["custom"] == {"PROJECT": "X-9"} and got["summary_available"] is False
with pytest.raises(UnsupportedCapabilityError):
await server.drawing_properties_set(title="Gearbox", ctx=ctx)
# ── live engine, against a fake SummaryInfo ─────────────────────────────────
class _FakeSummaryInfo:
def __init__(self):
self.Title = "Old title"
self.Subject = ""
self.Author = ""
self.Keywords = "gear"
self.Comments = ""
self._custom: list[tuple[str, str]] = [("PROJECT", "X-1"), ("OLD", "1")]
self.calls: list[tuple] = []
def NumCustomInfo(self):
self.calls.append(("NumCustomInfo",))
return len(self._custom)
def GetCustomByIndex(self, index):
self.calls.append(("GetCustomByIndex", index))
return self._custom[index] # pywin32 hands [out] BSTR pairs back as a tuple
def AddCustomInfo(self, key, value):
self.calls.append(("AddCustomInfo", key, value))
self._custom.append((key, value))
def SetCustomByKey(self, key, value):
self.calls.append(("SetCustomByKey", key, value))
self._custom = [(k, value if k == key else v) for k, v in self._custom]
def RemoveCustomByKey(self, key):
self.calls.append(("RemoveCustomByKey", key))
self._custom = [(k, v) for k, v in self._custom if k != key]
@pytest.fixture
def com_backend(monkeypatch):
pytest.importorskip("win32com.client", reason="pywin32 not installed")
from backends import com_backend as module
info = _FakeSummaryInfo()
document = types.SimpleNamespace(SummaryInfo=info)
monkeypatch.setattr(module, "_acad_doc", lambda: document)
backend = module.ComBackend()
async def _run_inline(func, *args, **kwargs):
return func(*args, **kwargs)
monkeypatch.setattr(backend, "_run", _run_inline)
return backend, info
async def test_com_get_reads_summary_and_custom(com_backend):
backend, info = com_backend
assert await backend.drawing_properties_get() == {
"summary": {
"title": "Old title",
"subject": "",
"author": "",
"keywords": "gear",
"comments": "",
},
"summary_available": True,
"custom": {"PROJECT": "X-1", "OLD": "1"},
"backend": "com",
}
async def test_com_set_writes_summary_and_routes_custom_by_existence(com_backend):
backend, info = com_backend
res = await backend.drawing_properties_set(
summary={"title": "Gearbox", "author": "U. Can", "subject": None},
custom={"PROJECT": "X-2", "REV": "B", "OLD": None, "NOPE": None},
)
assert res == {
"ok": True,
"summary_written": ["author", "title"],
"custom_written": ["PROJECT", "REV"],
"custom_deleted": ["OLD"],
"backend": "com",
}
assert info.Title == "Gearbox" and info.Author == "U. Can" and info.Keywords == "gear"
mutations = [c for c in info.calls if c[0] not in ("NumCustomInfo", "GetCustomByIndex")]
assert mutations == [
("SetCustomByKey", "PROJECT", "X-2"), # existed → set
("AddCustomInfo", "REV", "B"), # new → add
("RemoveCustomByKey", "OLD"), # existed → remove; NOPE never touched
]
async def test_com_validates_before_touching_activex(com_backend):
backend, info = com_backend
with pytest.raises(TypeError):
await backend.drawing_properties_set(summary={"title": "T"}, custom={"N": 3})
with pytest.raises(ValueError):
await backend.drawing_properties_set(summary={"edition": "1"})
assert info.calls == [] and info.Title == "Old title"
async def test_com_refuses_autocad_invalid_keys_before_any_write(com_backend):
"""Measured on AutoCAD 2026: `summary={'subject': 'PARTIAL?'},
custom={'AAA': 'first', ' BAD ': 'x', 'ZZZ': 'never'}` raised an unnamed
COM error from AddCustomInfo(' BAD ') and left subject and AAA written,
ZZZ absent -- a half-applied write reported as an error. The shared
validator now names the key and nothing reaches SummaryInfo."""
backend, info = com_backend
with pytest.raises(ValueError, match="' BAD '"):
await backend.drawing_properties_set(
summary={"subject": "PARTIAL?"},
custom={"AAA": "first", " BAD ": "x", "ZZZ": "never"},
)
assert info.calls == [] and info.Subject == ""
assert info._custom == [("PROJECT", "X-1"), ("OLD", "1")]