feat(environment): multi-document on both engines — ezdxf document registry, document_list/activate/close, drawing_close folded in
EnvironmentContract carries every member of the track E interface; the two
drawing_properties_* members are @capability('dwgprops') in this worktree only
and become @abstractmethod when group C's implementations merge.
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
49fc6ac6a5
commit
174fb8010c
@@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co
|
||||
|
||||
</div>
|
||||
|
||||
> **v1.5 release snapshot:** 166 tools · 8 resources · 5 prompt templates · 2423 collected tests.
|
||||
> 166 is the **registered** count; a default install advertises 161 over `tools/list`,
|
||||
> **v1.5 release snapshot:** 169 tools · 8 resources · 5 prompt templates · 2423 collected tests.
|
||||
> 169 is the **registered** count; a default install advertises 164 over `tools/list`,
|
||||
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.
|
||||
|
||||
## Why this exists
|
||||
@@ -104,6 +104,7 @@ Claude Desktop, Cursor, or any stdio MCP host. For HTTP: `autocad-mcp --transpor
|
||||
| Discovery | `search_tools` ranked over an AutoCAD command and synonym corpus — `FILLET`, `BPOLY`, `QSELECT`, `WBLOCK`, `OVERKILL`, `CHSPACE` each rank **#1** of the 161-tool advertised catalog |
|
||||
| Batching | `cad_batch` runs a step list in one round trip; `fields=` projects 11 result-heavy tools |
|
||||
| Paper space | tab lifecycle, viewports, `entity_change_space` (CHSPACE), `drawing_export_pdf(layout=…)` |
|
||||
| Environment | `document_list/activate/close` — several drawings open at once on both engines, the active one is what every tool targets |
|
||||
| Selection | window vs crossing stated back to the caller; a polygon tested against its own shape, not its bounding box |
|
||||
| Boundaries | `boundary_trace` (BOUNDARY/BPOLY) chains loose edges into one closed polyline, arcs kept as bulges *(headless)* |
|
||||
| Measurement | `analysis_measure_entity` measures what is *in* the drawing, by handle |
|
||||
|
||||
@@ -25,6 +25,7 @@ from backends.contracts import (
|
||||
EntityCreationContract,
|
||||
EntityModificationContract,
|
||||
EntityQueryContract,
|
||||
EnvironmentContract,
|
||||
GdtContract,
|
||||
IdentityContract,
|
||||
LayerContract,
|
||||
@@ -184,6 +185,7 @@ class AutoCADBackend(
|
||||
PremiumContract,
|
||||
GdtContract,
|
||||
SettingsContract,
|
||||
EnvironmentContract,
|
||||
):
|
||||
"""All backends implement this interface.
|
||||
|
||||
|
||||
+133
-7
@@ -814,6 +814,14 @@ class ComBackend(AutoCADBackend):
|
||||
"viewport_render": FeatureCapability(True, "native"),
|
||||
"solid_3d": _solid_3d_capability(),
|
||||
"lisp": FeatureCapability(True, "sanitized"),
|
||||
"documents": FeatureCapability(True, "native"),
|
||||
"live_application": FeatureCapability(True, "native"),
|
||||
"preferences": FeatureCapability(True, "native", reason="whitelisted_keys_only"),
|
||||
"interactive_prompt": FeatureCapability(
|
||||
True, "native", reason="cancel_returns_cancelled_true;timeout_returns_timed_out"
|
||||
),
|
||||
# Byte-identical to group C's Task 16 entry; the merge agent keeps one.
|
||||
"dwgprops": FeatureCapability(True, "native"),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1276,6 +1284,125 @@ class ComBackend(AutoCADBackend):
|
||||
|
||||
return await self._run(_sync)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ── environment (track E) ───────────────────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _com_documents(app) -> list:
|
||||
return [app.Documents.Item(i) for i in range(int(app.Documents.Count))]
|
||||
|
||||
@staticmethod
|
||||
def _com_document_row(doc, active) -> dict:
|
||||
full_name = str(doc.FullName)
|
||||
return {
|
||||
"name": str(doc.Name),
|
||||
"path": full_name or None,
|
||||
"active": active is not None
|
||||
and (str(doc.Name), full_name) == (str(active.Name), str(active.FullName)),
|
||||
"saved": bool(doc.Saved),
|
||||
"entity_count": int(doc.ModelSpace.Count),
|
||||
}
|
||||
|
||||
def _com_find_document(self, app, name_or_path: str):
|
||||
"""Match a key the way ``_resolve_document_key`` does: full path, then name."""
|
||||
if not isinstance(name_or_path, str):
|
||||
raise TypeError(f"name_or_path must be a string, got {type(name_or_path).__name__}")
|
||||
wanted = name_or_path.strip()
|
||||
if not wanted:
|
||||
raise ValueError("name_or_path must not be empty")
|
||||
docs = self._com_documents(app)
|
||||
try:
|
||||
as_path = str(Path(wanted).resolve()).lower()
|
||||
except (OSError, ValueError):
|
||||
as_path = wanted.lower()
|
||||
by_path = [
|
||||
d
|
||||
for d in docs
|
||||
if str(d.FullName) and str(Path(str(d.FullName)).resolve()).lower() == as_path
|
||||
]
|
||||
by_name = [d for d in docs if str(d.Name).lower() == wanted.lower()]
|
||||
hits = by_path or by_name
|
||||
if len(hits) == 1:
|
||||
return hits[0]
|
||||
names = [str(d.Name) for d in docs]
|
||||
if len(hits) > 1:
|
||||
raise ValueError(f"document {wanted!r} is ambiguous: {names}; pass the full path")
|
||||
raise ValueError(f"no open document named {wanted!r}; open documents: {names}")
|
||||
|
||||
async def document_list(self) -> list[dict]:
|
||||
def _sync():
|
||||
app = _acad_app()
|
||||
active = app.ActiveDocument if int(app.Documents.Count) else None
|
||||
return [self._com_document_row(doc, active) for doc in self._com_documents(app)]
|
||||
|
||||
return await self._run(_sync)
|
||||
|
||||
async def document_activate(self, name_or_path: str) -> dict:
|
||||
def _sync():
|
||||
app = _acad_app()
|
||||
previous = str(app.ActiveDocument.Name) if int(app.Documents.Count) else None
|
||||
doc = self._com_find_document(app, name_or_path)
|
||||
doc.Activate()
|
||||
return {
|
||||
"ok": True,
|
||||
"active": str(doc.Name),
|
||||
"previous": previous,
|
||||
"name": str(doc.Name),
|
||||
"path": str(doc.FullName) or None,
|
||||
}
|
||||
|
||||
result = await self._run(_sync)
|
||||
await self._ensure_document_state()
|
||||
return result
|
||||
|
||||
async def document_close(
|
||||
self, name_or_path: str | None = None, save: bool = False, discard: bool = False
|
||||
) -> dict:
|
||||
if save and discard:
|
||||
raise ValueError("document_close: save and discard are mutually exclusive")
|
||||
|
||||
def _sync():
|
||||
app = _acad_app()
|
||||
doc = (
|
||||
_acad_doc() if name_or_path is None else self._com_find_document(app, name_or_path)
|
||||
)
|
||||
name = str(doc.Name)
|
||||
path = str(doc.FullName) or None
|
||||
dirty = not bool(doc.Saved)
|
||||
if dirty:
|
||||
if save:
|
||||
if not path:
|
||||
raise ValueError(
|
||||
f"document_close: {name!r} has never been saved; Close(True) would "
|
||||
"open AutoCAD's Save dialog and block the COM thread. "
|
||||
"drawing_save_as(path) first, or pass discard=True"
|
||||
)
|
||||
elif not discard:
|
||||
where = f" to {path}" if path else " (after drawing_save_as)"
|
||||
raise ValueError(
|
||||
f"document_close: {name!r} has unsaved changes; pass save=True to "
|
||||
f"write them{where}, or discard=True to drop them"
|
||||
)
|
||||
# A clean or untitled document is never asked to save: Close(True)
|
||||
# on a clean file is a no-op write, on an untitled one a dialog.
|
||||
doc.Close(bool(save and dirty and path))
|
||||
count = int(app.Documents.Count)
|
||||
return {
|
||||
"ok": True,
|
||||
"closed": name,
|
||||
"path": path,
|
||||
"saved": bool(save and dirty and path),
|
||||
"discarded_changes": bool(dirty and not save),
|
||||
"active": str(app.ActiveDocument.Name) if count else None,
|
||||
"open_documents": count,
|
||||
"backend": "com",
|
||||
}
|
||||
|
||||
result = await self._run(_sync)
|
||||
await self._ensure_document_state()
|
||||
return result
|
||||
|
||||
# ── selection filters (M8 / F1) ──────────────────────────────────────────
|
||||
#
|
||||
# VERIFIED against a live AutoCAD 2026 (2026-08-05).
|
||||
@@ -2119,14 +2246,13 @@ class ComBackend(AutoCADBackend):
|
||||
return await self._run(_sync)
|
||||
|
||||
async def drawing_close(self, save: bool = True) -> dict:
|
||||
def _sync():
|
||||
doc = _acad_doc()
|
||||
doc.Close(save)
|
||||
return {"ok": True}
|
||||
"""The active-document case of ``document_close``.
|
||||
|
||||
result = await self._run(_sync)
|
||||
await self._ensure_document_state()
|
||||
return result
|
||||
``Close(True)`` on a drawing that has never been saved opens AutoCAD's
|
||||
Save dialog and blocks the single STA thread until ``COM_CALL_TIMEOUT``;
|
||||
that is refused here by name rather than waited out.
|
||||
"""
|
||||
return await self.document_close(None, save=save, discard=not save)
|
||||
|
||||
async def drawing_undo(self) -> dict:
|
||||
def _sync():
|
||||
|
||||
@@ -16,6 +16,7 @@ from backends.contracts.drawing import DrawingContract
|
||||
from backends.contracts.entity_creation import EntityCreationContract
|
||||
from backends.contracts.entity_modification import EntityModificationContract
|
||||
from backends.contracts.entity_query import EntityQueryContract
|
||||
from backends.contracts.environment import EnvironmentContract
|
||||
from backends.contracts.gdt import GdtContract
|
||||
from backends.contracts.identity import IdentityContract
|
||||
from backends.contracts.layers import LayerContract
|
||||
@@ -37,6 +38,7 @@ __all__ = [
|
||||
"EntityCreationContract",
|
||||
"EntityModificationContract",
|
||||
"EntityQueryContract",
|
||||
"EnvironmentContract",
|
||||
"GdtContract",
|
||||
"IdentityContract",
|
||||
"LayerContract",
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Environment: documents, layer states, named views, UCS, application,
|
||||
preferences, drawing properties and operator prompts (v1.6 track E).
|
||||
|
||||
``AutoCADBackend`` composes every contract in this package, so importing
|
||||
``backends.base.AutoCADBackend`` is unchanged for callers.
|
||||
|
||||
Ownership: group V owns this module. The ``# ── properties ──`` block is
|
||||
declared here and *implemented* by group C on both backends; the six
|
||||
``@capability`` members are implemented on the COM backend only and refuse on
|
||||
ezdxf with a key both capability maps declare.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from backends.capability import capability
|
||||
|
||||
|
||||
class EnvironmentContract(ABC):
|
||||
# ── documents ────────────────────────────────────────────────────────────
|
||||
|
||||
@abstractmethod
|
||||
async def document_list(self) -> list[dict]:
|
||||
"""``[{"name", "path", "active": bool, "saved": bool, "entity_count"}]``."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def document_activate(self, name_or_path: str) -> dict:
|
||||
"""Make one open document the target of every later call.
|
||||
→ ``{"ok", "active", "previous"}``."""
|
||||
...
|
||||
|
||||
@abstractmethod
|
||||
async def document_close(
|
||||
self, name_or_path: str | None = None, save: bool = False, discard: bool = False
|
||||
) -> dict:
|
||||
"""Close one document (default: the active one).
|
||||
|
||||
Unsaved and neither ``save`` nor ``discard`` → ``ValueError`` naming the
|
||||
document; ``save`` on a document that has never been saved →
|
||||
``ValueError`` (no path to write, and the live engine would open a
|
||||
modal Save dialog). → ``{"ok", "closed", "saved": bool, "active": name|None}``.
|
||||
"""
|
||||
...
|
||||
|
||||
# ── layer states, named views, UCS: appended by Task 22 ──────────────────
|
||||
|
||||
# ── application, preferences, operator prompts (COM only) ───────────────
|
||||
|
||||
@capability( # noqa: B027 — @capability supplies the body
|
||||
"live_application",
|
||||
reason="only a live CAD application can be launched or attached",
|
||||
)
|
||||
async def system_launch(self, visible: bool = True, open_path: str | None = None) -> dict:
|
||||
"""→ ``{"launched": bool, "attached": bool, "version", "document"}``."""
|
||||
...
|
||||
|
||||
@capability( # noqa: B027 — @capability supplies the body
|
||||
"preferences",
|
||||
reason="AutoCAD preferences live in the running application, not in a file",
|
||||
)
|
||||
async def preferences_get(self, keys: list[str] | None = None) -> dict:
|
||||
"""→ ``{"values": {key: value}, "read_only": [keys]}``."""
|
||||
...
|
||||
|
||||
@capability( # noqa: B027 — @capability supplies the body
|
||||
"preferences",
|
||||
reason="AutoCAD preferences live in the running application, not in a file",
|
||||
)
|
||||
async def preferences_set(self, key: str, value) -> dict:
|
||||
"""→ ``{"ok", "key", "old", "new"}``."""
|
||||
...
|
||||
|
||||
@capability( # noqa: B027 — @capability supplies the body
|
||||
"interactive_prompt",
|
||||
reason="prompting the operator needs a live AutoCAD session",
|
||||
)
|
||||
async def user_pick_point(self, prompt: str) -> dict:
|
||||
"""→ ``{"cancelled": bool, "x", "y"}`` | ``{"timed_out": True}``."""
|
||||
...
|
||||
|
||||
@capability( # noqa: B027 — @capability supplies the body
|
||||
"interactive_prompt",
|
||||
reason="prompting the operator needs a live AutoCAD session",
|
||||
)
|
||||
async def user_select(self, prompt: str, mode: str = "single") -> dict:
|
||||
"""→ ``{"cancelled", "handles": [...]}``."""
|
||||
...
|
||||
|
||||
@capability( # noqa: B027 — @capability supplies the body
|
||||
"interactive_prompt",
|
||||
reason="prompting the operator needs a live AutoCAD session",
|
||||
)
|
||||
async def system_prompt_message(self, text: str) -> dict:
|
||||
"""→ ``{"ok"}``."""
|
||||
...
|
||||
|
||||
# ── properties (declared here; implemented by group C on both engines) ───
|
||||
|
||||
# TEMPORARY (track-e/environment worktree only — the merge agent deletes this
|
||||
# block and the two decorators below become @abstractmethod): until group C's
|
||||
# implementations land, declaring these abstract would stop both backends from
|
||||
# instantiating here. They refuse with a declared key rather than pretend.
|
||||
|
||||
@capability("dwgprops", reason="drawing properties arrive with group C's merge") # noqa: B027
|
||||
async def drawing_properties_get(self) -> dict:
|
||||
"""→ ``{"summary": {"title","subject","author","keywords","comments"}
|
||||
(None each headless), "summary_available": bool, "custom": {k: v}}``."""
|
||||
...
|
||||
|
||||
@capability("dwgprops", reason="drawing properties arrive with group C's merge") # noqa: B027
|
||||
async def drawing_properties_set(
|
||||
self, summary: dict | None = None, custom: dict | None = None
|
||||
) -> dict:
|
||||
"""ezdxf: ``summary`` with any non-None field → raise
|
||||
``UnsupportedCapabilityError("dwgprops")``; ``custom`` → header
|
||||
custom_vars (replace keys given; value None deletes).
|
||||
→ ``{"ok", "summary_written": [...], "custom_written": [...], "custom_deleted": [...]}``."""
|
||||
...
|
||||
+401
-48
@@ -18,6 +18,7 @@ import math
|
||||
import os
|
||||
import tempfile
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from importlib.util import find_spec
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -950,6 +951,69 @@ def _layer_info_dxf(layer_obj, current_name: str) -> LayerInfo:
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ── environment (track E): document registry ────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class _DocState:
|
||||
"""Everything the backend keeps per open document.
|
||||
|
||||
Before v1.6 these were twelve attributes on the backend itself, which is
|
||||
why it could hold exactly one drawing. The registry holds one of these
|
||||
per open document; the old attribute names survive as descriptors over
|
||||
the *active* entry so no existing method had to change.
|
||||
"""
|
||||
|
||||
key: str
|
||||
doc: Any = None
|
||||
path: str | None = None
|
||||
dirty: bool = False
|
||||
current_layer: str = "0"
|
||||
current_space: str = "Model"
|
||||
undo_stack: list[Path] = field(default_factory=list)
|
||||
redo_stack: list[Path] = field(default_factory=list)
|
||||
transaction_stack: list[Path] = field(default_factory=list)
|
||||
plan_spec: Any = None
|
||||
preflight_result: Any = None
|
||||
gdt_datums_defined: set = field(default_factory=set)
|
||||
gdt_datums_referenced: set = field(default_factory=set)
|
||||
activated_seq: int = 0
|
||||
|
||||
@property
|
||||
def label(self) -> str:
|
||||
return Path(self.path).name if self.path else self.key
|
||||
|
||||
def discard_snapshots(self) -> None:
|
||||
for stack in (self.undo_stack, self.redo_stack, self.transaction_stack):
|
||||
while stack:
|
||||
try:
|
||||
stack.pop().unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
class _ActiveField:
|
||||
"""A backend attribute that lives on the active document's ``_DocState``.
|
||||
|
||||
A data descriptor, so it also overrides the plain class attributes the
|
||||
``PremiumContract`` mixin declares (``_plan_spec = None``) — the mixin's
|
||||
``self._plan_spec = plan`` lands on the active document.
|
||||
"""
|
||||
|
||||
def __init__(self, attr: str):
|
||||
self.attr = attr
|
||||
|
||||
def __get__(self, instance, owner=None):
|
||||
if instance is None:
|
||||
return self
|
||||
return getattr(instance._state, self.attr)
|
||||
|
||||
def __set__(self, instance, value):
|
||||
setattr(instance._state, self.attr, value)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EzdxfBackend
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -958,17 +1022,32 @@ def _layer_info_dxf(layer_obj, current_name: str) -> LayerInfo:
|
||||
class EzdxfBackend(AutoCADBackend):
|
||||
"""File-based ezdxf backend – no live AutoCAD needed."""
|
||||
|
||||
# Old per-backend attributes, now routed to the active document (v1.6).
|
||||
# `_doc` and `_doc_path` are explicit properties below (binding and
|
||||
# re-keying rules); the rest are plain views.
|
||||
_dirty = _ActiveField("dirty")
|
||||
_current_layer = _ActiveField("current_layer")
|
||||
_current_space = _ActiveField("current_space")
|
||||
_undo_stack = _ActiveField("undo_stack")
|
||||
_redo_stack = _ActiveField("redo_stack")
|
||||
_transaction_stack = _ActiveField("transaction_stack")
|
||||
_plan_spec = _ActiveField("plan_spec")
|
||||
_preflight_result = _ActiveField("preflight_result")
|
||||
_gdt_datums_defined = _ActiveField("gdt_datums_defined")
|
||||
_gdt_datums_referenced = _ActiveField("gdt_datums_referenced")
|
||||
|
||||
def __init__(self):
|
||||
if not _EZDXF_OK:
|
||||
raise RuntimeError("ezdxf is not installed. Run: pip install ezdxf")
|
||||
self._doc: Any = None # ezdxf Drawing object
|
||||
self._doc_path: str | None = None
|
||||
self._dirty: bool = False
|
||||
self._current_layer: str = "0"
|
||||
self._undo_stack: list[Path] = [] # user-facing undo history
|
||||
self._redo_stack: list[Path] = [] # states stepped off by an undo
|
||||
self._current_space: str = "Model" # where entity_create_* writes
|
||||
self._transaction_stack: list[Path] = [] # isolated transaction snapshots
|
||||
# v1.6: one entry per open document; the old attributes above are
|
||||
# views over the active one. `_void` is what they read and write when
|
||||
# nothing is open, so `_reset_document_state()` on disconnect and
|
||||
# `system_status()` on an empty backend keep working.
|
||||
self._docs: dict[str, _DocState] = {}
|
||||
self._active_key: str | None = None
|
||||
self._void = _DocState(key="")
|
||||
self._untitled_counter = 0
|
||||
self._activation_counter = 0
|
||||
self._connected = False
|
||||
self._lock = asyncio.Lock()
|
||||
# R32: the document a timed-out call was abandoned on is untrusted until
|
||||
@@ -976,6 +1055,50 @@ class EzdxfBackend(AutoCADBackend):
|
||||
self._quarantine: QuarantineRecord | None = None
|
||||
self._abandoned_calls: list[QuarantineRecord] = []
|
||||
|
||||
@property
|
||||
def _state(self) -> _DocState:
|
||||
if self._active_key is None:
|
||||
return self._void
|
||||
return self._docs[self._active_key]
|
||||
|
||||
@property
|
||||
def _doc(self):
|
||||
return self._state.doc
|
||||
|
||||
@_doc.setter
|
||||
def _doc(self, value):
|
||||
# `_restore_snapshot` and `transaction_rollback` rebind the active
|
||||
# document; nothing may bind a Drawing while none is active — that is
|
||||
# `_register_document`'s job, and it is what keeps the registry honest.
|
||||
if self._active_key is None:
|
||||
if value is None:
|
||||
return
|
||||
raise RuntimeError("EzdxfBackend: bind a Drawing through _register_document")
|
||||
self._state.doc = value
|
||||
|
||||
@property
|
||||
def _doc_path(self) -> str | None:
|
||||
return self._state.path
|
||||
|
||||
@_doc_path.setter
|
||||
def _doc_path(self, value: str | None):
|
||||
# `drawing_save` / `drawing_save_as` assign here. A document keyed
|
||||
# `untitled-N` that is saved to a file becomes keyed by that file, so
|
||||
# `document_list` shows one identity per document and `document_close`
|
||||
# can be told the file name. Two entries may end up sharing a path
|
||||
# (save_as onto a file that is open in another entry); the resolver
|
||||
# then reports the ambiguity instead of guessing.
|
||||
state = self._state
|
||||
state.path = value
|
||||
if self._active_key is None or value is None:
|
||||
return
|
||||
new_key = os.path.abspath(value)
|
||||
if new_key != self._active_key and new_key not in self._docs:
|
||||
del self._docs[self._active_key]
|
||||
state.key = new_key
|
||||
self._docs[new_key] = state
|
||||
self._active_key = new_key
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "ezdxf"
|
||||
@@ -1058,6 +1181,26 @@ class EzdxfBackend(AutoCADBackend):
|
||||
False, reason="acis_generation_requires_live_autocad"
|
||||
),
|
||||
"lisp": FeatureCapability(False, reason="live_com_only"),
|
||||
"documents": FeatureCapability(True, "registry"),
|
||||
# Track E keys. `@capability` registers its key at import, and
|
||||
# tests/test_capability_contract.py checks both maps from that
|
||||
# moment on — so every key the contract module uses is declared
|
||||
# here in the task that creates the module, not when the COM
|
||||
# implementation lands (Task 23).
|
||||
"live_application": FeatureCapability(
|
||||
False, reason="headless_engine_has_no_application_to_launch_or_attach"
|
||||
),
|
||||
"preferences": FeatureCapability(
|
||||
False, reason="preferences_live_in_the_running_application_not_in_a_file"
|
||||
),
|
||||
"interactive_prompt": FeatureCapability(
|
||||
False, reason="no_operator_to_prompt_headlessly"
|
||||
),
|
||||
# Byte-identical to group C's Task 16 entry; the merge agent keeps one.
|
||||
"dwgprops": FeatureCapability(
|
||||
False,
|
||||
reason="summary_fields_need_live_autocad;custom_properties_supported",
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1066,21 +1209,153 @@ class EzdxfBackend(AutoCADBackend):
|
||||
log.info("ezdxf backend ready")
|
||||
|
||||
async def disconnect(self) -> None:
|
||||
self._cleanup_undo_stack()
|
||||
for state in self._docs.values():
|
||||
state.discard_snapshots()
|
||||
self._docs.clear()
|
||||
self._active_key = None
|
||||
self._void = _DocState(key="")
|
||||
self._reset_document_state()
|
||||
# The document goes away with the backend, so nothing is left to protect.
|
||||
# The documents go away with the backend, so nothing is left to protect.
|
||||
# The record stays in _abandoned_calls for anyone reading the transcript.
|
||||
self._clear_quarantine("disconnect")
|
||||
self._connected = False
|
||||
|
||||
def _cleanup_undo_stack(self):
|
||||
for stack in (self._undo_stack, self._redo_stack, self._transaction_stack):
|
||||
while stack:
|
||||
p = stack.pop()
|
||||
try:
|
||||
p.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
self._state.discard_snapshots()
|
||||
|
||||
# ── document registry (v1.6) ──────────────────────────────────────────────
|
||||
|
||||
def _register_document(self, doc, path: str | None) -> tuple[str, bool]:
|
||||
"""Add ``doc`` to the registry and make it active.
|
||||
|
||||
Returns ``(key, reloaded)``. A path that is already open is *reloaded*:
|
||||
its old entry (and its snapshots) is dropped and the fresh read takes
|
||||
its place — the pre-registry behaviour of ``drawing_open``, kept so a
|
||||
save-then-reopen verification still reads the disk.
|
||||
"""
|
||||
if path is None:
|
||||
self._untitled_counter += 1
|
||||
key = f"untitled-{self._untitled_counter}"
|
||||
reloaded = False
|
||||
else:
|
||||
key = os.path.abspath(path)
|
||||
previous = self._docs.pop(key, None)
|
||||
reloaded = previous is not None
|
||||
if previous is not None:
|
||||
previous.discard_snapshots()
|
||||
self._docs[key] = _DocState(key=key, doc=doc, path=path)
|
||||
self._activate_key(key)
|
||||
return key, reloaded
|
||||
|
||||
def _activate_key(self, key: str) -> None:
|
||||
self._activation_counter += 1
|
||||
self._docs[key].activated_seq = self._activation_counter
|
||||
self._active_key = key
|
||||
|
||||
def _most_recent_key(self) -> str | None:
|
||||
if not self._docs:
|
||||
return None
|
||||
return max(self._docs.values(), key=lambda state: state.activated_seq).key
|
||||
|
||||
def _resolve_document_key(self, name_or_path: str | None) -> str:
|
||||
"""Registry key for ``None`` (the active document), a key, a path or a basename."""
|
||||
if name_or_path is None:
|
||||
if self._active_key is None:
|
||||
raise RuntimeError("No document open. Call drawing_new() or drawing_open() first.")
|
||||
return self._active_key
|
||||
if not isinstance(name_or_path, str):
|
||||
raise TypeError(f"name_or_path must be a string, got {type(name_or_path).__name__}")
|
||||
wanted = name_or_path.strip()
|
||||
if not wanted:
|
||||
raise ValueError("name_or_path must not be empty")
|
||||
if wanted in self._docs:
|
||||
return wanted
|
||||
try:
|
||||
as_path = os.path.abspath(wanted)
|
||||
except (OSError, ValueError):
|
||||
as_path = wanted
|
||||
by_path = [
|
||||
key
|
||||
for key, state in self._docs.items()
|
||||
if state.path and os.path.abspath(state.path) == as_path
|
||||
]
|
||||
if len(by_path) == 1:
|
||||
return by_path[0]
|
||||
by_name = [
|
||||
key for key, state in self._docs.items() if state.label.lower() == wanted.lower()
|
||||
]
|
||||
if len(by_name) == 1:
|
||||
return by_name[0]
|
||||
if len(by_name) > 1:
|
||||
raise ValueError(f"document {wanted!r} is ambiguous: {by_name}; pass the full path")
|
||||
raise ValueError(f"no open document named {wanted!r}; open documents: {list(self._docs)}")
|
||||
|
||||
def _evict_quarantined_document(self) -> None:
|
||||
"""Drop the quarantined entry before a fresh document is registered.
|
||||
|
||||
Pre-registry, ``drawing_new`` *replaced* the only document, and the
|
||||
quarantine relies on that: the runaway keeps the object it captured
|
||||
and its writes land on an orphan nobody reads. With a registry the
|
||||
quarantined entry would otherwise survive, and ``document_activate``
|
||||
could hand back a drawing another thread is still mutating.
|
||||
"""
|
||||
if self._quarantine is None or self._active_key is None:
|
||||
return
|
||||
state = self._docs.pop(self._active_key)
|
||||
state.discard_snapshots()
|
||||
self._active_key = self._most_recent_key()
|
||||
|
||||
def _document_row(self, key: str, state: _DocState) -> dict:
|
||||
return {
|
||||
"name": state.label,
|
||||
"path": state.path,
|
||||
"key": key,
|
||||
"active": key == self._active_key,
|
||||
"saved": not state.dirty,
|
||||
"entity_count": len(state.doc.modelspace()),
|
||||
}
|
||||
|
||||
def _close_document_sync(self, key: str, *, save: bool, discard: bool) -> dict:
|
||||
"""Close one registry entry. Runs on the worker thread under the lock."""
|
||||
state = self._docs[key]
|
||||
closing_active = key == self._active_key
|
||||
quarantined = closing_active and self._quarantine is not None
|
||||
label = state.label
|
||||
saved = False
|
||||
if state.dirty:
|
||||
if save:
|
||||
if quarantined:
|
||||
raise ValueError(
|
||||
f"document_close: {label!r} was quarantined after a call was "
|
||||
"abandoned mid-write and cannot be saved; pass discard=True to drop "
|
||||
"its changes"
|
||||
)
|
||||
if not state.path:
|
||||
raise ValueError(
|
||||
f"document_close: {label!r} has never been saved, so save=True has "
|
||||
"nowhere to write; drawing_save_as(path) first, or pass discard=True"
|
||||
)
|
||||
state.doc.saveas(state.path)
|
||||
state.dirty = False
|
||||
saved = True
|
||||
elif not discard:
|
||||
where = f" to {state.path}" if state.path else " (after drawing_save_as)"
|
||||
raise ValueError(
|
||||
f"document_close: {label!r} has unsaved changes; pass save=True to write "
|
||||
f"them{where}, or discard=True to drop them"
|
||||
)
|
||||
discarded = state.dirty
|
||||
state.discard_snapshots()
|
||||
del self._docs[key]
|
||||
result = {"ok": True, "closed": key, "saved": saved, "discarded_changes": discarded}
|
||||
if closing_active:
|
||||
released = self._clear_quarantine("document_close")
|
||||
self._active_key = self._most_recent_key()
|
||||
if released is not None:
|
||||
result["quarantine_cleared"] = released.to_dict()
|
||||
result["active"] = self._active_key
|
||||
result["open_documents"] = len(self._docs)
|
||||
return result
|
||||
|
||||
def _require_doc(self):
|
||||
if self._doc is None:
|
||||
@@ -1403,22 +1678,26 @@ class EzdxfBackend(AutoCADBackend):
|
||||
async def drawing_new(self, template: str | None = None) -> dict:
|
||||
def _sync():
|
||||
if template and Path(template).exists():
|
||||
self._doc = ezdxf.readfile(template)
|
||||
_normalise_dimstyle_rounding(self._doc)
|
||||
doc = ezdxf.readfile(template)
|
||||
_normalise_dimstyle_rounding(doc)
|
||||
else:
|
||||
self._doc = ezdxf.new(dxfversion="R2010")
|
||||
_apply_iso_dimstyle(self._doc)
|
||||
# R32: the rebind above is the release — the runaway keeps the
|
||||
# Drawing it captured, so its remaining writes land on an orphan.
|
||||
# Clear before _reset_history_baseline, which snapshots the new doc.
|
||||
doc = ezdxf.new(dxfversion="R2010")
|
||||
_apply_iso_dimstyle(doc)
|
||||
# R32: registering a fresh Drawing is the release — the runaway keeps
|
||||
# the object it captured. The quarantined entry is evicted first so
|
||||
# it can never be re-activated. Clear before _reset_history_baseline,
|
||||
# which snapshots the new doc.
|
||||
self._evict_quarantined_document()
|
||||
released = self._clear_quarantine("drawing_new")
|
||||
self._doc_path = None
|
||||
self._dirty = False
|
||||
self._current_layer = "0"
|
||||
key, _ = self._register_document(doc, None)
|
||||
self._reset_document_state()
|
||||
self._current_space = "Model"
|
||||
self._reset_history_baseline()
|
||||
result = {"ok": True, "name": "untitled.dxf"}
|
||||
result = {
|
||||
"ok": True,
|
||||
"name": "untitled.dxf",
|
||||
"document": key,
|
||||
"open_documents": len(self._docs),
|
||||
}
|
||||
if released is not None:
|
||||
result["quarantine_cleared"] = released.to_dict()
|
||||
return result
|
||||
@@ -1438,24 +1717,31 @@ class EzdxfBackend(AutoCADBackend):
|
||||
f"DXF file exceeds MAX_DXF_BYTES limit "
|
||||
f"({size} > {max_bytes}). Set MAX_DXF_BYTES env var to override."
|
||||
)
|
||||
self._doc = ezdxf.readfile(path)
|
||||
_normalise_dimstyle_rounding(self._doc)
|
||||
doc = ezdxf.readfile(path)
|
||||
_normalise_dimstyle_rounding(doc)
|
||||
# R32: only reached if the read succeeded. Reopening the very path a
|
||||
# runaway doc.saveas is writing either fails loudly here (acceptable)
|
||||
# or hands back a truncated document (not) — _clear_quarantine
|
||||
# compares the two paths and says so.
|
||||
self._evict_quarantined_document()
|
||||
released = self._clear_quarantine("drawing_open", path)
|
||||
self._doc_path = path
|
||||
self._dirty = False
|
||||
key, reloaded = self._register_document(doc, path)
|
||||
try:
|
||||
self._current_layer = self._doc.header.get("$CLAYER", "0")
|
||||
self._current_layer = doc.header.get("$CLAYER", "0")
|
||||
except Exception as exc:
|
||||
log.debug("reading $CLAYER from header: %s", exc)
|
||||
self._current_layer = "0"
|
||||
self._reset_document_state()
|
||||
self._current_space = "Model"
|
||||
self._reset_history_baseline()
|
||||
result = {"ok": True, "name": Path(path).name, "path": path}
|
||||
result = {
|
||||
"ok": True,
|
||||
"name": Path(path).name,
|
||||
"path": path,
|
||||
"document": key,
|
||||
"open_documents": len(self._docs),
|
||||
}
|
||||
if reloaded:
|
||||
result["reloaded"] = True
|
||||
if released is not None:
|
||||
result["quarantine_cleared"] = released.to_dict()
|
||||
return result
|
||||
@@ -1873,6 +2159,56 @@ class EzdxfBackend(AutoCADBackend):
|
||||
|
||||
return await self._async(_sync)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ── environment (track E) ───────────────────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def document_list(self) -> list[dict]:
|
||||
def _sync():
|
||||
return [self._document_row(key, state) for key, state in self._docs.items()]
|
||||
|
||||
return await self._async(_sync)
|
||||
|
||||
async def document_activate(self, name_or_path: str) -> dict:
|
||||
if not isinstance(name_or_path, str):
|
||||
raise TypeError(
|
||||
f"document_activate: name_or_path must be a string, got {type(name_or_path).__name__}"
|
||||
)
|
||||
|
||||
def _sync():
|
||||
key = self._resolve_document_key(name_or_path)
|
||||
previous = self._active_key
|
||||
self._activate_key(key)
|
||||
state = self._docs[key]
|
||||
return {
|
||||
"ok": True,
|
||||
"active": key,
|
||||
"previous": previous,
|
||||
"name": state.label,
|
||||
"path": state.path,
|
||||
}
|
||||
|
||||
# Not a quarantine exit: a quarantined backend refuses to switch, because
|
||||
# the runaway thread holds the one worker and the one orphaned lock.
|
||||
return await self._async(_sync)
|
||||
|
||||
async def document_close(
|
||||
self, name_or_path: str | None = None, save: bool = False, discard: bool = False
|
||||
) -> dict:
|
||||
if save and discard:
|
||||
raise ValueError("document_close: save and discard are mutually exclusive")
|
||||
# Resolve once outside the lock to decide whether this is the active
|
||||
# document (then it is a quarantine exit, like drawing_close); the
|
||||
# closure resolves again under the lock before touching the registry.
|
||||
target = self._resolve_document_key(name_or_path)
|
||||
closing_active = target == self._active_key
|
||||
|
||||
def _sync():
|
||||
key = self._resolve_document_key(name_or_path)
|
||||
return self._close_document_sync(key, save=save, discard=discard)
|
||||
|
||||
return await self._async(_sync, quarantine_exit=closing_active)
|
||||
|
||||
# ── viewports ────────────────────────────────────────────────────────────
|
||||
|
||||
#: DXF viewport flag bit for "display locked" (VSF_LOCK_ZOOM).
|
||||
@@ -2438,29 +2774,44 @@ class EzdxfBackend(AutoCADBackend):
|
||||
return await self._async(_sync)
|
||||
|
||||
async def drawing_close(self, save: bool = True) -> dict:
|
||||
"""The active-document case of ``document_close``, with the 1.4 contract kept.
|
||||
|
||||
``save=True`` saves when the document has a path and is not quarantined.
|
||||
An untitled or quarantined document is closed *without* saving and the
|
||||
result says so (``saved: False``, ``discarded_changes``, ``warning``) —
|
||||
the pre-registry behaviour two shipped tests pin. ``document_close`` is
|
||||
the strict tool: it refuses instead of warning.
|
||||
"""
|
||||
|
||||
def _sync():
|
||||
if self._active_key is None:
|
||||
return {
|
||||
"ok": True,
|
||||
"closed": None,
|
||||
"saved": False,
|
||||
"active": None,
|
||||
"open_documents": 0,
|
||||
}
|
||||
key = self._active_key
|
||||
state = self._docs[key]
|
||||
# R32: closing is a way out of a quarantine, but saving on the way is
|
||||
# not. That save is the exact defect measured — a valid DXF holding
|
||||
# 15200 of the 60000 entities the runaway went on to write, a
|
||||
# considered document of a state that never existed. Say what was
|
||||
# dropped instead of writing it.
|
||||
quarantined = self._quarantine is not None
|
||||
if save and self._dirty and self._doc_path and not quarantined:
|
||||
self._doc.saveas(self._doc_path)
|
||||
self._cleanup_undo_stack()
|
||||
self._doc = None
|
||||
released = self._clear_quarantine("drawing_close")
|
||||
self._doc_path = None
|
||||
self._dirty = False
|
||||
self._reset_document_state()
|
||||
result = {"ok": True}
|
||||
if released is not None:
|
||||
result["quarantine_cleared"] = released.to_dict()
|
||||
result["saved"] = False
|
||||
dirty = state.dirty
|
||||
can_save = save and dirty and bool(state.path) and not quarantined
|
||||
result = self._close_document_sync(key, save=can_save, discard=not can_save)
|
||||
if save and dirty and not can_save:
|
||||
result["warning"] = (
|
||||
"the document was quarantined after a call was abandoned mid-write, "
|
||||
"so unsaved changes were discarded rather than serialised out of a "
|
||||
"drawing another thread may still have been mutating"
|
||||
if quarantined
|
||||
else f"{key} had never been saved, so save=True had nowhere to write; "
|
||||
"its changes were discarded. drawing_save_as(path) before closing, or "
|
||||
"document_close(discard=True) to say so explicitly."
|
||||
)
|
||||
return result
|
||||
|
||||
@@ -5503,6 +5854,8 @@ class EzdxfBackend(AutoCADBackend):
|
||||
"connected": True,
|
||||
"has_document": has_doc,
|
||||
"document_path": self._doc_path,
|
||||
"open_documents": len(self._docs),
|
||||
"active_document": self._active_key,
|
||||
"unsaved_changes": self._dirty,
|
||||
"transaction_depth": len(self._transaction_stack),
|
||||
"quarantine": self._quarantine.to_dict() if self._quarantine else None,
|
||||
|
||||
@@ -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.
|
||||
# CLOSE -- the active document (drawing_close) or one named document
|
||||
# (document_close); a drafter typing it could mean either.
|
||||
#
|
||||
# Every other AutoCAD command must map to exactly one tool.
|
||||
SHARED_ACAD_COMMANDS: frozenset[str] = frozenset(
|
||||
{
|
||||
"ARRAY",
|
||||
"BLOCK",
|
||||
"CLOSE",
|
||||
"ERASE",
|
||||
"INSERT",
|
||||
"LAYER",
|
||||
@@ -1427,6 +1430,42 @@ TOOL_ALIASES: dict[str, ToolAliases] = {
|
||||
"loop number",
|
||||
),
|
||||
),
|
||||
# ── Environment (track E, group V) ──────────────────────────────────────
|
||||
"document_activate": ToolAliases(
|
||||
acad=(),
|
||||
synonyms=(
|
||||
"switch drawing",
|
||||
"switch to the other drawing",
|
||||
"make this drawing active",
|
||||
"go to the other open file",
|
||||
"change the current document",
|
||||
),
|
||||
),
|
||||
"document_close": ToolAliases(
|
||||
acad=("CLOSE",),
|
||||
synonyms=(
|
||||
"close a drawing by name",
|
||||
"close the other drawing",
|
||||
"close without saving",
|
||||
"discard changes and close",
|
||||
"close all but this one",
|
||||
),
|
||||
),
|
||||
# No "which ... are" / "which one is" phrasings here: the interrogative
|
||||
# tokens are rare in the corpus, so two of them made this tool outrank
|
||||
# `block_list` for the holdout "which blocks are defined". "drawings",
|
||||
# "open", "active" and "documents" carry every document-list question
|
||||
# to #1 on their own (measured 2026-09-16).
|
||||
"document_list": ToolAliases(
|
||||
acad=(),
|
||||
synonyms=(
|
||||
"open drawings",
|
||||
"list documents",
|
||||
"what files are open",
|
||||
"the active drawing",
|
||||
"currently open files",
|
||||
),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"schema_version": 1,
|
||||
"package_version": "1.5.1",
|
||||
"totals": {
|
||||
"tools": 166,
|
||||
"tools": 169,
|
||||
"resources": 8,
|
||||
"prompts": 5,
|
||||
"groups": 20
|
||||
@@ -14,7 +14,7 @@
|
||||
"blocks": 9,
|
||||
"corner_ops": 4,
|
||||
"dimensions": 5,
|
||||
"drawing": 11,
|
||||
"drawing": 14,
|
||||
"engineering": 10,
|
||||
"entity_creation": 18,
|
||||
"entity_modification": 16,
|
||||
@@ -450,6 +450,46 @@
|
||||
"readOnlyHint": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "document_activate",
|
||||
"group": "drawing",
|
||||
"cost": "safe",
|
||||
"summary": "Switch which open document every later tool call targets.",
|
||||
"tags": [
|
||||
"drawing"
|
||||
],
|
||||
"annotations": {
|
||||
"title": "Activate Document",
|
||||
"readOnlyHint": false,
|
||||
"destructiveHint": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "document_close",
|
||||
"group": "drawing",
|
||||
"cost": "destructive",
|
||||
"summary": "Close one open document by name; never drops unsaved work unless told to.",
|
||||
"tags": [
|
||||
"drawing"
|
||||
],
|
||||
"annotations": {
|
||||
"title": "Close Document",
|
||||
"destructiveHint": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "document_list",
|
||||
"group": "drawing",
|
||||
"cost": "read",
|
||||
"summary": "List the open documents: which is active, which have unsaved changes.",
|
||||
"tags": [
|
||||
"drawing"
|
||||
],
|
||||
"annotations": {
|
||||
"title": "List Documents",
|
||||
"readOnlyHint": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "drawing_apply_iso_layers",
|
||||
"group": "premium",
|
||||
|
||||
@@ -1293,9 +1293,12 @@ async def drawing_close(
|
||||
save: Annotated[bool, "Save the drawing before closing"] = True,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Close the current drawing. If save is True (default), the drawing is
|
||||
saved to its current path before closing. After this call, you must call
|
||||
drawing_new or drawing_open before any other tool."""
|
||||
"""Close the active document. If save is True (default), the drawing is
|
||||
saved to its current path before closing; an untitled document is closed
|
||||
and `warning` says its changes were discarded (headless) or the call is
|
||||
refused (live, where the alternative is a modal Save dialog). The
|
||||
active-document case of `document_close`; after the last document is
|
||||
closed, call drawing_new or drawing_open before any other tool."""
|
||||
await ctx.info(f"Closing drawing (save={save})")
|
||||
return await _backend(ctx).drawing_close(save)
|
||||
|
||||
@@ -6815,6 +6818,92 @@ async def pid_tag_parse(
|
||||
return parse_tag(tag, kind, equipment_prefixes)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ── SECTION 20: Environment (3 tools) ───────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@cad_tool(
|
||||
summary="List the open documents: which is active, which have unsaved changes.",
|
||||
cost="read",
|
||||
)
|
||||
@mcp.tool(
|
||||
annotations={"title": "List Documents", "readOnlyHint": True},
|
||||
tags={"drawing"},
|
||||
)
|
||||
async def document_list(ctx: Context = None) -> dict:
|
||||
"""Every open document with `name`, `path`, `active`, `saved` and
|
||||
`entity_count`.
|
||||
|
||||
Headless, the server keeps its own registry: `drawing_new` / `drawing_open`
|
||||
add an entry (`untitled-N` or the file path) instead of replacing the only
|
||||
one, and `document_activate` chooses which one every later tool targets.
|
||||
Live, this is AutoCAD's `Documents` collection. No refusals: an empty
|
||||
backend answers with an empty list. Pack: settings · lean: no.
|
||||
"""
|
||||
rows = await _backend(ctx).document_list()
|
||||
return {
|
||||
"documents": rows,
|
||||
"count": len(rows),
|
||||
"active": next((r["name"] for r in rows if r["active"]), None),
|
||||
}
|
||||
|
||||
|
||||
@cad_tool(summary="Switch which open document every later tool call targets.", cost="safe")
|
||||
@mcp.tool(
|
||||
annotations={"title": "Activate Document", "readOnlyHint": False, "destructiveHint": False},
|
||||
tags={"drawing"},
|
||||
)
|
||||
async def document_activate(
|
||||
name_or_path: Annotated[
|
||||
str, "A name from document_list (`gear.dxf`, `untitled-2`) or the full path"
|
||||
],
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Make one open document the active one; reports `previous`.
|
||||
|
||||
Matches the full path first, then the file name; an ambiguous name is
|
||||
refused with the candidates, an unknown one with the list of open
|
||||
documents. Headless, a document quarantined after an abandoned call cannot
|
||||
be switched away from — `drawing_new`, `drawing_open` or closing it are
|
||||
the ways out. Pack: settings · lean: no.
|
||||
"""
|
||||
await ctx.info(f"Activating document {name_or_path!r}")
|
||||
return await _backend(ctx).document_activate(name_or_path)
|
||||
|
||||
|
||||
@cad_tool(
|
||||
summary="Close one open document by name; never drops unsaved work unless told to.",
|
||||
cost="destructive",
|
||||
)
|
||||
@mcp.tool(
|
||||
annotations={"title": "Close Document", "destructiveHint": True},
|
||||
tags={"drawing"},
|
||||
)
|
||||
async def document_close(
|
||||
name_or_path: Annotated[
|
||||
str | None, "A name from document_list or the full path; omit for the active document"
|
||||
] = None,
|
||||
save: Annotated[bool, "Write unsaved changes to the document's own path first"] = False,
|
||||
discard: Annotated[bool, "Close even with unsaved changes, dropping them"] = False,
|
||||
ctx: Context = None,
|
||||
) -> dict:
|
||||
"""Close a document and report `saved`, `discarded_changes` and the new `active`.
|
||||
|
||||
Refusals, all before anything is closed: unsaved changes with neither
|
||||
`save` nor `discard`; `save` on a document that has never been saved (no
|
||||
path to write — live, `Close(True)` would open AutoCAD's Save dialog and
|
||||
block the COM thread); `save` together with `discard`. Closing the last
|
||||
document leaves the backend with none open, which every tool already
|
||||
reports. `drawing_close` is the same operation for the active document
|
||||
with its 1.4 contract kept. Pack: settings · lean: no.
|
||||
"""
|
||||
await ctx.info(
|
||||
f"Closing document {name_or_path or '(active)'} (save={save}, discard={discard})"
|
||||
)
|
||||
return await _backend(ctx).document_close(name_or_path, save, discard)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ── RESOURCES ───────────────────────────────────────────────────────────────
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
"""Multi-document on both engines.
|
||||
|
||||
The headless backend used to hold exactly one ``Drawing``: ``drawing_new``
|
||||
replaced it and silently discarded whatever was there. It now keeps a
|
||||
registry keyed by path (or ``untitled-N``); every later tool call targets the
|
||||
active entry, and ``drawing_close`` is the active-document case of
|
||||
``document_close``.
|
||||
|
||||
Two rules are pinned here. ``document_close`` never drops unsaved work
|
||||
without being told to (``discard=True``), and it refuses ``save=True`` on a
|
||||
document that has nowhere to write (an untitled one) instead of guessing a
|
||||
path — on the live engine ``Close(True)`` on an untitled drawing opens
|
||||
AutoCAD's Save dialog and blocks the COM thread until the timeout, so the
|
||||
refusal is what keeps the server answerable. ``drawing_close`` keeps its 1.4
|
||||
contract (``save=True`` saves when it can; an untitled document is closed and
|
||||
the result says the changes were discarded) because two shipped tests rely on
|
||||
it; the strict path is the new tool.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import types
|
||||
|
||||
import pytest
|
||||
from fastmcp import Client
|
||||
|
||||
import server
|
||||
from backends.ezdxf_backend import EzdxfBackend
|
||||
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
# ── headless engine ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
async def test_fixture_document_is_registered_and_active(backend):
|
||||
rows = await backend.document_list()
|
||||
assert rows == [
|
||||
{
|
||||
"name": "untitled-1",
|
||||
"path": None,
|
||||
"key": "untitled-1",
|
||||
"active": True,
|
||||
"saved": True,
|
||||
"entity_count": 0,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
async def test_two_documents_hold_separate_entities(backend):
|
||||
first = (await backend.document_list())[0]["name"]
|
||||
second = (await backend.drawing_new())["document"]
|
||||
assert second == "untitled-2"
|
||||
await backend.entity_create_circle(0, 0, 5)
|
||||
|
||||
rows = {r["name"]: r for r in await backend.document_list()}
|
||||
assert rows[second]["entity_count"] == 1 and rows[second]["active"] is True
|
||||
assert rows[first]["entity_count"] == 0 and rows[first]["active"] is False
|
||||
|
||||
switched = await backend.document_activate(first)
|
||||
assert switched == {
|
||||
"ok": True,
|
||||
"active": first,
|
||||
"previous": second,
|
||||
"name": first,
|
||||
"path": None,
|
||||
}
|
||||
assert (await backend.drawing_info()).entity_count == 0
|
||||
await backend.entity_create_line(0, 0, 1, 1)
|
||||
await backend.document_activate(second)
|
||||
assert (await backend.drawing_info()).entity_count == 1
|
||||
rows = {r["name"]: r for r in await backend.document_list()}
|
||||
assert rows[first]["entity_count"] == 1 and rows[first]["saved"] is False
|
||||
|
||||
|
||||
async def test_per_document_state_follows_the_active_entry(backend):
|
||||
"""Layer, current space, dirty flag and undo stack are per document."""
|
||||
await backend.layer_create("A-ONLY")
|
||||
await backend.layer_set_current("A-ONLY")
|
||||
await backend.drawing_new()
|
||||
assert backend._current_layer == "0"
|
||||
assert "A-ONLY" not in [lyr.name for lyr in await backend.layer_list()]
|
||||
await backend.document_activate("untitled-1")
|
||||
assert backend._current_layer == "A-ONLY"
|
||||
assert backend._dirty is True
|
||||
await backend.document_activate("untitled-2")
|
||||
assert backend._dirty is False
|
||||
|
||||
|
||||
async def test_opening_a_file_twice_reloads_it_instead_of_registering_twice(backend, tmp_path):
|
||||
path = tmp_path / "part.dxf"
|
||||
await backend.drawing_save(str(path))
|
||||
await backend.entity_create_circle(0, 0, 1) # unsaved edit
|
||||
reopened = await backend.drawing_open(str(path))
|
||||
assert reopened["reloaded"] is True
|
||||
assert reopened["open_documents"] == 1
|
||||
assert (await backend.drawing_info()).entity_count == 0, "reload reads the disk, not memory"
|
||||
|
||||
|
||||
async def test_saving_rekeys_an_untitled_document_to_its_file(backend, tmp_path):
|
||||
path = tmp_path / "gear.dxf"
|
||||
await backend.drawing_save(str(path))
|
||||
rows = await backend.document_list()
|
||||
assert rows[0]["key"] == os.path.abspath(str(path))
|
||||
assert rows[0]["name"] == "gear.dxf" and rows[0]["path"] == str(path)
|
||||
assert backend._active_key == os.path.abspath(str(path))
|
||||
|
||||
|
||||
async def test_activate_by_basename_full_path_or_key(backend, tmp_path):
|
||||
path = tmp_path / "gear.dxf"
|
||||
await backend.drawing_save(str(path))
|
||||
key = os.path.abspath(str(path))
|
||||
await backend.drawing_new()
|
||||
assert (await backend.document_activate("gear.dxf"))["active"] == key
|
||||
await backend.document_activate("untitled-2")
|
||||
assert (await backend.document_activate(str(path)))["name"] == "gear.dxf"
|
||||
await backend.document_activate("untitled-2")
|
||||
assert (await backend.document_activate(key))["previous"] == "untitled-2"
|
||||
with pytest.raises(ValueError, match="no open document named 'nope'"):
|
||||
await backend.document_activate("nope")
|
||||
with pytest.raises(TypeError, match="name_or_path"):
|
||||
await backend.document_activate(None)
|
||||
|
||||
|
||||
async def test_close_unsaved_refuses_unless_discard(backend):
|
||||
await backend.entity_create_line(0, 0, 1, 1)
|
||||
with pytest.raises(ValueError, match="untitled-1.*unsaved changes"):
|
||||
await backend.document_close("untitled-1")
|
||||
assert len(await backend.document_list()) == 1, "a refusal closes nothing"
|
||||
with pytest.raises(ValueError, match="never been saved"):
|
||||
await backend.document_close("untitled-1", save=True)
|
||||
with pytest.raises(ValueError, match="mutually exclusive"):
|
||||
await backend.document_close("untitled-1", save=True, discard=True)
|
||||
|
||||
result = await backend.document_close("untitled-1", discard=True)
|
||||
assert result == {
|
||||
"ok": True,
|
||||
"closed": "untitled-1",
|
||||
"saved": False,
|
||||
"discarded_changes": True,
|
||||
"active": None,
|
||||
"open_documents": 0,
|
||||
}
|
||||
|
||||
|
||||
async def test_close_with_save_writes_the_file(backend, tmp_path):
|
||||
path = tmp_path / "saved.dxf"
|
||||
await backend.drawing_save(str(path))
|
||||
await backend.entity_create_circle(0, 0, 2)
|
||||
before = path.read_bytes()
|
||||
result = await backend.document_close(None, save=True)
|
||||
assert result["saved"] is True and result["discarded_changes"] is False
|
||||
assert path.read_bytes() != before
|
||||
|
||||
|
||||
async def test_closing_the_last_document_leaves_the_no_document_state(backend):
|
||||
await backend.document_close("untitled-1")
|
||||
assert await backend.document_list() == []
|
||||
status = await backend.system_status()
|
||||
assert status["has_document"] is False
|
||||
assert status["open_documents"] == 0 and status["active_document"] is None
|
||||
with pytest.raises(RuntimeError, match="No document open"):
|
||||
await backend.drawing_info()
|
||||
# ...and the backend recovers exactly as before.
|
||||
assert (await backend.drawing_new())["document"] == "untitled-2"
|
||||
|
||||
|
||||
async def test_closing_the_active_document_activates_the_most_recently_used(backend):
|
||||
await backend.drawing_new() # untitled-2
|
||||
await backend.drawing_new() # untitled-3
|
||||
await backend.document_activate("untitled-1")
|
||||
await backend.document_activate("untitled-3")
|
||||
result = await backend.document_close("untitled-3")
|
||||
assert result["active"] == "untitled-1", "most recently activated, not most recently created"
|
||||
|
||||
|
||||
async def test_closing_a_background_document_keeps_the_active_one(backend):
|
||||
await backend.drawing_new() # untitled-2, active
|
||||
await backend.entity_create_circle(0, 0, 1)
|
||||
result = await backend.document_close("untitled-1")
|
||||
assert result["active"] == "untitled-2"
|
||||
assert (await backend.drawing_info()).entity_count == 1
|
||||
|
||||
|
||||
async def test_drawing_close_keeps_its_legacy_contract(backend, tmp_path):
|
||||
await backend.entity_create_line(0, 0, 1, 1)
|
||||
closed = await backend.drawing_close(save=True)
|
||||
assert closed["ok"] is True and closed["saved"] is False
|
||||
assert closed["discarded_changes"] is True
|
||||
assert "never been saved" in closed["warning"]
|
||||
assert await backend.document_list() == []
|
||||
|
||||
await backend.drawing_new()
|
||||
path = tmp_path / "keep.dxf"
|
||||
await backend.drawing_save(str(path))
|
||||
await backend.entity_create_circle(0, 0, 3)
|
||||
before = path.read_bytes()
|
||||
closed = await backend.drawing_close(save=True)
|
||||
assert closed["saved"] is True and "warning" not in closed
|
||||
assert path.read_bytes() != before
|
||||
|
||||
|
||||
async def test_disconnect_drops_every_document_and_its_snapshots(monkeypatch):
|
||||
import config
|
||||
|
||||
monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 3)
|
||||
backend = EzdxfBackend()
|
||||
await backend.connect()
|
||||
await backend.drawing_new()
|
||||
await backend.entity_create_line(0, 0, 1, 1)
|
||||
await backend.drawing_new()
|
||||
await backend.entity_create_line(0, 0, 2, 2)
|
||||
snapshots = [p for state in backend._docs.values() for p in state.undo_stack]
|
||||
assert len(snapshots) == 4 and all(p.exists() for p in snapshots)
|
||||
await backend.disconnect()
|
||||
assert backend._docs == {} and backend._active_key is None
|
||||
assert not any(p.exists() for p in snapshots)
|
||||
|
||||
|
||||
async def test_document_list_tool_over_the_wire(monkeypatch):
|
||||
monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
|
||||
async with Client(server.mcp) as client:
|
||||
await client.call_tool("drawing_new", {"bootstrap": False})
|
||||
await client.call_tool("drawing_new", {"bootstrap": False})
|
||||
payload = (await client.call_tool("document_list", {})).structured_content
|
||||
assert payload["count"] == 2 and payload["active"] == "untitled-2"
|
||||
closed = (
|
||||
await client.call_tool("document_close", {"name_or_path": "untitled-1"})
|
||||
).structured_content
|
||||
assert closed["closed"] == "untitled-1" and closed["active"] == "untitled-2"
|
||||
|
||||
|
||||
# ── live engine, against a fake ActiveX surface ──────────────────────────────
|
||||
|
||||
|
||||
class _FakeDoc:
|
||||
def __init__(self, app, name, full_name, *, saved=True, count=0):
|
||||
self._app = app
|
||||
self.Name = name
|
||||
self.FullName = full_name
|
||||
self.Saved = saved
|
||||
self.ModelSpace = types.SimpleNamespace(Count=count)
|
||||
self.calls: list[tuple[str, tuple]] = []
|
||||
|
||||
def Activate(self):
|
||||
self.calls.append(("Activate", ()))
|
||||
self._app.ActiveDocument = self
|
||||
|
||||
def Close(self, save_changes):
|
||||
self.calls.append(("Close", (save_changes,)))
|
||||
self._app.Documents._docs.remove(self)
|
||||
docs = self._app.Documents._docs
|
||||
self._app.ActiveDocument = docs[-1] if docs else None
|
||||
|
||||
|
||||
class _FakeDocuments:
|
||||
def __init__(self):
|
||||
self._docs: list[_FakeDoc] = []
|
||||
|
||||
@property
|
||||
def Count(self):
|
||||
return len(self._docs)
|
||||
|
||||
def Item(self, index):
|
||||
return self._docs[index]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def com_backend(monkeypatch):
|
||||
pytest.importorskip("win32com.client", reason="pywin32 not installed")
|
||||
from backends import com_backend as module
|
||||
|
||||
app = types.SimpleNamespace(Documents=_FakeDocuments(), ActiveDocument=None)
|
||||
first = _FakeDoc(app, "First.dwg", "C:/work/First.dwg", saved=True, count=3)
|
||||
second = _FakeDoc(app, "Second.dwg", "C:/work/Second.dwg", saved=False, count=1)
|
||||
untitled = _FakeDoc(app, "Drawing1.dwg", "", saved=False, count=0)
|
||||
app.Documents._docs.extend([first, second, untitled])
|
||||
app.ActiveDocument = first
|
||||
monkeypatch.setattr(module, "_acad_app", lambda: app)
|
||||
monkeypatch.setattr(module, "_acad_doc", lambda: app.ActiveDocument)
|
||||
backend = module.ComBackend()
|
||||
|
||||
async def _run_inline(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(backend, "_run", _run_inline)
|
||||
return backend, app
|
||||
|
||||
|
||||
async def test_com_document_list_reads_the_documents_collection(com_backend):
|
||||
backend, app = com_backend
|
||||
rows = await backend.document_list()
|
||||
assert rows == [
|
||||
{
|
||||
"name": "First.dwg",
|
||||
"path": "C:/work/First.dwg",
|
||||
"active": True,
|
||||
"saved": True,
|
||||
"entity_count": 3,
|
||||
},
|
||||
{
|
||||
"name": "Second.dwg",
|
||||
"path": "C:/work/Second.dwg",
|
||||
"active": False,
|
||||
"saved": False,
|
||||
"entity_count": 1,
|
||||
},
|
||||
{"name": "Drawing1.dwg", "path": None, "active": False, "saved": False, "entity_count": 0},
|
||||
]
|
||||
|
||||
|
||||
async def test_com_activate_calls_activate_and_resets_document_scope(com_backend):
|
||||
backend, app = com_backend
|
||||
await backend._ensure_document_state()
|
||||
backend._plan_spec = object()
|
||||
result = await backend.document_activate("Second.dwg")
|
||||
assert result == {
|
||||
"ok": True,
|
||||
"active": "Second.dwg",
|
||||
"previous": "First.dwg",
|
||||
"name": "Second.dwg",
|
||||
"path": "C:/work/Second.dwg",
|
||||
}
|
||||
assert app.Documents._docs[1].calls == [("Activate", ())]
|
||||
assert backend._plan_spec is None, "a document switch clears drawing-scoped state"
|
||||
with pytest.raises(ValueError, match="no open document named 'Third.dwg'"):
|
||||
await backend.document_activate("Third.dwg")
|
||||
|
||||
|
||||
async def test_com_close_refuses_before_touching_activex(com_backend):
|
||||
backend, app = com_backend
|
||||
second = app.Documents._docs[1]
|
||||
with pytest.raises(ValueError, match="Second.dwg.*unsaved changes"):
|
||||
await backend.document_close("Second.dwg")
|
||||
untitled = app.Documents._docs[2]
|
||||
with pytest.raises(ValueError, match="never been saved"):
|
||||
await backend.document_close("Drawing1.dwg", save=True)
|
||||
assert second.calls == [] and untitled.calls == []
|
||||
|
||||
|
||||
async def test_com_close_passes_savechanges_through(com_backend):
|
||||
backend, app = com_backend
|
||||
first, second, untitled = app.Documents._docs
|
||||
saved = await backend.document_close("Second.dwg", save=True)
|
||||
assert second.calls == [("Close", (True,))]
|
||||
assert saved["saved"] is True and saved["open_documents"] == 2
|
||||
dropped = await backend.document_close("Drawing1.dwg", discard=True)
|
||||
assert untitled.calls == [("Close", (False,))]
|
||||
assert dropped["discarded_changes"] is True and dropped["open_documents"] == 1
|
||||
clean = await backend.document_close(None) # the active, clean First.dwg
|
||||
assert first.calls == [("Close", (False,))], "nothing to save, so nothing is asked to be saved"
|
||||
assert clean["active"] is None
|
||||
|
||||
|
||||
async def test_com_drawing_close_refuses_the_untitled_save_dialog(com_backend):
|
||||
backend, app = com_backend
|
||||
app.ActiveDocument = app.Documents._docs[2] # the untitled one
|
||||
with pytest.raises(ValueError, match="Save dialog"):
|
||||
await backend.drawing_close(save=True)
|
||||
assert app.Documents._docs[2].calls == []
|
||||
@@ -517,7 +517,9 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
when `pid_tag_parse` (the ISA-5.1 tag grammar as a read-only tool)
|
||||
joined; the `pid`
|
||||
tag is ranked first in `_GROUP_TAG_PRIORITY` so those tools file under
|
||||
`pid` rather than under their secondary `query` / `create` tags. Every
|
||||
`pid` rather than under their secondary `query` / `create` tags. `drawing`
|
||||
moved 11 -> 14 when v1.6's `document_list` / `document_activate` /
|
||||
`document_close` (multi-document on both engines) opened SECTION 20. 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()}
|
||||
@@ -527,7 +529,7 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
"blocks": 9,
|
||||
"corner_ops": 4,
|
||||
"dimensions": 5,
|
||||
"drawing": 11,
|
||||
"drawing": 14,
|
||||
"engineering": 10,
|
||||
"entity_creation": 18,
|
||||
"entity_modification": 16,
|
||||
@@ -543,4 +545,4 @@ async def test_tool_group_sizes_are_unchanged():
|
||||
"validation": 1,
|
||||
"view": 4,
|
||||
}
|
||||
assert sum(sizes.values()) == 166
|
||||
assert sum(sizes.values()) == 169
|
||||
|
||||
Reference in New Issue
Block a user