From 174fb8010c0bd374ba2a4014c65b07704f3866c6 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Wed, 16 Sep 2026 21:20:21 +0300 Subject: [PATCH] =?UTF-8?q?feat(environment):=20multi-document=20on=20both?= =?UTF-8?q?=20engines=20=E2=80=94=20ezdxf=20document=20registry,=20documen?= =?UTF-8?q?t=5Flist/activate/close,=20drawing=5Fclose=20folded=20in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- README.md | 5 +- backends/base.py | 2 + backends/com_backend.py | 140 +++++++++- backends/contracts/__init__.py | 2 + backends/contracts/environment.py | 120 ++++++++ backends/ezdxf_backend.py | 449 ++++++++++++++++++++++++++---- discovery/aliases.py | 39 +++ docs/tool-inventory.json | 44 ++- server.py | 95 ++++++- tests/test_documents.py | 361 ++++++++++++++++++++++++ tests/test_tool_registry.py | 8 +- 11 files changed, 1200 insertions(+), 65 deletions(-) create mode 100644 backends/contracts/environment.py create mode 100644 tests/test_documents.py diff --git a/README.md b/README.md index 0864612..7ba7a61 100644 --- a/README.md +++ b/README.md @@ -27,8 +27,8 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co -> **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 | diff --git a/backends/base.py b/backends/base.py index 0adbddc..800625a 100644 --- a/backends/base.py +++ b/backends/base.py @@ -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. diff --git a/backends/com_backend.py b/backends/com_backend.py index b0aa28d..2ab9467 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -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(): diff --git a/backends/contracts/__init__.py b/backends/contracts/__init__.py index fb04be8..83b9c9c 100644 --- a/backends/contracts/__init__.py +++ b/backends/contracts/__init__.py @@ -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", diff --git a/backends/contracts/environment.py b/backends/contracts/environment.py new file mode 100644 index 0000000..00b294a --- /dev/null +++ b/backends/contracts/environment.py @@ -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": [...]}``.""" + ... diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 465d1d8..ba0b77e 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -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, diff --git a/discovery/aliases.py b/discovery/aliases.py index e8b9af9..a089893 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -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", + ), + ), } diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index 264451c..f934c03 100644 --- a/docs/tool-inventory.json +++ b/docs/tool-inventory.json @@ -3,7 +3,7 @@ "schema_version": 1, "package_version": "1.5.1", "totals": { - "tools": 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", diff --git a/server.py b/server.py index 27a06b2..553b40d 100644 --- a/server.py +++ b/server.py @@ -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 ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_documents.py b/tests/test_documents.py new file mode 100644 index 0000000..a9abf45 --- /dev/null +++ b/tests/test_documents.py @@ -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 == [] diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index 13ab419..29ee537 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -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