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:
Umut CAN
2026-09-16 21:20:21 +03:00
co-authored by Claude Opus 5
parent 49fc6ac6a5
commit 174fb8010c
11 changed files with 1200 additions and 65 deletions
+3 -2
View File
@@ -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 |
+2
View File
@@ -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
View File
@@ -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():
+2
View File
@@ -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",
+120
View File
@@ -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
View File
@@ -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,
+39
View File
@@ -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",
),
),
}
+42 -2
View File
@@ -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",
+92 -3
View File
@@ -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 ───────────────────────────────────────────────────────────────
# ---------------------------------------------------------------------------
+361
View File
@@ -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 == []
+5 -3
View File
@@ -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