diff --git a/README.md b/README.md index d32d5df..a05646f 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co -> **v1.5 release snapshot:** 155 tools · 6 resources · 5 prompt templates · 1369 collected tests. +> **v1.5 release snapshot:** 157 tools · 6 resources · 5 prompt templates · 1369 collected tests. > 154 is the **registered** count; a default install advertises 149 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 ee5e479..a51fad8 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -3085,6 +3085,61 @@ class ComBackend(AutoCADBackend): return await self._run(_sync) + # ── extended entity data (XDATA) ───────────────────────────────────────── + + async def entity_get_xdata(self, handle, app_name=None) -> dict: + from backends.xdata_specs import split_by_app, validate_app_name + + wanted = validate_app_name(app_name) if app_name else "" + + def _sync(): + doc = _acad_doc() + ent = doc.HandleToObject(str(handle).strip().upper()) + # GetXData("") returns every app's stream with the 1001 markers + # inline; a named app returns just that app's slice. + codes, values = ent.GetXData(wanted) + grouped = split_by_app(list(codes or []), list(values or [])) + if wanted: + grouped = {k: v for k, v in grouped.items() if k == wanted} + return {"handle": handle, "xdata": grouped, "backend": "com"} + + return await self._run(_sync) + + async def entity_set_xdata(self, handle, app_name, values) -> dict: + from backends.xdata_specs import encode_values, validate_app_name + + # Validate and type every value before any ActiveX call: a bad + # values[i] raises here and nothing is written. + app = validate_app_name(app_name) + tags = encode_values(values) + + def _sync(): + doc = _acad_doc() + ent = doc.HandleToObject(str(handle).strip().upper()) + try: + doc.RegisteredApplications.Add(app) + except Exception as exc: # already registered + log.debug("RegisteredApplications.Add(%s): %s", app, exc) + # A bare 1001 marker with no data is how ActiveX removes an app's + # XDATA, so the empty case is the same call with an empty tail. + codes = [1001] + [code for code, _ in tags] + payload = [app] + [_apoint(*value) if code == 1010 else value for code, value in tags] + ent.SetXData( + _ai(codes), + win32com.client.VARIANT(pythoncom.VT_ARRAY | pythoncom.VT_VARIANT, payload), + ) + return { + "ok": True, + "handle": handle, + "app_name": app, + "value_count": len(tags), + "bytes": sum(len(str(v)) for _, v in tags), + "removed": not tags, + "backend": "com", + } + + return await self._run(_sync) + async def block_create_from_entities( self, name, diff --git a/backends/contracts/entity_query.py b/backends/contracts/entity_query.py index a43f82e..9238ee3 100644 --- a/backends/contracts/entity_query.py +++ b/backends/contracts/entity_query.py @@ -156,3 +156,17 @@ class EntityQueryContract(ABC): would cost more than the listing it is meant to make cheap. """ ... + + # ── extended entity data (XDATA) ───────────────────────────────────────── + + @abstractmethod + async def entity_get_xdata(self, handle: str, app_name: str | None = None) -> dict: + """Extended entity data as ``{"handle", "xdata": {app: [values]}, "backend"}``. + ``app_name=None`` returns every app; an absent app is ``{}``, not an error.""" + ... + + @abstractmethod + async def entity_set_xdata(self, handle: str, app_name: str, values: list) -> dict: + """Replace the app's XDATA (registering the APPID if needed). Values are typed + by ``backends/xdata_specs.py``; an empty list removes the app's XDATA.""" + ... diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index f0b6957..d3c656e 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -4518,6 +4518,66 @@ class EzdxfBackend(AutoCADBackend): return await self._async(_sync) + # ── extended entity data (XDATA) ───────────────────────────────────────── + + async def entity_get_xdata(self, handle, app_name=None) -> dict: + from backends.xdata_specs import decode_values, validate_app_name + + wanted = validate_app_name(app_name) if app_name else None + + def _sync(): + ent = self._get_entity(handle) + out: dict[str, list] = {} + if ent.xdata is not None: + apps = [wanted] if wanted else list(ent.xdata.data.keys()) + for app in apps: + if not ent.has_xdata(app): + continue + tags = ent.get_xdata(app) + out[app] = decode_values((tag.code, tag.value) for tag in tags) + return {"handle": handle, "xdata": out, "backend": "ezdxf"} + + return await self._async(_sync) + + async def entity_set_xdata(self, handle, app_name, values) -> dict: + from backends.xdata_specs import encode_values, validate_app_name + + # Validate and type every value before touching the document: a bad + # values[i] raises here and nothing is written. + app = validate_app_name(app_name) + tags = encode_values(values) + + def _sync(): + doc = self._require_doc() + ent = self._get_entity(handle) + if not tags: + ent.discard_xdata(app) + self._mark_dirty() + return { + "ok": True, + "handle": handle, + "app_name": app, + "value_count": 0, + "bytes": 0, + "removed": True, + "backend": "ezdxf", + } + if app not in doc.appids: + doc.appids.add(app) + ent.set_xdata(app, tags) + self._mark_dirty() + return { + "ok": True, + "handle": handle, + "app_name": app, + "value_count": len(tags), + "bytes": sum(len(str(v)) for _, v in tags), + "removed": False, + "backend": "ezdxf", + } + + return await self._async(_sync) + async def block_create_from_entities( self, name, diff --git a/backends/xdata_specs.py b/backends/xdata_specs.py new file mode 100644 index 0000000..a0e3dd1 --- /dev/null +++ b/backends/xdata_specs.py @@ -0,0 +1,102 @@ +"""XDATA value typing and limits, shared by both engines. + +DXF XDATA is a list of (group code, value) pairs under a registered APPID. +This module is the one place that decides how a JSON value becomes a group +code and enforces the two limits AutoCAD enforces silently: 255 characters +per string and 16 KB per entity. +""" + +from __future__ import annotations + +import re + +APP_NAME_RE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]{0,30}$") +RESERVED_APPS = {"ACAD"} +MAX_STRING = 255 +MAX_BYTES = 16 * 1024 +INT_MIN, INT_MAX = -(2**31), 2**31 - 1 + + +def validate_app_name(app) -> str: + if not isinstance(app, str) or not APP_NAME_RE.match(app): + raise ValueError(f"xdata: app name must match {APP_NAME_RE.pattern}, got {app!r}") + if app.upper() in RESERVED_APPS: + raise ValueError(f"xdata: app name {app!r} is reserved") + return app + + +def encode_values(values) -> list[tuple[int, object]]: + """JSON values → (group code, value). Raises TypeError/ValueError; writes nothing.""" + if not isinstance(values, (list, tuple)): + raise TypeError("xdata: values must be a list") + tags: list[tuple[int, object]] = [] + size = 0 + for i, value in enumerate(values): + where = f"values[{i}]" + if isinstance(value, bool): + raise TypeError(f"xdata: {where} booleans have no XDATA group code") + if isinstance(value, str): + if len(value) > MAX_STRING: + raise ValueError( + f"xdata: {where} is {len(value)} characters; the limit is {MAX_STRING}" + ) + tags.append((1000, value)) + size += len(value.encode("utf-8")) + 2 + elif isinstance(value, int): + if not INT_MIN <= value <= INT_MAX: + raise ValueError(f"xdata: {where} is outside the 32-bit integer range") + tags.append((1071, int(value))) + size += 6 + elif isinstance(value, float): + tags.append((1040, float(value))) + size += 10 + elif isinstance(value, (list, tuple)) and len(value) in (2, 3): + if any(isinstance(c, bool) for c in value): + raise TypeError(f"xdata: {where} point coordinates must be numbers") + try: + coords = tuple(float(c) for c in value) + except (TypeError, ValueError) as exc: + raise TypeError(f"xdata: {where} point coordinates must be numbers") from exc + if len(coords) == 2: + coords = (coords[0], coords[1], 0.0) + tags.append((1010, coords)) + size += 26 + else: + raise TypeError(f"xdata: {where} must be a string, int, float or [x, y(, z)] point") + if size > MAX_BYTES: + raise ValueError(f"xdata: encoded size {size} bytes exceeds the 16 KB per-entity limit") + return tags + + +def decode_values(tags) -> list: + """(group code, value) → JSON values. Skips the 1001 app marker and 1002 braces.""" + out: list = [] + for code, value in tags: + if code in (1001, 1002): + continue + if code == 1010 or code in (1011, 1012, 1013): + out.append([float(c) for c in value]) + elif code in (1040, 1041, 1042): + out.append(float(value)) + elif code in (1070, 1071): + out.append(int(value)) + else: + out.append(str(value)) + return out + + +def split_by_app(codes, values) -> dict[str, list]: + """A flat ActiveX GetXData stream (1001 markers inline) → {app: values}.""" + out: dict[str, list] = {} + current: str | None = None + pending: list[tuple[int, object]] = [] + for code, value in zip(codes, values, strict=True): + if code == 1001: + if current is not None: + out[current] = decode_values(pending) + current, pending = str(value), [] + else: + pending.append((code, value)) + if current is not None: + out[current] = decode_values(pending) + return out diff --git a/discovery/aliases.py b/discovery/aliases.py index 9dc4db4..f888d60 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -734,6 +734,14 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "hide an entity", ), ), + "entity_get_xdata": ToolAliases( + acad=("XDLIST",), + synonyms=("extended data", "read xdata", "entity metadata", "registered application data"), + ), + "entity_set_xdata": ToolAliases( + acad=("XDATA",), + synonyms=("attach extended data", "write xdata", "tag an entity with metadata"), + ), "entity_trim": ToolAliases( acad=("TRIM", "TR"), synonyms=( diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index c37b1e5..86c8fc9 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": 155, + "tools": 157, "resources": 6, "prompts": 5, "groups": 19 @@ -17,8 +17,8 @@ "drawing": 11, "engineering": 10, "entity_creation": 18, - "entity_modification": 15, - "entity_query": 8, + "entity_modification": 16, + "entity_query": 9, "layers": 14, "layouts": 12, "premium": 12, @@ -1152,6 +1152,20 @@ "readOnlyHint": true } }, + { + "name": "entity_get_xdata", + "group": "entity_query", + "cost": "read", + "summary": "Read an entity's extended data (XDATA) by application name.", + "tags": [ + "entity", + "query" + ], + "annotations": { + "title": "Get Entity XDATA", + "readOnlyHint": true + } + }, { "name": "entity_list", "group": "entity_query", @@ -1270,6 +1284,20 @@ "destructiveHint": false } }, + { + "name": "entity_set_xdata", + "group": "entity_modification", + "cost": "safe", + "summary": "Attach or replace extended data (XDATA) on an entity.", + "tags": [ + "entity", + "modify" + ], + "annotations": { + "title": "Set Entity XDATA", + "readOnlyHint": false + } + }, { "name": "entity_trim", "group": "corner_ops", diff --git a/server.py b/server.py index bba01b2..c8971e9 100644 --- a/server.py +++ b/server.py @@ -2394,7 +2394,7 @@ async def entity_edit_geometry( # --------------------------------------------------------------------------- -# ── SECTION 5: Entity Query (7 tools) ─────────────────────────────────────── +# ── SECTION 5: Entity Query (9 tools) ─────────────────────────────────────── # --------------------------------------------------------------------------- @@ -2639,6 +2639,42 @@ async def selection_get( return result +@cad_tool(summary="Read an entity's extended data (XDATA) by application name.", cost="read") +@mcp.tool( + annotations={"title": "Get Entity XDATA", "readOnlyHint": True}, + tags={"entity", "query"}, +) +async def entity_get_xdata( + handle: Annotated[str, "Entity handle"], + app_name: Annotated[str | None, "Registered application name; omit for every app"] = None, + ctx: Context = None, +) -> dict: + """Extended entity data as {app: [values]}. An app with no XDATA is simply absent.""" + return await _backend(ctx).entity_get_xdata(handle, app_name) + + +@cad_tool(summary="Attach or replace extended data (XDATA) on an entity.", cost="safe") +@mcp.tool( + annotations={"title": "Set Entity XDATA", "readOnlyHint": False}, + tags={"entity", "modify"}, +) +async def entity_set_xdata( + handle: Annotated[str, "Entity handle"], + app_name: Annotated[str, "Registered application name (created if missing)"], + values: Annotated[ + list, + "Values typed by kind: strings (≤255 chars) → 1000, ints → 1071, floats → 1040, [x,y] → 1010", + ], + ctx: Context = None, +) -> dict: + """Replace the named app's XDATA on one entity; an empty list removes it. + + Limits are AutoCAD's and are enforced before writing: 255 characters per + string and 16 KB per entity. + """ + return await _backend(ctx).entity_set_xdata(handle, app_name, values) + + # --------------------------------------------------------------------------- # ── SECTION 6: Layer Management (14 tools) ────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index be4f96f..22d5f21 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -495,7 +495,7 @@ async def test_tool_groups_is_byte_identical_to_the_source_declared_grouping(): async def test_tool_group_sizes_are_unchanged(): - """Frozen snapshot of the surface (155 tools, 19 groups). + """Frozen snapshot of the surface (157 tools, 19 groups). Taken before @cad_tool landed at 131 tools; `batch` moved 2 -> 3 when v1.5.0's `cad_batch` joined `entity_batch_create`/`entity_batch_modify`, and @@ -504,8 +504,11 @@ async def test_tool_group_sizes_are_unchanged(): `_GROUP_TAG_PRIORITY` ranks `layout` higher, so CHSPACE files under layouts rather than splitting a ninth tool off into entity_modification. `blocks` moved 8 -> 9 when v1.6's `block_define` (typed primitives + ATTDEFs on - both engines) joined SECTION 7. Every other number here has been unchanged - since the snapshot was taken. + both engines) joined SECTION 7. `entity_query` moved 8 -> 9 and + `entity_modification` 15 -> 16 when v1.6's `entity_get_xdata` / + `entity_set_xdata` joined SECTION 5 (the getter is tagged `query`, the + setter `modify`, so the pair splits across two groups). 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 == { @@ -517,8 +520,8 @@ async def test_tool_group_sizes_are_unchanged(): "drawing": 11, "engineering": 10, "entity_creation": 18, - "entity_modification": 15, - "entity_query": 8, + "entity_modification": 16, + "entity_query": 9, "layers": 14, "layouts": 12, "premium": 12, @@ -529,4 +532,4 @@ async def test_tool_group_sizes_are_unchanged(): "validation": 1, "view": 4, } - assert sum(sizes.values()) == 155 + assert sum(sizes.values()) == 157 diff --git a/tests/test_xdata.py b/tests/test_xdata.py new file mode 100644 index 0000000..e78d195 --- /dev/null +++ b/tests/test_xdata.py @@ -0,0 +1,172 @@ +"""XDATA round trips, typing by group code, limits, removal — both engines.""" + +from __future__ import annotations + +import types + +import pytest + +from backends.xdata_specs import ( + MAX_BYTES, + MAX_STRING, + decode_values, + encode_values, + split_by_app, + validate_app_name, +) + +pytestmark = pytest.mark.asyncio + + +# ── pure encoding ──────────────────────────────────────────────────────────── + + +def test_values_are_typed_by_group_code(): + assert encode_values(["json", 7, 2.5, [1, 2], [1, 2, 3]]) == [ + (1000, "json"), + (1071, 7), + (1040, 2.5), + (1010, (1.0, 2.0, 0.0)), + (1010, (1.0, 2.0, 3.0)), + ] + + +def test_decode_is_the_inverse(): + tags = encode_values(["a", 1, 1.5, [3, 4]]) + assert decode_values(tags) == ["a", 1, 1.5, [3.0, 4.0, 0.0]] + + +@pytest.mark.parametrize("bad", [True, None, {"a": 1}, [1], [1, 2, 3, 4], ["x", "y"], 2**31]) +def test_untypeable_values_are_refused(bad): + with pytest.raises((TypeError, ValueError)): + encode_values([bad]) + + +def test_string_over_255_is_refused(): + encode_values(["x" * MAX_STRING]) + with pytest.raises(ValueError, match="255"): + encode_values(["x" * (MAX_STRING + 1)]) + + +def test_total_size_over_16k_is_refused(): + with pytest.raises(ValueError, match="16"): + encode_values(["x" * 255] * (MAX_BYTES // 255 + 1)) + + +@pytest.mark.parametrize("bad", ["", "1abc", "a-b", "a" * 32, "ACAD"]) +def test_bad_app_names_are_refused(bad): + with pytest.raises(ValueError): + validate_app_name(bad) + + +def test_split_by_app_groups_a_flat_activex_stream(): + codes = [1001, 1000, 1071, 1001, 1040] + values = ["APP_A", "hello", 3, "APP_B", 1.5] + assert split_by_app(codes, values) == {"APP_A": ["hello", 3], "APP_B": [1.5]} + + +# ── headless engine ───────────────────────────────────────────────────────── + + +async def test_roundtrip_registers_the_appid(backend): + line = await backend.entity_create_line(0, 0, 1, 1) + result = await backend.entity_set_xdata(line.handle, "ACADMCP_PID", ["json", 42, 1.5, [1, 2]]) + assert result["ok"] is True and result["value_count"] == 4 and result["removed"] is False + assert "ACADMCP_PID" in backend._doc.appids + got = await backend.entity_get_xdata(line.handle, "ACADMCP_PID") + assert got["xdata"] == {"ACADMCP_PID": ["json", 42, 1.5, [1.0, 2.0, 0.0]]} + + +async def test_get_without_app_returns_every_app(backend): + line = await backend.entity_create_line(0, 0, 1, 1) + await backend.entity_set_xdata(line.handle, "APP_A", ["a"]) + await backend.entity_set_xdata(line.handle, "APP_B", ["b"]) + got = await backend.entity_get_xdata(line.handle) + assert got["xdata"] == {"APP_A": ["a"], "APP_B": ["b"]} + + +async def test_unknown_app_is_empty_not_an_error(backend): + line = await backend.entity_create_line(0, 0, 1, 1) + got = await backend.entity_get_xdata(line.handle, "NOPE") + assert got["xdata"] == {} + + +async def test_set_replaces_and_empty_removes(backend): + line = await backend.entity_create_line(0, 0, 1, 1) + await backend.entity_set_xdata(line.handle, "APP_A", ["one", "two"]) + await backend.entity_set_xdata(line.handle, "APP_A", ["three"]) + assert (await backend.entity_get_xdata(line.handle, "APP_A"))["xdata"] == {"APP_A": ["three"]} + result = await backend.entity_set_xdata(line.handle, "APP_A", []) + assert result["removed"] is True + assert (await backend.entity_get_xdata(line.handle))["xdata"] == {} + + +async def test_limits_are_enforced_before_writing(backend): + line = await backend.entity_create_line(0, 0, 1, 1) + with pytest.raises(ValueError, match="255"): + await backend.entity_set_xdata(line.handle, "APP_A", ["x" * 256]) + assert (await backend.entity_get_xdata(line.handle))["xdata"] == {} + + +# ── live engine, fake ActiveX ──────────────────────────────────────────────── + + +class _FakeEntity: + def __init__(self): + self.stored: tuple | None = None + + def SetXData(self, types_, values): + self.stored = (list(types_.value), list(values.value)) + + def GetXData(self, app): + if self.stored is None: + return ([], []) + codes, values = self.stored + if app: + return (codes, values) + return (codes, values) + + +@pytest.fixture +def com_backend(monkeypatch): + pytest.importorskip("win32com.client", reason="pywin32 not installed") + from backends import com_backend as module + + entity = _FakeEntity() + registered: list[str] = [] + document = types.SimpleNamespace( + HandleToObject=lambda handle: entity, + RegisteredApplications=types.SimpleNamespace(Add=registered.append), + ) + 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, entity, registered + + +async def test_com_set_writes_the_1001_marker_first(com_backend): + backend, entity, registered = com_backend + await backend.entity_set_xdata("2F", "ACADMCP_PID", ["json", 3, [1, 2]]) + assert registered == ["ACADMCP_PID"] + codes, values = entity.stored + assert codes == [1001, 1000, 1071, 1010] + assert values[0] == "ACADMCP_PID" and values[1] == "json" and values[2] == 3 + assert tuple(values[3].value) == (1.0, 2.0, 0.0) + + +async def test_com_get_splits_by_app(com_backend): + backend, entity, _ = com_backend + await backend.entity_set_xdata("2F", "ACADMCP_PID", ["json"]) + got = await backend.entity_get_xdata("2F", "ACADMCP_PID") + assert got["xdata"] == {"ACADMCP_PID": ["json"]} + + +async def test_com_empty_values_write_only_the_marker(com_backend): + backend, entity, _ = com_backend + result = await backend.entity_set_xdata("2F", "ACADMCP_PID", []) + assert result["removed"] is True + assert entity.stored == ([1001], ["ACADMCP_PID"])