feat(settings): drawing_properties_get/set — SummaryInfo on COM, custom properties on both engines
Summary fields are refused headlessly with capability dwgprops before any write; custom properties are $CUSTOMPROPERTYTAG/$CUSTOMPROPERTY pairs and survive save + reopen. Validation is shared, by name, and never str()-coerces. 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
75c13196b9
commit
10a7372ce7
@@ -0,0 +1,233 @@
|
||||
"""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).
|
||||
"""
|
||||
|
||||
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"),
|
||||
],
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
# ── 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_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"
|
||||
Reference in New Issue
Block a user