From 10a7372ce7093dbba97004c4b6f15fb916558c97 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Mon, 21 Sep 2026 16:59:19 +0300 Subject: [PATCH] =?UTF-8?q?feat(settings):=20drawing=5Fproperties=5Fget/se?= =?UTF-8?q?t=20=E2=80=94=20SummaryInfo=20on=20COM,=20custom=20properties?= =?UTF-8?q?=20on=20both=20engines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- README.md | 2 +- backends/com_backend.py | 63 +++++++++ backends/contracts/settings.py | 50 +++++++ backends/ezdxf_backend.py | 63 ++++++++- discovery/aliases.py | 25 ++++ docs/tool-inventory.json | 32 ++++- server.py | 67 ++++++++- tests/test_drawing_properties.py | 233 +++++++++++++++++++++++++++++++ tests/test_tool_registry.py | 11 +- 9 files changed, 537 insertions(+), 9 deletions(-) create mode 100644 tests/test_drawing_properties.py diff --git a/README.md b/README.md index 11bfe92..540c09e 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co -> **v1.5 release snapshot:** 167 tools · 8 resources · 5 prompt templates · 2423 collected tests. +> **v1.5 release snapshot:** 169 tools · 8 resources · 5 prompt templates · 2423 collected tests. > 166 is the **registered** count; a default install advertises 161 over `tools/list`, > because `ENABLE_3D` is unset. `system_about` is the runtime authority. diff --git a/backends/com_backend.py b/backends/com_backend.py index 5f506d7..b72d124 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -36,6 +36,7 @@ from .base import ( rad2deg, shoelace_area, ) +from .contracts.settings import SUMMARY_FIELDS, validate_drawing_properties log = logging.getLogger(__name__) @@ -195,6 +196,9 @@ def _msp(): _BUILTIN_LINETYPES = {"continuous", "bylayer", "byblock"} +#: `drawing_properties_*` field → `IAcadSummaryInfo` property. +_SUMMARY_ATTRS = {field: field.capitalize() for field in SUMMARY_FIELDS} + def _ensure_linetype_loaded(name: str) -> None: """If `name` is not already loaded, load it via -LINETYPE behind FILEDIA=0. @@ -815,6 +819,7 @@ class ComBackend(AutoCADBackend): "solid_3d": _solid_3d_capability(), "lisp": FeatureCapability(True, "sanitized"), "registry_sysvar": FeatureCapability(True, "native"), + "dwgprops": FeatureCapability(True, "native"), }, ) @@ -4257,3 +4262,61 @@ class ComBackend(AutoCADBackend): return _entity_info(xline) return await self._run(_sync) + + # ── settings (track E, group C) ─────────────────────────────────────────── + + async def drawing_properties_get(self) -> dict: + def _sync(): + info = _acad_doc().SummaryInfo + summary = { + field: str(getattr(info, attr) or "") for field, attr in _SUMMARY_ATTRS.items() + } + custom: dict[str, str] = {} + for index in range(int(info.NumCustomInfo())): + key, value = info.GetCustomByIndex(index) + custom[str(key)] = str(value) + return { + "summary": summary, + "summary_available": True, + "custom": custom, + "backend": "com", + } + + return await self._run(_sync) + + async def drawing_properties_set( + self, summary: dict | None = None, custom: dict | None = None + ) -> dict: + # Validated before any ActiveX call: a bad field or value raises here + # and SummaryInfo is never touched. + written, to_write, to_delete = validate_drawing_properties(summary, custom) + + def _sync(): + info = _acad_doc().SummaryInfo + existing: set[str] = set() + for index in range(int(info.NumCustomInfo())): + key, _value = info.GetCustomByIndex(index) + existing.add(str(key)) + for field, value in written.items(): + setattr(info, _SUMMARY_ATTRS[field], value) + for key, value in to_write.items(): + # AddCustomInfo on an existing key raises in AutoCAD; SetCustomByKey + # on a missing one does too — route by what is really there. + if key in existing: + info.SetCustomByKey(key, value) + else: + info.AddCustomInfo(key, value) + deleted = [] + for key in to_delete: + if key in existing: + info.RemoveCustomByKey(key) + deleted.append(key) + return { + "ok": True, + "summary_written": sorted(written), + "custom_written": sorted(to_write), + "custom_deleted": deleted, + "backend": "com", + } + + return await self._run(_sync) diff --git a/backends/contracts/settings.py b/backends/contracts/settings.py index 1a12036..0362e35 100644 --- a/backends/contracts/settings.py +++ b/backends/contracts/settings.py @@ -505,3 +505,53 @@ def _decode_setting(key: str, kind: str, raw: Any) -> Any: except (TypeError, ValueError): return raw return raw + + +# --------------------------------------------------------------------------- +# Document properties (DWGPROPS) — validation shared by both engines +# --------------------------------------------------------------------------- + +#: The SummaryInfo fields AutoCAD's DWGPROPS dialog shows on its Summary tab. +SUMMARY_FIELDS = ("title", "subject", "author", "keywords", "comments") + + +def validate_drawing_properties( + summary: dict | None, custom: dict | None +) -> tuple[dict[str, str], dict[str, str], list[str]]: + """→ (summary fields to write, custom keys to write, custom keys to delete). + + Refuses the whole request by name before either engine writes anything: + an unknown summary field is a ``ValueError``; a non-string summary value, + a non-string or empty custom key, or a custom value that is neither a + string nor ``None`` (``None`` deletes) is a ``TypeError``. Values are never + coerced with ``str()`` — a number the caller meant as text is theirs to + format. + """ + if summary is not None and not isinstance(summary, dict): + raise TypeError("summary: must be an object of {field: text}") + written: dict[str, str] = {} + for field, value in (summary or {}).items(): + if field not in SUMMARY_FIELDS: + raise ValueError(f"summary: unknown field {field!r} (valid: {list(SUMMARY_FIELDS)})") + if value is None: + continue + if not isinstance(value, str): + raise TypeError(f"summary.{field}: must be a string, got {type(value).__name__}") + written[field] = value + if custom is not None and not isinstance(custom, dict): + raise TypeError("custom: must be an object of {key: text | null}") + to_write: dict[str, str] = {} + to_delete: list[str] = [] + for key, value in (custom or {}).items(): + if not isinstance(key, str) or not key.strip(): + raise TypeError(f"custom: key {key!r} must be a non-empty string") + if value is None: + to_delete.append(key) + elif isinstance(value, str): + to_write[key] = value + else: + raise TypeError( + f"custom[{key!r}]: must be a string or null (null deletes the key), " + f"got {type(value).__name__}" + ) + return written, to_write, to_delete diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 247e882..b33eb18 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -39,7 +39,12 @@ from .base import ( normalize_lineweight, shoelace_area, ) -from .contracts.settings import parse_scale, scale_value +from .contracts.settings import ( + SUMMARY_FIELDS, + parse_scale, + scale_value, + validate_drawing_properties, +) from .quarantine import ( AbandonedCall, DocumentQuarantineError, @@ -1110,6 +1115,10 @@ class EzdxfBackend(AutoCADBackend): "registry_sysvar": FeatureCapability( False, reason="registry_saved_variables_have_no_home_in_a_file" ), + "dwgprops": FeatureCapability( + False, + reason="summary_fields_need_live_autocad;custom_properties_supported", + ), }, ) @@ -6086,3 +6095,55 @@ class EzdxfBackend(AutoCADBackend): else: entry.dxf.value = name return name + + async def drawing_properties_get(self) -> dict: + def _sync(): + doc = self._require_doc() + return { + "summary": {field: None for field in SUMMARY_FIELDS}, + "summary_available": False, + "custom": {tag: value for tag, value in doc.header.custom_vars}, + "backend": "ezdxf", + } + + return await self._async(_sync) + + async def drawing_properties_set( + self, summary: dict | None = None, custom: dict | None = None + ) -> dict: + written, to_write, to_delete = validate_drawing_properties(summary, custom) + if written: + # The five summary fields live in the DWG SummaryInfo stream, which + # a DXF has no slot for; refuse before the custom keys are touched. + raise UnsupportedCapabilityError( + "dwgprops", + "drawing_properties_set: the summary fields (title, subject, author, keywords, " + "comments) live in the DWG SummaryInfo, which the headless ezdxf backend cannot " + f"write (asked for {sorted(written)}). Custom properties work on both engines; " + "set the summary on the live COM backend.", + ) + + def _sync(): + doc = self._require_doc() + custom_vars = doc.header.custom_vars + for key, value in to_write.items(): + if custom_vars.has_tag(key): + custom_vars.replace(key, value) + else: + custom_vars.append(key, value) + deleted = [] + for key in to_delete: + if custom_vars.has_tag(key): + custom_vars.remove(key, all=True) + deleted.append(key) + if to_write or deleted: + self._mark_dirty() + return { + "ok": True, + "summary_written": [], + "custom_written": sorted(to_write), + "custom_deleted": deleted, + "backend": "ezdxf", + } + + return await self._async(_sync) diff --git a/discovery/aliases.py b/discovery/aliases.py index 694c96c..f0e56df 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -72,12 +72,15 @@ class ToolAliases: # PLINE -- a raw polyline (entity_create_polyline) or a P&ID line run # between two ports (pid_line_draw), which is an LWPOLYLINE with a # class, a number and markers; a drafter typing it could mean either. +# DWGPROPS -- one dialog, three tabs: General (drawing_info), Summary and +# Custom (drawing_properties_get / drawing_properties_set). # # Every other AutoCAD command must map to exactly one tool. SHARED_ACAD_COMMANDS: frozenset[str] = frozenset( { "ARRAY", "BLOCK", + "DWGPROPS", "ERASE", "INSERT", "LAYER", @@ -453,6 +456,28 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "drawing intent", ), ), + "drawing_properties_get": ToolAliases( + acad=("DWGPROPS",), + synonyms=( + "drawing properties", + "document properties", + "summary info", + "title subject author keywords", + "custom properties", + "who made this drawing", + ), + ), + "drawing_properties_set": ToolAliases( + acad=("DWGPROPS",), + synonyms=( + "set the drawing title", + "set author and keywords", + "add a custom property", + "project number in the file properties", + "edit summary info", + "delete a custom property", + ), + ), "drawing_preflight": ToolAliases( synonyms=( "preflight", diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index 7aa17d6..2ffd63a 100644 --- a/docs/tool-inventory.json +++ b/docs/tool-inventory.json @@ -3,7 +3,7 @@ "schema_version": 1, "package_version": "1.5.1", "totals": { - "tools": 167, + "tools": 169, "resources": 8, "prompts": 5, "groups": 20 @@ -14,7 +14,7 @@ "blocks": 9, "corner_ops": 4, "dimensions": 5, - "drawing": 11, + "drawing": 13, "engineering": 10, "entity_creation": 18, "entity_modification": 16, @@ -631,6 +631,34 @@ "readOnlyHint": true } }, + { + "name": "drawing_properties_get", + "group": "drawing", + "cost": "read", + "summary": "Read the drawing's title, subject, author, keywords, comments and custom properties.", + "tags": [ + "drawing", + "settings" + ], + "annotations": { + "title": "Drawing Properties (read)", + "readOnlyHint": true + } + }, + { + "name": "drawing_properties_set", + "group": "drawing", + "cost": "safe", + "summary": "Set the drawing's summary fields and add, change or delete custom properties.", + "tags": [ + "drawing", + "settings" + ], + "annotations": { + "title": "Drawing Properties (write)", + "readOnlyHint": false + } + }, { "name": "drawing_purge", "group": "drawing", diff --git a/server.py b/server.py index 1a81ae3..9b11afa 100644 --- a/server.py +++ b/server.py @@ -6853,7 +6853,7 @@ async def pid_tag_parse( # --------------------------------------------------------------------------- -# ── SECTION 20: Environment (1 tool) ──────────────────────────────────────── +# ── SECTION 20: Environment (3 tools) ─────────────────────────────────────── # --------------------------------------------------------------------------- # Group C (settings) opens this section with `system_variable_describe` and, # in Task 16, the two `drawing_properties_*` tools; group V's merge extends the @@ -6912,6 +6912,71 @@ async def system_variable_describe( } +@cad_tool( + summary="Read the drawing's title, subject, author, keywords, comments and custom properties.", + cost="read", +) +@mcp.tool( + annotations={"title": "Drawing Properties (read)", "readOnlyHint": True}, + tags={"drawing", "settings"}, +) +async def drawing_properties_get(ctx: Context = None) -> dict: + """The DWGPROPS dialog as data: `summary` (title, subject, author, keywords, + comments), `summary_available`, and `custom` ({key: value}). + + On the live engine the summary comes from the document's SummaryInfo. The + headless engine cannot read that stream, so the five summary fields are + `null` with `summary_available: false` — not empty strings, which would + claim the drawing has no title. Custom properties (`$CUSTOMPROPERTYTAG` / + `$CUSTOMPROPERTY` header pairs) are read on both engines. + """ + return await _backend(ctx).drawing_properties_get() + + +@cad_tool( + summary="Set the drawing's summary fields and add, change or delete custom properties.", + cost="safe", +) +@mcp.tool( + annotations={"title": "Drawing Properties (write)", "readOnlyHint": False}, + tags={"drawing", "settings"}, +) +async def drawing_properties_set( + title: Annotated[str | None, "SummaryInfo Title"] = None, + subject: Annotated[str | None, "SummaryInfo Subject"] = None, + author: Annotated[str | None, "SummaryInfo Author"] = None, + keywords: Annotated[str | None, "SummaryInfo Keywords"] = None, + comments: Annotated[str | None, "SummaryInfo Comments"] = None, + custom: Annotated[ + dict | None, + "Custom properties to write: {key: text}; a null value deletes the key. " + "Keys not mentioned are left alone.", + ] = None, + ctx: Context = None, +) -> dict: + """Write DWGPROPS fields. Only the arguments given are touched; returns + `summary_written`, `custom_written` and `custom_deleted` (a key that was + never there is not reported deleted). + + Refusals, before anything is written: a summary field on the headless + engine (`capability: dwgprops` — SummaryInfo lives in the DWG, a DXF has + no slot for it; custom properties still work headlessly), a non-string + value (`TypeError` naming the field or key — nothing is coerced with + `str()`), an empty custom key. On the live engine a custom key is added + with AddCustomInfo when new and changed with SetCustomByKey when present. + """ + summary = { + "title": title, + "subject": subject, + "author": author, + "keywords": keywords, + "comments": comments, + } + touched = [k for k, v in summary.items() if v is not None] + sorted(custom or {}) + await ctx.info(f"Setting drawing properties: {', '.join(touched) or 'nothing'}") + return await _backend(ctx).drawing_properties_set(summary, custom) + + # --------------------------------------------------------------------------- # ── RESOURCES ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_drawing_properties.py b/tests/test_drawing_properties.py new file mode 100644 index 0000000..afcc3ec --- /dev/null +++ b/tests/test_drawing_properties.py @@ -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" diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index 194f674..ed2259b 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -519,8 +519,11 @@ async def test_tool_group_sizes_are_unchanged(): tag is ranked first in `_GROUP_TAG_PRIORITY` so those tools file under `pid` rather than under their secondary `query` / `create` tags. `system` moved 7 -> 8 when v1.6's `system_variable_describe` (the sysvar catalogue - as a read-only tool) opened SECTION 20. Every other number here has been - unchanged since the snapshot was taken. + as a read-only tool) opened SECTION 20. `drawing` moved 11 -> 13 when + v1.6's `drawing_properties_get` / `drawing_properties_set` (DWGPROPS on + both engines) joined SECTION 20 — tagged `drawing` and `settings`, and + `settings` is not a group tag, so they file under drawing. Every other + number here has been unchanged since the snapshot was taken. """ sizes = {label: len(names) for label, names in (await server._tool_groups()).items()} assert sizes == { @@ -529,7 +532,7 @@ async def test_tool_group_sizes_are_unchanged(): "blocks": 9, "corner_ops": 4, "dimensions": 5, - "drawing": 11, + "drawing": 13, "engineering": 10, "entity_creation": 18, "entity_modification": 16, @@ -545,4 +548,4 @@ async def test_tool_group_sizes_are_unchanged(): "validation": 1, "view": 4, } - assert sum(sizes.values()) == 167 + assert sum(sizes.values()) == 169