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
@@ -27,7 +27,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
> **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`,
|
> 166 is the **registered** count; a default install advertises 161 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.
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ from .base import (
|
|||||||
rad2deg,
|
rad2deg,
|
||||||
shoelace_area,
|
shoelace_area,
|
||||||
)
|
)
|
||||||
|
from .contracts.settings import SUMMARY_FIELDS, validate_drawing_properties
|
||||||
|
|
||||||
log = logging.getLogger(__name__)
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -195,6 +196,9 @@ def _msp():
|
|||||||
|
|
||||||
_BUILTIN_LINETYPES = {"continuous", "bylayer", "byblock"}
|
_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:
|
def _ensure_linetype_loaded(name: str) -> None:
|
||||||
"""If `name` is not already loaded, load it via -LINETYPE behind FILEDIA=0.
|
"""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(),
|
"solid_3d": _solid_3d_capability(),
|
||||||
"lisp": FeatureCapability(True, "sanitized"),
|
"lisp": FeatureCapability(True, "sanitized"),
|
||||||
"registry_sysvar": FeatureCapability(True, "native"),
|
"registry_sysvar": FeatureCapability(True, "native"),
|
||||||
|
"dwgprops": FeatureCapability(True, "native"),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -4257,3 +4262,61 @@ class ComBackend(AutoCADBackend):
|
|||||||
return _entity_info(xline)
|
return _entity_info(xline)
|
||||||
|
|
||||||
return await self._run(_sync)
|
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)
|
||||||
|
|||||||
@@ -505,3 +505,53 @@ def _decode_setting(key: str, kind: str, raw: Any) -> Any:
|
|||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return raw
|
return raw
|
||||||
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
|
||||||
|
|||||||
@@ -39,7 +39,12 @@ from .base import (
|
|||||||
normalize_lineweight,
|
normalize_lineweight,
|
||||||
shoelace_area,
|
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 (
|
from .quarantine import (
|
||||||
AbandonedCall,
|
AbandonedCall,
|
||||||
DocumentQuarantineError,
|
DocumentQuarantineError,
|
||||||
@@ -1110,6 +1115,10 @@ class EzdxfBackend(AutoCADBackend):
|
|||||||
"registry_sysvar": FeatureCapability(
|
"registry_sysvar": FeatureCapability(
|
||||||
False, reason="registry_saved_variables_have_no_home_in_a_file"
|
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:
|
else:
|
||||||
entry.dxf.value = name
|
entry.dxf.value = name
|
||||||
return 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)
|
||||||
|
|||||||
@@ -72,12 +72,15 @@ class ToolAliases:
|
|||||||
# PLINE -- a raw polyline (entity_create_polyline) or a P&ID line run
|
# 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
|
# between two ports (pid_line_draw), which is an LWPOLYLINE with a
|
||||||
# class, a number and markers; a drafter typing it could mean either.
|
# 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.
|
# Every other AutoCAD command must map to exactly one tool.
|
||||||
SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
|
SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
|
||||||
{
|
{
|
||||||
"ARRAY",
|
"ARRAY",
|
||||||
"BLOCK",
|
"BLOCK",
|
||||||
|
"DWGPROPS",
|
||||||
"ERASE",
|
"ERASE",
|
||||||
"INSERT",
|
"INSERT",
|
||||||
"LAYER",
|
"LAYER",
|
||||||
@@ -453,6 +456,28 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
|
|||||||
"drawing intent",
|
"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(
|
"drawing_preflight": ToolAliases(
|
||||||
synonyms=(
|
synonyms=(
|
||||||
"preflight",
|
"preflight",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"schema_version": 1,
|
"schema_version": 1,
|
||||||
"package_version": "1.5.1",
|
"package_version": "1.5.1",
|
||||||
"totals": {
|
"totals": {
|
||||||
"tools": 167,
|
"tools": 169,
|
||||||
"resources": 8,
|
"resources": 8,
|
||||||
"prompts": 5,
|
"prompts": 5,
|
||||||
"groups": 20
|
"groups": 20
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
"blocks": 9,
|
"blocks": 9,
|
||||||
"corner_ops": 4,
|
"corner_ops": 4,
|
||||||
"dimensions": 5,
|
"dimensions": 5,
|
||||||
"drawing": 11,
|
"drawing": 13,
|
||||||
"engineering": 10,
|
"engineering": 10,
|
||||||
"entity_creation": 18,
|
"entity_creation": 18,
|
||||||
"entity_modification": 16,
|
"entity_modification": 16,
|
||||||
@@ -631,6 +631,34 @@
|
|||||||
"readOnlyHint": true
|
"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",
|
"name": "drawing_purge",
|
||||||
"group": "drawing",
|
"group": "drawing",
|
||||||
|
|||||||
@@ -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,
|
# 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
|
# 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 ───────────────────────────────────────────────────────────────
|
# ── RESOURCES ───────────────────────────────────────────────────────────────
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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
|
tag is ranked first in `_GROUP_TAG_PRIORITY` so those tools file under
|
||||||
`pid` rather than under their secondary `query` / `create` tags. `system`
|
`pid` rather than under their secondary `query` / `create` tags. `system`
|
||||||
moved 7 -> 8 when v1.6's `system_variable_describe` (the sysvar catalogue
|
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
|
as a read-only tool) opened SECTION 20. `drawing` moved 11 -> 13 when
|
||||||
unchanged since the snapshot was taken.
|
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()}
|
sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
|
||||||
assert sizes == {
|
assert sizes == {
|
||||||
@@ -529,7 +532,7 @@ async def test_tool_group_sizes_are_unchanged():
|
|||||||
"blocks": 9,
|
"blocks": 9,
|
||||||
"corner_ops": 4,
|
"corner_ops": 4,
|
||||||
"dimensions": 5,
|
"dimensions": 5,
|
||||||
"drawing": 11,
|
"drawing": 13,
|
||||||
"engineering": 10,
|
"engineering": 10,
|
||||||
"entity_creation": 18,
|
"entity_creation": 18,
|
||||||
"entity_modification": 16,
|
"entity_modification": 16,
|
||||||
@@ -545,4 +548,4 @@ async def test_tool_group_sizes_are_unchanged():
|
|||||||
"validation": 1,
|
"validation": 1,
|
||||||
"view": 4,
|
"view": 4,
|
||||||
}
|
}
|
||||||
assert sum(sizes.values()) == 167
|
assert sum(sizes.values()) == 169
|
||||||
|
|||||||
Reference in New Issue
Block a user