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
+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,