feat(entity): entity_get_xdata / entity_set_xdata on both engines
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
a84cae2641
commit
cda8b69dba
@@ -19,7 +19,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> **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`,
|
> 154 is the **registered** count; a default install advertises 149 over `tools/list`,
|
||||||
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
|
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
|
||||||
|
|
||||||
|
|||||||
@@ -3085,6 +3085,61 @@ class ComBackend(AutoCADBackend):
|
|||||||
|
|
||||||
return await self._run(_sync)
|
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(
|
async def block_create_from_entities(
|
||||||
self,
|
self,
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -156,3 +156,17 @@ class EntityQueryContract(ABC):
|
|||||||
would cost more than the listing it is meant to make cheap.
|
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."""
|
||||||
|
...
|
||||||
|
|||||||
@@ -4518,6 +4518,66 @@ class EzdxfBackend(AutoCADBackend):
|
|||||||
|
|
||||||
return await self._async(_sync)
|
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(
|
async def block_create_from_entities(
|
||||||
self,
|
self,
|
||||||
name,
|
name,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -734,6 +734,14 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
|
|||||||
"hide an entity",
|
"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(
|
"entity_trim": ToolAliases(
|
||||||
acad=("TRIM", "TR"),
|
acad=("TRIM", "TR"),
|
||||||
synonyms=(
|
synonyms=(
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"package_version": "1.5.1",
|
"package_version": "1.5.1",
|
||||||
"totals": {
|
"totals": {
|
||||||
"tools": 155,
|
"tools": 157,
|
||||||
"resources": 6,
|
"resources": 6,
|
||||||
"prompts": 5,
|
"prompts": 5,
|
||||||
"groups": 19
|
"groups": 19
|
||||||
@@ -17,8 +17,8 @@
|
|||||||
"drawing": 11,
|
"drawing": 11,
|
||||||
"engineering": 10,
|
"engineering": 10,
|
||||||
"entity_creation": 18,
|
"entity_creation": 18,
|
||||||
"entity_modification": 15,
|
"entity_modification": 16,
|
||||||
"entity_query": 8,
|
"entity_query": 9,
|
||||||
"layers": 14,
|
"layers": 14,
|
||||||
"layouts": 12,
|
"layouts": 12,
|
||||||
"premium": 12,
|
"premium": 12,
|
||||||
@@ -1152,6 +1152,20 @@
|
|||||||
"readOnlyHint": true
|
"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",
|
"name": "entity_list",
|
||||||
"group": "entity_query",
|
"group": "entity_query",
|
||||||
@@ -1270,6 +1284,20 @@
|
|||||||
"destructiveHint": false
|
"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",
|
"name": "entity_trim",
|
||||||
"group": "corner_ops",
|
"group": "corner_ops",
|
||||||
|
|||||||
@@ -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
|
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) ──────────────────────────────────
|
# ── SECTION 6: Layer Management (14 tools) ──────────────────────────────────
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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():
|
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
|
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
|
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
|
`_GROUP_TAG_PRIORITY` ranks `layout` higher, so CHSPACE files under layouts
|
||||||
rather than splitting a ninth tool off into entity_modification. `blocks`
|
rather than splitting a ninth tool off into entity_modification. `blocks`
|
||||||
moved 8 -> 9 when v1.6's `block_define` (typed primitives + ATTDEFs on
|
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
|
both engines) joined SECTION 7. `entity_query` moved 8 -> 9 and
|
||||||
since the snapshot was taken.
|
`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()}
|
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
|
||||||
assert sizes == {
|
assert sizes == {
|
||||||
@@ -517,8 +520,8 @@ async def test_tool_group_sizes_are_unchanged():
|
|||||||
"drawing": 11,
|
"drawing": 11,
|
||||||
"engineering": 10,
|
"engineering": 10,
|
||||||
"entity_creation": 18,
|
"entity_creation": 18,
|
||||||
"entity_modification": 15,
|
"entity_modification": 16,
|
||||||
"entity_query": 8,
|
"entity_query": 9,
|
||||||
"layers": 14,
|
"layers": 14,
|
||||||
"layouts": 12,
|
"layouts": 12,
|
||||||
"premium": 12,
|
"premium": 12,
|
||||||
@@ -529,4 +532,4 @@ async def test_tool_group_sizes_are_unchanged():
|
|||||||
"validation": 1,
|
"validation": 1,
|
||||||
"view": 4,
|
"view": 4,
|
||||||
}
|
}
|
||||||
assert sum(sizes.values()) == 155
|
assert sum(sizes.values()) == 157
|
||||||
|
|||||||
@@ -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"])
|
||||||
Reference in New Issue
Block a user