From df39b21f2effc51bca22b06f9a27089a9e46cbfb Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Wed, 16 Sep 2026 21:14:31 +0300 Subject: [PATCH 01/12] feat(settings): drawing_settings gains limits, grid/snap, ortho/polar, psltscale, annotation scale, unit formats and current styles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headlessly, grid/snap are stored on the active VPORT, CANNOSCALE in AcDbVariableDictionary (+ an ACAD_SCALELIST entry), and LIMMIN/LIMMAX on the model-space layout as well as the header — measured: the header alone is overwritten at save. Registry-saved variables (AUTOSNAP, POLARANG, …) are refused with capability registry_sysvar instead of vanishing. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/com_backend.py | 8 + backends/contracts/settings.py | 307 +++++++++++++++++++++++- backends/ezdxf_backend.py | 172 ++++++++++++- discovery/aliases.py | 25 +- server.py | 29 ++- tests/test_settings_facade.py | 425 +++++++++++++++++++++++++++++++++ 6 files changed, 944 insertions(+), 22 deletions(-) create mode 100644 tests/test_settings_facade.py diff --git a/backends/com_backend.py b/backends/com_backend.py index b0aa28d..8fb117e 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -814,6 +814,7 @@ class ComBackend(AutoCADBackend): "viewport_render": FeatureCapability(True, "native"), "solid_3d": _solid_3d_capability(), "lisp": FeatureCapability(True, "sanitized"), + "registry_sysvar": FeatureCapability(True, "native"), }, ) @@ -4010,6 +4011,13 @@ class ComBackend(AutoCADBackend): coerced = int(float(value)) if isinstance(value, str) else int(value) elif isinstance(current, float): coerced = float(value) + elif isinstance(current, (tuple, list)): + # 2D/3D point variables (LIMMIN, GRIDUNIT, SNAPUNIT, ...) travel + # as VARIANT double arrays of the length AutoCAD itself reports; + # a bare Python tuple is marshalled as VT_VARIANT and rejected. + coords = [float(v) for v in value] + coords += [0.0] * (len(current) - len(coords)) + coerced = _av(coords[: len(current)]) except Exception as exc: log.debug("set_variable type probe for %s failed: %s", name, exc) app.SetVariable(name, coerced) diff --git a/backends/contracts/settings.py b/backends/contracts/settings.py index 3428961..fe81457 100644 --- a/backends/contracts/settings.py +++ b/backends/contracts/settings.py @@ -3,10 +3,19 @@ Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7). ``AutoCADBackend`` composes every contract in this package, so importing ``backends.base.AutoCADBackend`` is unchanged for callers. + +v1.6 (track E) grows the facade from 15 keys to 29: drawing limits, grid and +snap, ortho and polar tracking, PSLTSCALE, the annotation scale, linear and +angular unit formats, and the current dimension / text style. Every key still +goes through ``system_get_variable`` / ``system_set_variable`` so the facade +stays backend-agnostic; the *engines* are where a variable's real home is +known (header, VPORT, the variable dictionary, or — headlessly — nowhere). """ from __future__ import annotations +import math +import re from typing import Any @@ -20,46 +29,94 @@ class SettingsContract: """Read (no args) or apply (with args) common drawing settings. With ``settings=None`` returns a snapshot of every known setting. With a - dict, applies each provided key and returns ``{applied, errors, current}``. + dict, applies each provided key and returns ``{applied, changed, + errors}`` — ``changed`` holds ``[old, new]`` only for keys whose value + actually moved (re-setting a value to itself is not a change). + Friendly keys: units, linear_precision, angular_precision, ltscale, dimscale, dim_text_height, dim_arrow_size, dim_decimals, decimal_separator, zero_suppression, text_size, point_mode, point_size, - osmode, fillet_radius. `units` accepts mm/cm/m/inch/feet (mapped to the - INSUNITS code) and `decimal_separator` accepts "." or ",". + osmode, fillet_radius, limits, grid, grid_spacing, snap, snap_spacing, + ortho, polar, polar_angle, psltscale, annotation_scale, linear_units, + angular_units, dimstyle, textstyle. - Note that a no-argument snapshot is one ``system_get_variable`` per key. - Free headless; a COM round trip each on a live seat.""" + Refusals (per key, in ``errors``; the other keys still apply): an + unknown key; a value outside ``_SETTING_RANGES``; a malformed + ``limits`` / ``annotation_scale``; ``polar`` / ``polar_angle`` on the + headless engine (registry-saved — ``capability: registry_sysvar``); + ``dimstyle`` / ``textstyle`` when the backend has no styles contract. + + Note that a no-argument snapshot is one ``system_get_variable`` per key + (two for ``limits``). Free headless; a COM round trip each on a live + seat, and a write costs a read before and after it for ``changed``.""" if not settings: snapshot: dict = {} - for key, (var, kind) in _SETTING_MAP.items(): + for key, spec in _SETTING_MAP.items(): try: - raw = await self.system_get_variable(var) + snapshot[key] = await self._read_setting(key, spec) except Exception as exc: snapshot[key] = {"error": str(exc)} - continue - snapshot[key] = _decode_setting(key, kind, raw) return {"ok": True, "settings": snapshot} applied: dict = {} + changed: dict = {} errors: dict = {} for key, value in settings.items(): spec = _SETTING_MAP.get(key) if spec is None: errors[key] = f"unknown setting (valid: {sorted(_SETTING_MAP)})" continue - var, kind = spec try: - encoded = _encode_setting(key, kind, value) - await self.system_set_variable(var, encoded) + before = await self._read_setting(key, spec) + await self._write_setting(key, spec, value) + after = await self._read_setting(key, spec) applied[key] = value + if after != before: + changed[key] = [before, after] except Exception as exc: errors[key] = str(exc) - result = {"ok": not errors, "applied": applied} + result = {"ok": not errors, "applied": applied, "changed": changed} if errors: result["errors"] = errors return result + async def _read_setting(self, key: str, spec: tuple[str, str]) -> Any: + var, kind = spec + if kind == "limits": + low = await self.system_get_variable("LIMMIN") + high = await self.system_get_variable("LIMMAX") + if low is None or high is None: + return None + return [_point2(low), _point2(high)] + return _decode_setting(key, kind, await self.system_get_variable(var)) + + async def _write_setting(self, key: str, spec: tuple[str, str], value: Any) -> None: + var, kind = spec + if kind == "limits": + low, high = _encode_limits(key, value) # validated before either write + await self.system_set_variable("LIMMIN", low) + await self.system_set_variable("LIMMAX", high) + return + if kind == "name": + name = _encode_name(key, value) + setter = getattr(self, _STYLE_SETTERS[key], None) + if not callable(setter): + raise ValueError( + f"{key}: this backend has no {_STYLE_SETTERS[key]} (styles contract " + f"absent); the current {key} can be read but not changed here." + ) + await setter(name) + return + if kind == "bit": + flag = _encode_bool(key, value) + raw = await self.system_get_variable(var) + current = int(raw) if raw is not None else 0 + mask = _BIT_MASKS[key] + await self.system_set_variable(var, (current | mask) if flag else (current & ~mask)) + return + await self.system_set_variable(var, _encode_setting(key, kind, value)) + # --------------------------------------------------------------------------- # Helpers @@ -88,8 +145,43 @@ _SETTING_MAP: dict[str, tuple[str, str]] = { "point_size": ("PDSIZE", "float"), "osmode": ("OSMODE", "int"), "fillet_radius": ("FILLETRAD", "float"), + # ── track E: the environment a drawing lives in ────────────────────── + # `limits` is one friendly key over two variables; `_read_setting` and + # `_write_setting` special-case the kind, the variable named here is the + # first of the pair. + "limits": ("LIMMIN", "limits"), + # GRIDMODE / GRIDUNIT / SNAPMODE / SNAPUNIT are not header variables in a + # DXF file — AutoCAD keeps them on the active VPORT — so the headless engine + # routes them there (see `EzdxfBackend.system_set_variable`). + "grid": ("GRIDMODE", "bool"), + "grid_spacing": ("GRIDUNIT", "spacing"), + "snap": ("SNAPMODE", "bool"), + "snap_spacing": ("SNAPUNIT", "spacing"), + "ortho": ("ORTHOMODE", "bool"), + # Polar tracking on/off is AUTOSNAP bit 8 (the F10 toggle), not POLARMODE + # — POLARMODE's bits choose *how* polar angles are measured. Both AUTOSNAP + # and POLARANG are registry-saved, so a headless file cannot hold them. + "polar": ("AUTOSNAP", "bit"), + "polar_angle": ("POLARANG", "degrees"), + "psltscale": ("PSLTSCALE", "bool"), + # CANNOSCALE is a DICTIONARYVAR in AcDbVariableDictionary, not a header + # variable; CANNOSCALEVALUE is derived from it and read-only. + "annotation_scale": ("CANNOSCALE", "scale"), + "linear_units": ("LUNITS", "lunits"), + "angular_units": ("AUNITS", "aunits"), + # The current style names read through the header / GetVariable; a write + # delegates to the styles contract (`dimstyle_set_current` / + # `textstyle_set_current`) because DIMSTYLE is a read-only variable. + "dimstyle": ("DIMSTYLE", "name"), + "textstyle": ("TEXTSTYLE", "name"), } +_STYLE_SETTERS = {"dimstyle": "dimstyle_set_current", "textstyle": "textstyle_set_current"} + +#: AUTOSNAP bit 8 = polar tracking on. (1 marker, 2 magnet, 4 tooltip, 16 +#: object snap tracking, 32 tracking tooltips, 64 dynamic input aperture.) +_BIT_MASKS = {"polar": 8} + # INSUNITS code table (AutoCAD $INSUNITS): friendly name <-> integer code. _UNIT_TO_CODE: dict[str, int] = { "unitless": 0, @@ -111,6 +203,24 @@ _UNIT_TO_CODE: dict[str, int] = { } _CODE_TO_UNIT: dict[int, str] = {0: "unitless", 1: "inch", 2: "feet", 4: "mm", 5: "cm", 6: "m"} +# LUNITS (linear unit format) and AUNITS (angular unit format) code tables. +_LUNITS_TO_CODE: dict[str, int] = { + "scientific": 1, + "decimal": 2, + "engineering": 3, + "architectural": 4, + "fractional": 5, +} +_CODE_TO_LUNITS: dict[int, str] = {code: name for name, code in _LUNITS_TO_CODE.items()} +_AUNITS_TO_CODE: dict[str, int] = { + "degrees": 0, + "dms": 1, + "grads": 2, + "radians": 3, + "surveyor": 4, +} +_CODE_TO_AUNITS: dict[int, str] = {code: name for name, code in _AUNITS_TO_CODE.items()} + #: DIMDSEP is a character *code*, not a character — ezdxf raises DXFValueError #: on a string in the header — so the friendly key is where the translation #: belongs. Only two markers are in real use: ISO 129's point and the comma most @@ -118,6 +228,111 @@ _CODE_TO_UNIT: dict[int, str] = {0: "unitless", 1: "inch", 2: "feet", 4: "mm", 5 #: the middle of every number on the sheet, so it is refused rather than stored. _DECIMAL_MARKERS = (".", ",") +#: "1:50", "2:1", "1:2.5" — paper units : drawing units, both strictly positive. +SCALE_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$") + + +def parse_scale(text: Any) -> tuple[str, float, float]: + """``"1:50"`` → ``("1:50", 1.0, 50.0)``; raises ``ValueError`` naming the input. + + Returns the canonical name (whitespace stripped, integers without a + trailing ``.0``), the paper units and the drawing units. The annotation + scale value AutoCAD reports as CANNOSCALEVALUE is ``paper / drawing``. + """ + match = SCALE_RE.match(str(text)) if not isinstance(text, bool) else None + if match is None: + raise ValueError( + f"annotation_scale: {text!r} is not a scale — use 'paper:drawing' such as " + "'1:1', '1:50' or '2:1'." + ) + paper, drawing = float(match.group(1)), float(match.group(2)) + if paper <= 0 or drawing <= 0: + raise ValueError(f"annotation_scale: both sides of {text!r} must be greater than 0.") + return f"{_trim(paper)}:{_trim(drawing)}", paper, drawing + + +def scale_value(text: Any) -> float: + """CANNOSCALEVALUE for a scale name: ``"1:50"`` → ``0.02``.""" + _, paper, drawing = parse_scale(text) + return paper / drawing + + +def _trim(number: float) -> str: + return str(int(number)) if number == int(number) else f"{number:g}" + + +def _point2(raw: Any) -> list[float]: + """A 2D point from whatever the engine returned (tuple, list, Vec2/Vec3).""" + try: + return [float(raw[0]), float(raw[1])] + except (TypeError, IndexError, ValueError) as exc: + raise ValueError(f"expected a point, got {raw!r}") from exc + + +def _finite(key: str, value: Any, what: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise ValueError(f"{key}: {what} must be a number, got {value!r}") + number = float(value) + if not math.isfinite(number): + raise ValueError(f"{key}: {what} must be finite") + return number + + +def _encode_limits(key: str, value: Any) -> tuple[tuple[float, float], tuple[float, float]]: + """``[[xmin, ymin], [xmax, ymax]]`` → two 2D tuples; refused before any write.""" + if ( + not isinstance(value, (list, tuple)) + or len(value) != 2 + or any(not isinstance(corner, (list, tuple)) or len(corner) != 2 for corner in value) + ): + raise ValueError(f"{key}: expected [[xmin, ymin], [xmax, ymax]], got {value!r}") + xmin = _finite(key, value[0][0], "xmin") + ymin = _finite(key, value[0][1], "ymin") + xmax = _finite(key, value[1][0], "xmax") + ymax = _finite(key, value[1][1], "ymax") + if xmax <= xmin or ymax <= ymin: + raise ValueError( + f"{key}: the upper-right corner must be above and right of the lower-left " + f"(got {[[xmin, ymin], [xmax, ymax]]})" + ) + return (xmin, ymin), (xmax, ymax) + + +_TRUE_WORDS = {"1", "on", "true", "yes"} +_FALSE_WORDS = {"0", "off", "false", "no"} + + +def _encode_bool(key: str, value: Any) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, (int, float)) and value in (0, 1): + return bool(value) + if isinstance(value, str): + word = value.strip().lower() + if word in _TRUE_WORDS: + return True + if word in _FALSE_WORDS: + return False + raise ValueError(f"{key}: expected true/false (or 1/0, on/off), got {value!r}") + + +def _encode_name(key: str, value: Any) -> str: + if not isinstance(value, str) or not value.strip(): + raise ValueError(f"{key}: expected a non-empty style name, got {value!r}") + return value.strip() + + +def _encode_enum(key: str, value: Any, table: dict[str, int]) -> int: + if isinstance(value, (int, float)) and not isinstance(value, bool): + code = int(value) + if code in table.values(): + return code + raise ValueError(f"{key}: code {code} is not one of {sorted(table.values())}") + name = str(value).strip().lower() + if name in table: + return table[name] + raise ValueError(f"{key}: unknown value {value!r}. Use one of {sorted(table)}.") + def _encode_setting(key: str, kind: str, value: Any) -> Any: """Coerce a friendly value into the raw system-variable value.""" @@ -131,6 +346,27 @@ def _encode_setting(key: str, kind: str, value: Any) -> Any: f"{sorted(set(_UNIT_TO_CODE))} or an INSUNITS integer." ) return code + if kind == "lunits": + return _encode_enum(key, value, _LUNITS_TO_CODE) + if kind == "aunits": + return _encode_enum(key, value, _AUNITS_TO_CODE) + if kind == "bool": + return 1 if _encode_bool(key, value) else 0 + if kind == "scale": + name, _paper, _drawing = parse_scale(value) + return name + if kind == "spacing": + if isinstance(value, (list, tuple)): + if len(value) != 2: + raise ValueError(f"{key}: expected a number or [x, y], got {value!r}") + x = _in_range(key, _finite(key, value[0], "x")) + y = _in_range(key, _finite(key, value[1], "y")) + return (x, y) + step = _in_range(key, _finite(key, value, "spacing")) + return (step, step) + if kind == "degrees": + degrees = _in_range(key, _finite(key, value, "angle")) + return math.radians(degrees) if kind == "char": if isinstance(value, bool): marker = "" @@ -173,6 +409,10 @@ _SETTING_RANGES: dict[str, tuple[float, float]] = { "ltscale": (1e-9, 1e6), "point_size": (-100, 1e6), # negative is a percentage of the screen "fillet_radius": (0, 1e6), + # track E + "grid_spacing": (1e-9, 1e9), # GRIDUNIT, strictly positive per axis + "snap_spacing": (1e-9, 1e9), # SNAPUNIT, strictly positive per axis + "polar_angle": (1e-9, 360), # POLARANG increment, degrees at the facade } @@ -200,6 +440,47 @@ def _decode_setting(key: str, kind: str, raw: Any) -> Any: except (TypeError, ValueError): return raw return {"code": code, "name": _CODE_TO_UNIT.get(code, "unknown")} + if kind == "lunits": + try: + code = int(raw) + except (TypeError, ValueError): + return raw + return {"code": code, "name": _CODE_TO_LUNITS.get(code, "unknown")} + if kind == "aunits": + try: + code = int(raw) + except (TypeError, ValueError): + return raw + return {"code": code, "name": _CODE_TO_AUNITS.get(code, "unknown")} + if kind == "bool": + try: + return bool(int(raw)) + except (TypeError, ValueError): + return raw + if kind == "bit": + try: + return bool(int(raw) & _BIT_MASKS[key]) + except (TypeError, ValueError): + return raw + if kind == "degrees": + try: + return round(math.degrees(float(raw)), 6) + except (TypeError, ValueError): + return raw + if kind == "spacing": + try: + x, y = _point2(raw) + except ValueError: + return raw + return x if x == y else [x, y] + if kind == "scale": + try: + name, paper, drawing = parse_scale(raw) + except ValueError: + return {"name": str(raw), "value": None} + return {"name": name, "value": paper / drawing} + if kind == "name": + return str(raw) if kind == "char": try: code = int(raw) diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 465d1d8..04c5ff6 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -39,6 +39,7 @@ from .base import ( normalize_lineweight, shoelace_area, ) +from .contracts.settings import parse_scale, scale_value from .quarantine import ( AbandonedCall, DocumentQuarantineError, @@ -59,6 +60,41 @@ __all__ = [ #: document that has been abandoned on repeatedly rather than once. _MAX_ABANDONED_RECORDS = 10 +#: System variables that AutoCAD does not keep in the HEADER section. Grid and +#: snap belong to the active VPORT table entry (DXF codes 76 / 15 / 75 / 14); +#: measured 2026-09-16: `doc.header["$GRIDMODE"] = 1` raises `DXFKeyError`. +_VPORT_SYSVARS: dict[str, tuple[str, str]] = { + "GRIDMODE": ("grid_on", "flag"), + "SNAPMODE": ("snap_on", "flag"), + "GRIDUNIT": ("grid_spacing", "point"), + "SNAPUNIT": ("snap_spacing", "point"), +} + +#: Registry-saved or never-saved (AutoCAD System Variables reference, "Saved +#: in" column). A file has nowhere to keep them, so the headless engine refuses +#: a write with `capability: registry_sysvar` instead of storing a value that +#: would vanish; `engineering.standards.sysvars` pins this set to its catalogue. +_UNSAVED_SYSVARS: frozenset[str] = frozenset( + { + "AUTOSNAP", + "POLARANG", + "POLARMODE", + "ATTREQ", + "ATTDIA", + "SAVETIME", + "FILEDIA", + "XREFCTL", + "PROXYSHOW", + "CMDECHO", + "HIGHLIGHT", + "HPNAME", + "HPSCALE", + "HPANG", + "DWGNAME", + "DWGPREFIX", + } +) + log = logging.getLogger(__name__) @@ -1058,6 +1094,9 @@ class EzdxfBackend(AutoCADBackend): False, reason="acis_generation_requires_live_autocad" ), "lisp": FeatureCapability(False, reason="live_com_only"), + "registry_sysvar": FeatureCapability( + False, reason="registry_saved_variables_have_no_home_in_a_file" + ), }, ) @@ -5523,14 +5562,69 @@ class EzdxfBackend(AutoCADBackend): async def system_get_variable(self, name) -> Any: def _sync(): doc = self._require_doc() - return doc.header.get(f"${name.upper()}", None) + key = str(name).upper() + if key in _VPORT_SYSVARS: + attr, shape = _VPORT_SYSVARS[key] + raw = getattr(self._active_vport(doc).dxf, attr) + if shape == "point": + return (float(raw[0]), float(raw[1])) + return int(raw) + if key == "CANNOSCALE": + return self._dictionary_variable(doc, "CANNOSCALE", "1:1") + if key == "CANNOSCALEVALUE": + return scale_value(self._dictionary_variable(doc, "CANNOSCALE", "1:1")) + return doc.header.get(f"${key}", None) return await self._async(_sync) async def system_set_variable(self, name, value) -> dict: + from ezdxf.lldxf.const import DXFKeyError + def _sync(): doc = self._require_doc() - doc.header[f"${name.upper()}"] = value + key = str(name).upper() + if key in _UNSAVED_SYSVARS: + raise UnsupportedCapabilityError( + "registry_sysvar", + f"system_set_variable: {key} is saved in the AutoCAD registry (or not " + "saved at all), never in the drawing, so the headless ezdxf backend has " + "nowhere to keep it. Set it on the live COM backend.", + ) + if key in _VPORT_SYSVARS: + attr, shape = _VPORT_SYSVARS[key] + vport = self._active_vport(doc) + if shape == "point": + x, y = float(value[0]), float(value[1]) + if x <= 0 or y <= 0: + raise ValueError(f"{key}: spacing must be greater than 0, got {value!r}") + vport.dxf.set(attr, (x, y)) + else: + vport.dxf.set(attr, 1 if int(value) else 0) + elif key == "CANNOSCALE": + self._set_annotation_scale(doc, value) + elif key == "CANNOSCALEVALUE": + raise ValueError( + "CANNOSCALEVALUE is read-only; set CANNOSCALE (e.g. '1:50') instead." + ) + else: + try: + doc.header[f"${key}"] = value + except DXFKeyError as exc: + # Keeps the pinned "$$DIMTXT" text (tests/test_dimension_header_vars.py) + # while saying why the write is refused rather than dropped. + raise ValueError( + f"system_set_variable: {exc} ezdxf has no header slot for it, so the " + "value would be dropped at save; system_variable_describe(name) says " + "where the variable lives." + ) from exc + if key in ("LIMMIN", "LIMMAX"): + # Measured: `Drawing.update_limits()` copies the model-space + # LAYOUT's limits into the header on every write(), so a + # header-only value is overwritten at save. The layout is + # the source of truth; keep both in step. + doc.modelspace().dxf_layout.dxf.set( + key.lower(), (float(value[0]), float(value[1])) + ) self._mark_dirty() return {"ok": True, "variable": name, "value": value} @@ -5808,3 +5902,77 @@ class EzdxfBackend(AutoCADBackend): return _entity_info_dxf(xline) return await self._async(_sync) + + # ── settings (track E, group C) ─────────────────────────────────────────── + # + # Where the variables the `drawing_settings` facade reaches actually live + # in a DXF file when they are not header variables. + + @staticmethod + def _active_vport(doc): + """The `*Active` VPORT entry — grid and snap state live on it. + + Owned here (group C). Group V's named views use the same entry and + carry a worktree-only copy that the merge agent deletes; this + definition is the one that ships. + """ + entries = doc.viewports.get("*Active") + if not entries: + entries = [doc.viewports.new("*Active")] + return entries[0] + + @staticmethod + def _variable_dictionary(doc, create: bool): + """`AcDbVariableDictionary` under the root dictionary (CANNOSCALE lives there).""" + vardict = doc.rootdict.get("AcDbVariableDictionary") + if vardict is None and create: + vardict = doc.rootdict.add_new_dict("AcDbVariableDictionary") + return vardict + + def _dictionary_variable(self, doc, key: str, default: str) -> str: + vardict = self._variable_dictionary(doc, create=False) + if vardict is None: + return default + entry = vardict.get(key) + return default if entry is None else str(entry.dxf.value) + + @staticmethod + def _ensure_scale_entry(doc, name: str, paper: float, drawing: float) -> bool: + """Add `name` to ACAD_SCALELIST unless present; True when added. + + ezdxf has no SCALE entity class, so the object is authored as raw tags + and loaded through the factory (a `DXFTagStorage` that exports + verbatim). Measured: it round-trips, ezdxf registers the CLASS, and + `doc.audit()` is clean after reopen. AutoCAD only honours a CANNOSCALE + that names an entry of this list. + """ + from ezdxf.entities import factory + from ezdxf.lldxf.extendedtags import ExtendedTags + + scales = doc.rootdict.get("ACAD_SCALELIST") + if scales is None: + scales = doc.rootdict.add_new_dict("ACAD_SCALELIST") + if scales.get(name) is not None: + return False + handle = doc.entitydb.next_handle() + unit = 1 if paper == drawing else 0 + text = ( + f" 0\nSCALE\n 5\n{handle}\n330\n{scales.dxf.handle}\n100\nAcDbScale\n" + f" 70\n0\n300\n{name}\n140\n{paper}\n141\n{drawing}\n290\n{unit}\n" + ) + entry = factory.load(ExtendedTags.from_text(text), doc) + doc.entitydb.add(entry) + doc.objects.add_object(entry) + scales.add(name, entry) + return True + + def _set_annotation_scale(self, doc, value: Any) -> str: + name, paper, drawing = parse_scale(value) # refused before any write + self._ensure_scale_entry(doc, name, paper, drawing) + vardict = self._variable_dictionary(doc, create=True) + entry = vardict.get("CANNOSCALE") + if entry is None: + vardict.add_dict_var("CANNOSCALE", name) + else: + entry.dxf.value = name + return name diff --git a/discovery/aliases.py b/discovery/aliases.py index e8b9af9..e1a6c2c 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -498,7 +498,20 @@ TOOL_ALIASES: dict[str, ToolAliases] = { ), ), "drawing_settings": ToolAliases( - acad=("UNITS", "DDUNITS", "OSNAP", "DSETTINGS", "LTSCALE", "DIMSCALE"), + acad=( + "UNITS", + "DDUNITS", + "OSNAP", + "DSETTINGS", + "LTSCALE", + "DIMSCALE", + "LIMITS", + "GRID", + "SNAP", + "ORTHO", + "PSLTSCALE", + "CANNOSCALE", + ), synonyms=( "drawing units", "millimetres or inches", @@ -506,6 +519,16 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "dimension scale", "linetype scale", "object snap settings", + "drawing limits", + "turn the grid on", + "grid spacing", + "snap spacing", + "ortho mode", + "polar tracking", + "annotation scale", + "architectural units", + "current dimension style", + "current text style", ), ), "drawing_undo": ToolAliases( diff --git a/server.py b/server.py index 27a06b2..b4b7864 100644 --- a/server.py +++ b/server.py @@ -5050,8 +5050,13 @@ async def drawing_settings( "units (mm/cm/m/inch/feet), linear_precision, angular_precision, ltscale, " "dimscale, dim_text_height, dim_arrow_size, dim_decimals, " 'decimal_separator ("." or ","), zero_suppression, text_size, point_mode, ' - "point_size, osmode, fillet_radius. " - 'Example: {"units": "mm", "dimscale": 1.0, "dim_text_height": 3.5}.' + "point_size, osmode, fillet_radius, limits ([[xmin,ymin],[xmax,ymax]]), " + "grid (bool), grid_spacing, snap (bool), snap_spacing, ortho (bool), " + "polar (bool), polar_angle (degrees), psltscale (bool), annotation_scale " + '("1:50"), linear_units (decimal|engineering|architectural|fractional|' + "scientific), angular_units (degrees|dms|grads|radians|surveyor), " + "dimstyle, textstyle (current style names). " + 'Example: {"units": "mm", "limits": [[0, 0], [420, 297]], "grid": true}.' ), ), ] = None, @@ -5060,14 +5065,26 @@ async def drawing_settings( """Read or change common AutoCAD drawing settings by friendly name. A convenience facade over the system variables (INSUNITS, LUPREC, LTSCALE, - DIMSCALE, DIMTXT, DIMASZ, DIMDEC, DIMDSEP, DIMZIN, TEXTSIZE, OSMODE, …) so - the user can say "set units to mm and dimension text to 3.5" without - memorising sysvar names. Call with no argument to get a full snapshot of the - current settings. + DIMSCALE, DIMTXT, DIMASZ, DIMDEC, DIMDSEP, DIMZIN, TEXTSIZE, OSMODE, LIMMIN/ + LIMMAX, GRIDMODE/GRIDUNIT, SNAPMODE/SNAPUNIT, ORTHOMODE, AUTOSNAP/POLARANG, + PSLTSCALE, CANNOSCALE, LUNITS, AUNITS, DIMSTYLE, TEXTSTYLE) so the user can + say "set units to mm, limits to A3 and the grid on" without memorising + sysvar names. Call with no argument for a full snapshot; a write returns + `applied`, `changed` ({key: [old, new]} — only what moved) and `errors`. `dim_text_height` / `dim_arrow_size` / `dim_decimals` / `decimal_separator` / `zero_suppression` shape the *dimension* — `text_size` is TEXTSIZE, the height of a standalone TEXT entity, and does not touch dimensions. + + Refusals, per key, nothing else rolled back: an unknown key; a value outside + its range (grid/snap spacing > 0, polar_angle 0–360, precision 0–8, …); a + malformed `limits` or `annotation_scale`; `polar` / `polar_angle` on the + headless engine (`capability: registry_sysvar` — AutoCAD keeps them in the + registry, a file cannot); `dimstyle` / `textstyle` when the backend has no + styles contract. On the live engine `annotation_scale` must name a scale in + the drawing's scale list (SCALELISTEDIT); the headless engine adds it. + Headlessly, grid/snap are stored on the active VPORT and the annotation + scale in the variable dictionary — the places AutoCAD reads them from. """ if settings: await ctx.info(f"Applying drawing settings: {', '.join(settings)}") diff --git a/tests/test_settings_facade.py b/tests/test_settings_facade.py new file mode 100644 index 0000000..d8afb3d --- /dev/null +++ b/tests/test_settings_facade.py @@ -0,0 +1,425 @@ +"""drawing_settings, track E: limits, grid/snap, ortho/polar, PSLTSCALE, annotation +scale, unit formats and the current styles — on both engines. + +Measured before writing (ezdxf 1.4.4, 2026-09-16): + +* `doc.header["$GRIDMODE"]` (and GRIDUNIT / SNAPMODE / SNAPUNIT / POLARMODE / + POLARANG / CANNOSCALE / CANNOSCALEVALUE) raises `DXFKeyError: Invalid header + variable` — none of them is a DXF header variable. Grid and snap live on the + active VPORT; CANNOSCALE is a DICTIONARYVAR in AcDbVariableDictionary; the + polar pair is registry-saved and has no home in a file at all. +* A `$LIMMAX` written to the header is overwritten at save by + `Drawing.update_limits()`, which copies the model-space LAYOUT's limits into + the header — so a header-only write reported success and vanished on reload. +""" + +from __future__ import annotations + +import math + +import pytest + +from backends.base import UnsupportedCapabilityError +from backends.contracts.settings import _SETTING_MAP, parse_scale, scale_value + +pytestmark = pytest.mark.asyncio + +NEW_KEYS = ( + "limits", + "grid", + "grid_spacing", + "snap", + "snap_spacing", + "ortho", + "polar", + "polar_angle", + "psltscale", + "annotation_scale", + "linear_units", + "angular_units", + "dimstyle", + "textstyle", +) + + +def _active_vport(backend): + return backend._doc.viewports.get("*Active")[0] + + +# ── pure helpers ──────────────────────────────────────────────────────────── + + +def test_parse_scale_canonicalises_and_refuses(): + assert parse_scale("1:50") == ("1:50", 1.0, 50.0) + assert parse_scale(" 2 : 1 ") == ("2:1", 2.0, 1.0) + assert parse_scale("1:2.5") == ("1:2.5", 1.0, 2.5) + assert scale_value("1:50") == pytest.approx(0.02) + for bad in ("fifty", "1:0", "0:1", "1-50", "", True): + with pytest.raises(ValueError, match="annotation_scale"): + parse_scale(bad) + + +def test_every_new_key_is_in_the_map(): + for key in NEW_KEYS: + assert key in _SETTING_MAP, key + + +# ── headless engine ───────────────────────────────────────────────────────── + + +async def test_snapshot_reports_every_key_including_the_unavailable_ones(backend): + snap = (await backend.drawing_settings())["settings"] + for key in NEW_KEYS: + assert key in snap, key + assert snap["limits"] == [[0.0, 0.0], [420.0, 297.0]] + assert snap["grid"] is False and snap["snap"] is False and snap["ortho"] is False + assert snap["psltscale"] is True + assert snap["annotation_scale"] == {"name": "1:1", "value": 1.0} + assert snap["linear_units"] == {"code": 2, "name": "decimal"} + assert snap["angular_units"] == {"code": 0, "name": "degrees"} + assert snap["dimstyle"] == "ISO-25" + assert snap["textstyle"] == "Standard" + # registry-saved: a file holds no value, and None says so rather than a guess + assert snap["polar"] is None + assert snap["polar_angle"] is None + + +async def test_limits_survive_a_save_and_reopen(backend, tmp_path): + res = await backend.drawing_settings({"limits": [[0, 0], [500, 350]]}) + assert res["ok"] is True, res.get("errors") + assert res["changed"]["limits"] == [ + [[0.0, 0.0], [420.0, 297.0]], + [[0.0, 0.0], [500.0, 350.0]], + ] + layout = backend._doc.modelspace().dxf_layout.dxf + assert tuple(layout.limmax)[:2] == (500.0, 350.0), "the layout is what survives write()" + + path = str(tmp_path / "limits.dxf") + await backend.drawing_save_as(path) + await backend.drawing_open(path) + assert (await backend.drawing_settings())["settings"]["limits"] == [ + [0.0, 0.0], + [500.0, 350.0], + ] + + +async def test_grid_and_snap_live_on_the_active_vport(backend, tmp_path): + res = await backend.drawing_settings( + {"grid": True, "grid_spacing": 5, "snap": "on", "snap_spacing": [2.5, 5]} + ) + assert res["ok"] is True, res.get("errors") + vport = _active_vport(backend).dxf + assert vport.grid_on == 1 and vport.snap_on == 1 + assert tuple(vport.grid_spacing)[:2] == (5.0, 5.0) + assert tuple(vport.snap_spacing)[:2] == (2.5, 5.0) + + snap = (await backend.drawing_settings())["settings"] + assert snap["grid"] is True and snap["grid_spacing"] == 5.0 + assert snap["snap"] is True and snap["snap_spacing"] == [2.5, 5.0] + + path = str(tmp_path / "grid.dxf") + await backend.drawing_save_as(path) + await backend.drawing_open(path) + again = (await backend.drawing_settings())["settings"] + assert again["grid"] is True and again["grid_spacing"] == 5.0 + assert again["snap_spacing"] == [2.5, 5.0] + + +async def test_ortho_and_psltscale_are_header_variables(backend): + res = await backend.drawing_settings({"ortho": True, "psltscale": False}) + assert res["ok"] is True, res.get("errors") + assert backend._doc.header["$ORTHOMODE"] == 1 + assert backend._doc.header["$PSLTSCALE"] == 0 + snap = (await backend.drawing_settings())["settings"] + assert snap["ortho"] is True and snap["psltscale"] is False + + +async def test_polar_is_refused_headlessly_by_capability(backend): + res = await backend.drawing_settings({"polar": True, "polar_angle": 45, "ortho": True}) + assert res["ok"] is False + assert res["applied"] == {"ortho": True}, "the key that has a home is still applied" + assert "registry" in res["errors"]["polar"] + assert "registry" in res["errors"]["polar_angle"] + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await backend.system_set_variable("POLARANG", 0.5) + assert excinfo.value.capability == "registry_sysvar" + + +async def test_annotation_scale_writes_the_variable_dictionary_and_scale_list(backend, tmp_path): + res = await backend.drawing_settings({"annotation_scale": "1:50"}) + assert res["ok"] is True, res.get("errors") + assert res["changed"]["annotation_scale"] == [ + {"name": "1:1", "value": 1.0}, + {"name": "1:50", "value": 0.02}, + ] + vardict = backend._doc.rootdict.get("AcDbVariableDictionary") + assert vardict["CANNOSCALE"].dxf.value == "1:50" + scales = backend._doc.rootdict.get("ACAD_SCALELIST") + assert scales.get("1:50") is not None, "AutoCAD only honours a scale that is in the list" + assert await backend.system_get_variable("CANNOSCALEVALUE") == pytest.approx(0.02) + + # re-setting the same scale is not a change and adds no second list entry + again = await backend.drawing_settings({"annotation_scale": "1:50"}) + assert again["changed"] == {} + assert len(list(scales.keys())) == 1 + + path = str(tmp_path / "scale.dxf") + await backend.drawing_save_as(path) + await backend.drawing_open(path) + assert (await backend.drawing_settings())["settings"]["annotation_scale"] == { + "name": "1:50", + "value": 0.02, + } + assert backend._doc.audit().has_errors is False + + +async def test_unit_formats_by_name_and_by_code(backend): + res = await backend.drawing_settings({"linear_units": "architectural", "angular_units": "dms"}) + assert res["ok"] is True, res.get("errors") + assert backend._doc.header["$LUNITS"] == 4 + assert backend._doc.header["$AUNITS"] == 1 + snap = (await backend.drawing_settings())["settings"] + assert snap["linear_units"] == {"code": 4, "name": "architectural"} + assert snap["angular_units"] == {"code": 1, "name": "dms"} + + by_code = await backend.drawing_settings({"linear_units": 1, "angular_units": 3}) + assert by_code["ok"] is True + assert backend._doc.header["$LUNITS"] == 1 and backend._doc.header["$AUNITS"] == 3 + + bad = await backend.drawing_settings({"linear_units": "furlongs", "angular_units": 9}) + assert "architectural" in bad["errors"]["linear_units"] + assert "angular_units" in bad["errors"]["angular_units"] + assert backend._doc.header["$LUNITS"] == 1, "a refused value writes nothing" + + +async def test_current_styles_delegate_to_the_styles_contract(backend, monkeypatch): + calls: list[tuple[str, str]] = [] + + async def _dimstyle(name): + calls.append(("dimstyle_set_current", name)) + backend._doc.header["$DIMSTYLE"] = name + return {"ok": True, "current": name, "previous": "ISO-25"} + + async def _textstyle(name): + calls.append(("textstyle_set_current", name)) + backend._doc.header["$TEXTSTYLE"] = name + return {"ok": True, "current": name, "previous": "Standard"} + + monkeypatch.setattr(backend, "dimstyle_set_current", _dimstyle, raising=False) + monkeypatch.setattr(backend, "textstyle_set_current", _textstyle, raising=False) + res = await backend.drawing_settings({"dimstyle": " ANSI ", "textstyle": "ISOCP"}) + assert res["ok"] is True, res.get("errors") + assert calls == [("dimstyle_set_current", "ANSI"), ("textstyle_set_current", "ISOCP")] + assert res["changed"]["dimstyle"] == ["ISO-25", "ANSI"] + snap = (await backend.drawing_settings())["settings"] + assert snap["dimstyle"] == "ANSI" and snap["textstyle"] == "ISOCP" + + +async def test_current_styles_are_refused_when_no_styles_contract_exists(backend, monkeypatch): + # Group S may or may not have merged: force the "absent" branch either way. + monkeypatch.setattr(backend, "dimstyle_set_current", None, raising=False) + res = await backend.drawing_settings({"dimstyle": "ANSI"}) + assert res["ok"] is False + assert "dimstyle_set_current" in res["errors"]["dimstyle"] + assert (await backend.drawing_settings())["settings"]["dimstyle"] == "ISO-25" + + +async def test_changed_reports_only_values_that_moved(backend): + same = await backend.drawing_settings({"psltscale": True, "ortho": False}) + assert same["ok"] is True and same["changed"] == {} + moved = await backend.drawing_settings({"psltscale": False, "ortho": False}) + assert moved["changed"] == {"psltscale": [True, False]} + + +async def test_malformed_values_are_refused_before_any_write(backend): + before_limmax = tuple(backend._doc.header["$LIMMAX"]) + res = await backend.drawing_settings( + { + "limits": [[0, 0], [0, 5]], + "annotation_scale": "fifty", + "grid_spacing": 0, + "snap_spacing": [1, 2, 3], + "polar": "maybe", + "dimstyle": "", + } + ) + assert res["ok"] is False and res["applied"] == {} + assert set(res["errors"]) == { + "limits", + "annotation_scale", + "grid_spacing", + "snap_spacing", + "polar", + "dimstyle", + } + assert "upper-right" in res["errors"]["limits"] + assert "paper:drawing" in res["errors"]["annotation_scale"] + assert "greater than 0" in res["errors"]["grid_spacing"] + assert tuple(backend._doc.header["$LIMMAX"]) == before_limmax + assert backend._doc.rootdict.get("AcDbVariableDictionary") is None + assert tuple(_active_vport(backend).dxf.grid_spacing)[:2] != (0.0, 0.0) + + +async def test_raw_gridmode_routes_to_the_vport_and_the_old_error_is_gone(backend): + assert await backend.system_get_variable("GRIDMODE") == 0 + out = await backend.system_set_variable("GRIDMODE", 1) + assert out["ok"] is True + assert _active_vport(backend).dxf.grid_on == 1 + assert await backend.system_get_variable("GRIDMODE") == 1 + + +async def test_raw_unknown_header_name_is_a_named_value_error(backend): + with pytest.raises(ValueError, match=r"\$HPNAME2"): + await backend.system_set_variable("HPNAME2", "x") + + +async def test_cannoscalevalue_is_read_only(backend): + with pytest.raises(ValueError, match="CANNOSCALEVALUE is read-only"): + await backend.system_set_variable("CANNOSCALEVALUE", 0.5) + + +def test_both_capability_maps_declare_registry_sysvar(): + from backends.com_backend import ComBackend + from backends.ezdxf_backend import EzdxfBackend + + ezdxf_features = EzdxfBackend().capabilities().to_dict()["features"] + com_features = ComBackend().capabilities().to_dict()["features"] + assert ezdxf_features["registry_sysvar"]["supported"] is False + assert com_features["registry_sysvar"]["supported"] is True + + +# ── server tool wiring ────────────────────────────────────────────────────── + + +class _FakeCtx: + def __init__(self, backend): + self.lifespan_context = {"backend": backend} + + async def info(self, *a, **k): + pass + + +async def test_server_tool_carries_the_new_keys(backend): + import server + + ctx = _FakeCtx(backend) + write = await server.drawing_settings( + settings={"limits": [[0, 0], [297, 210]], "grid": True, "annotation_scale": "1:2"}, + ctx=ctx, + ) + assert write["ok"] is True, write.get("errors") + read = await server.drawing_settings(ctx=ctx) + assert read["settings"]["limits"] == [[0.0, 0.0], [297.0, 210.0]] + assert read["settings"]["annotation_scale"] == {"name": "1:2", "value": 0.5} + + +# ── live engine, against a fake ActiveX application ───────────────────────── + + +class _FakeApp: + """Records GetVariable / SetVariable; stores what AutoCAD would store.""" + + def __init__(self): + self.store = { + "INSUNITS": 4, + "LIMMIN": (0.0, 0.0), + "LIMMAX": (420.0, 297.0), + "GRIDMODE": 0, + "GRIDUNIT": (10.0, 10.0), + "SNAPMODE": 0, + "SNAPUNIT": (10.0, 10.0), + "ORTHOMODE": 0, + "AUTOSNAP": 63, + "POLARANG": math.pi / 2, + "PSLTSCALE": 1, + "CANNOSCALE": "1:1", + "LUNITS": 2, + "AUNITS": 0, + "DIMSTYLE": "ISO-25", + "TEXTSTYLE": "Standard", + "DIMSCALE": 1.0, + } + self.calls: list[tuple] = [] + + def GetVariable(self, name): + self.calls.append(("GetVariable", name)) + return self.store[name] + + def SetVariable(self, name, value): + stored = getattr(value, "value", value) # a VARIANT array → its Python sequence + if isinstance(stored, list): + stored = tuple(stored) + self.calls.append(("SetVariable", name, stored)) + self.store[name] = stored + + +@pytest.fixture +def com_backend(monkeypatch): + pytest.importorskip("win32com.client", reason="pywin32 not installed") + from backends import com_backend as module + + app = _FakeApp() + monkeypatch.setattr(module, "_acad_app", lambda: app) + 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_facade_sends_autocad_shaped_values(com_backend): + backend, app = com_backend + res = await backend.drawing_settings( + { + "limits": [[0, 0], [500, 350]], + "grid": True, + "grid_spacing": 5, + "polar": False, + "polar_angle": 45, + "annotation_scale": "1:50", + "linear_units": "architectural", + "psltscale": 0, + } + ) + assert res["ok"] is True, res.get("errors") + sets = [call[1:] for call in app.calls if call[0] == "SetVariable"] + assert sets == [ + ("LIMMIN", (0.0, 0.0)), + ("LIMMAX", (500.0, 350.0)), + ("GRIDMODE", 1), + ("GRIDUNIT", (5.0, 5.0)), + ("AUTOSNAP", 55), # 63 with bit 8 cleared: polar off, everything else kept + ("POLARANG", pytest.approx(math.radians(45))), + ("CANNOSCALE", "1:50"), + ("LUNITS", 4), + ("PSLTSCALE", 0), + ] + assert res["changed"]["polar"] == [True, False] + assert res["changed"]["polar_angle"] == [90.0, 45.0] + + +async def test_com_point_variables_travel_as_variant_double_arrays(com_backend): + import pythoncom + + backend, app = com_backend + seen: dict = {} + real_set = app.SetVariable + + def _capture(name, value): + seen[name] = value + real_set(name, value) + + app.SetVariable = _capture + await backend.system_set_variable("LIMMAX", (300, 200)) + variant = seen["LIMMAX"] + assert variant.varianttype == pythoncom.VT_ARRAY | pythoncom.VT_R8 + assert tuple(variant.value) == (300.0, 200.0) + + +async def test_com_refuses_before_touching_activex(com_backend): + backend, app = com_backend + res = await backend.drawing_settings({"limits": [[0, 0], [0, 0]], "annotation_scale": "x"}) + assert res["ok"] is False + assert not [call for call in app.calls if call[0] == "SetVariable"] From e8e7e3c5248c334955664c0b78a8596923673549 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Wed, 16 Sep 2026 21:31:13 +0300 Subject: [PATCH 02/12] fix(task-14): match ACAD_SCALELIST entries by their group-300 name, not the dictionary key `_ensure_scale_entry` decided "already present" with `scales.get(name)`, a lookup by DICTIONARY key. AutoCAD saves ACAD_SCALELIST keyed A0, A1, ... with the scale name only in the SCALE object's AcDbScale group 300, so on any AutoCAD-authored drawing (whose default metric list already carries 1:1 ... 1:100, 2:1) the check never matched and every `annotation_scale` write appended a second SCALE object with the same name. Presence is now decided by iterating the list and reading each entry's group-300 name (`_scale_entry_name`, which reads the DXFTagStorage subclass ezdxf loads SCALE into). A genuinely new entry is still keyed by its name (the plan's test contract); if that key is somehow taken it falls back to the next free `A` slot like AutoCAD. Regression test seeds an A0/A1/A2-keyed list the way AutoCAD writes it and asserts: an existing 1:50 is reused (no new key, exactly one object named 1:50), a missing 1:25 is appended exactly once and is idempotent, and the file round-trips with a clean audit. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/ezdxf_backend.py | 41 +++++++++++++++++++--- tests/test_settings_facade.py | 65 +++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 4 deletions(-) diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 04c5ff6..55712aa 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -5937,9 +5937,34 @@ class EzdxfBackend(AutoCADBackend): return default if entry is None else str(entry.dxf.value) @staticmethod - def _ensure_scale_entry(doc, name: str, paper: float, drawing: float) -> bool: + def _scale_entry_name(entry) -> str | None: + """The name a SCALE object carries in its AcDbScale group 300. + + The dictionary *key* is not the name: AutoCAD saves ACAD_SCALELIST + keyed `A0`, `A1`, … and only group 300 says `1:50`. ezdxf loads SCALE + as `DXFTagStorage`, so the tag is read from the stored subclass. + """ + xtags = getattr(entry, "xtags", None) + if xtags is None: + return None + for subclass in xtags.subclasses: + if subclass and subclass[0].code == 100 and subclass[0].value == "AcDbScale": + for tag in subclass: + if tag.code == 300: + return str(tag.value) + return None + + @classmethod + def _ensure_scale_entry(cls, doc, name: str, paper: float, drawing: float) -> bool: """Add `name` to ACAD_SCALELIST unless present; True when added. + Presence is decided by the SCALE objects' group-300 names, never by + the dictionary key — an AutoCAD-authored drawing keys the list `A0`, + `A1`, … (its default metric list already carries 1:1 … 1:100, 2:1), + and matching on the key appended a duplicate `1:50` to every one of + them. A new entry is keyed by its name unless that key is taken, in + which case it takes the next free `A` slot like AutoCAD does. + ezdxf has no SCALE entity class, so the object is authored as raw tags and loaded through the factory (a `DXFTagStorage` that exports verbatim). Measured: it round-trips, ezdxf registers the CLASS, and @@ -5952,8 +5977,16 @@ class EzdxfBackend(AutoCADBackend): scales = doc.rootdict.get("ACAD_SCALELIST") if scales is None: scales = doc.rootdict.add_new_dict("ACAD_SCALELIST") - if scales.get(name) is not None: - return False + existing_keys = set(scales.keys()) + for _key, entry in scales.items(): + if cls._scale_entry_name(entry) == name: + return False + key = name + if key in existing_keys: + index = 0 + while f"A{index}" in existing_keys: + index += 1 + key = f"A{index}" handle = doc.entitydb.next_handle() unit = 1 if paper == drawing else 0 text = ( @@ -5963,7 +5996,7 @@ class EzdxfBackend(AutoCADBackend): entry = factory.load(ExtendedTags.from_text(text), doc) doc.entitydb.add(entry) doc.objects.add_object(entry) - scales.add(name, entry) + scales.add(key, entry) return True def _set_annotation_scale(self, doc, value: Any) -> str: diff --git a/tests/test_settings_facade.py b/tests/test_settings_facade.py index d8afb3d..2a8b8b6 100644 --- a/tests/test_settings_facade.py +++ b/tests/test_settings_facade.py @@ -173,6 +173,71 @@ async def test_annotation_scale_writes_the_variable_dictionary_and_scale_list(ba assert backend._doc.audit().has_errors is False +def _seed_autocad_style_scale_list(doc, names): + """ACAD_SCALELIST the way AutoCAD saves it: keyed A0, A1, ... with the scale + name only in the SCALE object's group 300 (the default metric list already + carries 1:1, 1:2, 1:5, 1:10, 1:20, 1:50, 1:100, 2:1 ...).""" + from ezdxf.entities import factory + from ezdxf.lldxf.extendedtags import ExtendedTags + + scales = doc.rootdict.get("ACAD_SCALELIST") + if scales is None: + scales = doc.rootdict.add_new_dict("ACAD_SCALELIST") + for index, name in enumerate(names): + paper, drawing = (float(part) for part in name.split(":")) + handle = doc.entitydb.next_handle() + text = ( + f" 0\nSCALE\n 5\n{handle}\n330\n{scales.dxf.handle}\n100\nAcDbScale\n" + f" 70\n0\n300\n{name}\n140\n{paper}\n141\n{drawing}\n290\n{int(paper == drawing)}\n" + ) + entry = factory.load(ExtendedTags.from_text(text), doc) + doc.entitydb.add(entry) + doc.objects.add_object(entry) + scales.add(f"A{index}", entry) + return scales + + +def _scale_names(scales): + return [ + tag.value + for _key, entry in scales.items() + for subclass in entry.xtags.subclasses + for tag in subclass + if tag.code == 300 + ] + + +async def test_annotation_scale_matches_an_autocad_keyed_scale_list_by_name(backend, tmp_path): + """Regression: the presence check used to look the scale up by dictionary + *key*, so on any AutoCAD-authored drawing (keys A0, A1, ...) every write + appended a second SCALE object with the same name.""" + scales = _seed_autocad_style_scale_list(backend._doc, ["1:1", "1:2", "1:50"]) + assert list(scales.keys()) == ["A0", "A1", "A2"] + + res = await backend.drawing_settings({"annotation_scale": "1:50"}) + assert res["ok"] is True, res.get("errors") + assert res["changed"]["annotation_scale"][1] == {"name": "1:50", "value": 0.02} + assert list(scales.keys()) == ["A0", "A1", "A2"], "the existing entry is reused, not duplicated" + assert _scale_names(scales).count("1:50") == 1 + assert backend._doc.rootdict.get("AcDbVariableDictionary")["CANNOSCALE"].dxf.value == "1:50" + + # a scale the list does not carry is still appended, exactly once, under a + # key that does not collide with AutoCAD's A keys + res = await backend.drawing_settings({"annotation_scale": "1:25"}) + assert res["ok"] is True, res.get("errors") + assert _scale_names(scales).count("1:25") == 1 + assert len(list(scales.keys())) == 4 + await backend.drawing_settings({"annotation_scale": "1:25"}) + assert len(list(scales.keys())) == 4 + + path = str(tmp_path / "autocad_keyed.dxf") + await backend.drawing_save_as(path) + await backend.drawing_open(path) + reopened = backend._doc.rootdict.get("ACAD_SCALELIST") + assert sorted(_scale_names(reopened)) == ["1:1", "1:2", "1:25", "1:50"] + assert backend._doc.audit().has_errors is False + + async def test_unit_formats_by_name_and_by_code(backend): res = await backend.drawing_settings({"linear_units": "architectural", "angular_units": "dms"}) assert res["ok"] is True, res.get("errors") From df2ab91900905fbcf9db7b89479d7b5f46051180 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Wed, 16 Sep 2026 21:48:09 +0300 Subject: [PATCH 03/12] fix(task-14): return the plain point list from COM system_set_variable, not the VARIANT The tuple/list branch put the win32com VARIANT built for SetVariable into the returned dict. The system_set_variable tool returns that dict verbatim under output_schema {"type": "object"}; pydantic cannot serialise a VARIANT, FastMCP drops the structured content and the client sees "outputSchema defined but no structured output returned" -- after AutoCAD had already applied the write, so every raw point-variable write (LIMMIN, LIMMAX, GRIDUNIT, SNAPUNIT, INSBASE) was a retry/undo trap on the live engine while the audit log said TOOL OK. The VARIANT is now only the SetVariable argument; "value" carries the coerced Python list. The fake-ActiveX test asserts the reply, and a new Client(server.mcp) test drives the tool through the output schema so the serialisation path is covered. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/com_backend.py | 11 +++++++++-- tests/test_settings_facade.py | 29 ++++++++++++++++++++++++++++- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/backends/com_backend.py b/backends/com_backend.py index 8fb117e..7436dec 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -4003,6 +4003,12 @@ class ComBackend(AutoCADBackend): # AutoCAD rejects e.g. a string "0" for the integer OSMODE. Probe the # current value's type and convert to match; pass through on failure. coerced = value + # `payload` is what SetVariable receives. It differs from `coerced` + # only for point variables, whose VARIANT wrapper must never reach + # the returned dict: pydantic cannot serialise + # win32com.client.VARIANT, so the tool would report failure *after* + # AutoCAD had already applied the write. + payload = value try: current = app.GetVariable(name) if isinstance(current, bool): @@ -4017,10 +4023,11 @@ class ComBackend(AutoCADBackend): # a bare Python tuple is marshalled as VT_VARIANT and rejected. coords = [float(v) for v in value] coords += [0.0] * (len(current) - len(coords)) - coerced = _av(coords[: len(current)]) + coerced = coords[: len(current)] + payload = _av(coerced) if isinstance(current, (tuple, list)) else coerced except Exception as exc: log.debug("set_variable type probe for %s failed: %s", name, exc) - app.SetVariable(name, coerced) + app.SetVariable(name, payload) return {"ok": True, "variable": name, "value": coerced} return await self._run(_sync) diff --git a/tests/test_settings_facade.py b/tests/test_settings_facade.py index 2a8b8b6..3f6cde9 100644 --- a/tests/test_settings_facade.py +++ b/tests/test_settings_facade.py @@ -15,6 +15,7 @@ Measured before writing (ezdxf 1.4.4, 2026-09-16): from __future__ import annotations +import json import math import pytest @@ -477,10 +478,36 @@ async def test_com_point_variables_travel_as_variant_double_arrays(com_backend): real_set(name, value) app.SetVariable = _capture - await backend.system_set_variable("LIMMAX", (300, 200)) + res = await backend.system_set_variable("LIMMAX", (300, 200)) variant = seen["LIMMAX"] assert variant.varianttype == pythoncom.VT_ARRAY | pythoncom.VT_R8 assert tuple(variant.value) == (300.0, 200.0) + # The VARIANT is an ActiveX argument only. The reply carries the plain + # Python list -- a VARIANT in the dict cannot be serialised by pydantic, + # and the tool would then report failure after AutoCAD applied the write. + assert res == {"ok": True, "variable": "LIMMAX", "value": [300.0, 200.0]} + json.dumps(res) + + +async def test_com_point_variable_reply_survives_the_mcp_output_schema(com_backend, monkeypatch): + """Through the real tool: `system_set_variable` returns its dict verbatim + under `output_schema={"type": "object"}`, so anything pydantic cannot + serialise turns into `ToolError: outputSchema defined but no structured + output returned` *after* `SetVariable` has landed -- a retry/undo trap.""" + from fastmcp import Client + + import server + + backend, app = com_backend + + async def _fake_make_backend(): + return backend + + monkeypatch.setattr(server, "_make_backend", _fake_make_backend) + async with Client(server.mcp) as client: + res = await client.call_tool("system_set_variable", {"name": "LIMMAX", "value": [300, 200]}) + assert res.data == {"ok": True, "variable": "LIMMAX", "value": [300.0, 200.0]} + assert app.store["LIMMAX"] == (300.0, 200.0) async def test_com_refuses_before_touching_activex(com_backend): From 33604c44083f024b332e732f1b6a04b5e471cf6f Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Thu, 17 Sep 2026 09:49:25 +0300 Subject: [PATCH 04/12] fix(task-14): read and write system variables on the ActiveX document, not the Application `system_get_variable` / `system_set_variable` called `GetVariable` / `SetVariable` on the object `_acad_app()` returns. Those are `AcadDocument` members; `AcadApplication` has neither, so on a live seat every call died with `AttributeError: AutoCAD.Application.GetVariable` before anything reached AutoCAD -- `drawing_settings` (all keys, read and write) was dead on the COM engine although both engines were promised. Measured on AutoCAD 2026: `hasattr(app, "GetVariable")` is False. Both methods now resolve the document through `_acad_doc()`, whose "No drawing is open" refusal is what a seat without a document reports. Verified live through `ComBackend`: LIMMAX reads (277.0, 202.5); `drawing_settings({"annotation_scale": "1:50", "polar_angle": 45, "polar": True})` applies and reads back 1:50 / 0.02 / 45.0 / True in model space. The test fake shipped with the task had GetVariable / SetVariable on the *Application*, so the gate asserted a call shape the real object does not have. `_FakeApp` now carries only `ActiveDocument` + `Documents.Count` and raises `AttributeError` for the two names, exactly like the real Application; the sysvar store is a `_FakeDoc` that the real `_acad_doc()` resolves. Two regression tests: the document is the target (call list), and no open drawing raises the clear RuntimeError. The older `tests/test_com_sprint3.py` coercion test pinned the same wrong target and now fakes the document. `tests/test_com_backend.py::_measure_backend` replaced the module-level `_acad_doc` without ever restoring it, so a MagicMock document leaked into every later test of the session -- invisible while nothing downstream resolved the document itself, fatal once the sysvar path did. An autouse fixture in that module restores it. Not touched (same root cause, other owners): `app.GetVariable` / `app.SetVariable` in `_ensure_linetype_loaded`, `drawing_info`, `drawing_audit`, `system_run_command` and `system_run_lisp` in backends/com_backend.py. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/com_backend.py | 13 ++++++++----- tests/test_com_backend.py | 15 ++++++++++++++- tests/test_com_sprint3.py | 10 ++++++---- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/backends/com_backend.py b/backends/com_backend.py index 7436dec..5f506d7 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -3990,15 +3990,18 @@ class ComBackend(AutoCADBackend): return await self._run(_sync) async def system_get_variable(self, name) -> Any: + # GetVariable / SetVariable are members of AcadDocument, not of + # AcadApplication: `AutoCAD.Application.GetVariable` raises + # AttributeError on a live seat before anything reaches AutoCAD. def _sync(): - app = _acad_app() - return app.GetVariable(name) + doc = _acad_doc() + return doc.GetVariable(name) return await self._run(_sync) async def system_set_variable(self, name, value) -> dict: def _sync(): - app = _acad_app() + doc = _acad_doc() # the sysvar host is the document (see system_get_variable) # Coerce to the sysvar's actual type (NEW-com-set-variable-coercion): # AutoCAD rejects e.g. a string "0" for the integer OSMODE. Probe the # current value's type and convert to match; pass through on failure. @@ -4010,7 +4013,7 @@ class ComBackend(AutoCADBackend): # AutoCAD had already applied the write. payload = value try: - current = app.GetVariable(name) + current = doc.GetVariable(name) if isinstance(current, bool): coerced = bool(int(value)) if isinstance(value, str) else bool(value) elif isinstance(current, int): @@ -4027,7 +4030,7 @@ class ComBackend(AutoCADBackend): payload = _av(coerced) if isinstance(current, (tuple, list)) else coerced except Exception as exc: log.debug("set_variable type probe for %s failed: %s", name, exc) - app.SetVariable(name, payload) + doc.SetVariable(name, payload) return {"ok": True, "variable": name, "value": coerced} return await self._run(_sync) diff --git a/tests/test_com_backend.py b/tests/test_com_backend.py index 8646a5f..3033553 100644 --- a/tests/test_com_backend.py +++ b/tests/test_com_backend.py @@ -317,6 +317,19 @@ async def test_com_backend_connects(): # number it already has? +@pytest.fixture(autouse=True) +def _restore_acad_doc(): + """`_measure_backend` swaps the module-level `_acad_doc`; put it back after + every test here. Left in place, the MagicMock document leaked into every + later test of the session that resolves the document itself (the settings + facade's COM tests read sysvars through `_acad_doc()`).""" + import backends.com_backend as cb + + original = cb._acad_doc + yield + cb._acad_doc = original + + def _measure_backend(entity): """A ComBackend whose HandleToObject returns exactly this entity.""" import backends.com_backend as cb @@ -329,7 +342,7 @@ def _measure_backend(entity): backend._run = _run doc = MagicMock() doc.HandleToObject.return_value = entity - cb._acad_doc = lambda: doc + cb._acad_doc = lambda: doc # restored by `_restore_acad_doc` return backend diff --git a/tests/test_com_sprint3.py b/tests/test_com_sprint3.py index 743ff11..b90c35f 100644 --- a/tests/test_com_sprint3.py +++ b/tests/test_com_sprint3.py @@ -55,14 +55,16 @@ class _Doc: async def test_set_variable_coerces_string_to_sysvar_int_type(monkeypatch): - app = MagicMock() - app.GetVariable.return_value = 4159 # OSMODE current value is an int - monkeypatch.setattr(cb, "_acad_app", lambda: app) + # GetVariable / SetVariable are AcadDocument members (the Application has + # neither), so the fake is the document that `_acad_doc()` returns. + doc = MagicMock() + doc.GetVariable.return_value = 4159 # OSMODE current value is an int + monkeypatch.setattr(cb, "_acad_doc", lambda: doc) b = _backend_no_executor() out = await b.system_set_variable("OSMODE", "0") - name, val = app.SetVariable.call_args[0] + name, val = doc.SetVariable.call_args[0] assert name == "OSMODE" assert val == 0 and isinstance(val, int) # coerced from "0" to int 0 assert out["value"] == 0 From 909bc47e5e0c21954eb47d0d3b03262043872c90 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Thu, 17 Sep 2026 09:49:38 +0300 Subject: [PATCH 05/12] fix(task-14): seed AutoCAD's default scale list before adding an annotation scale headlessly An ezdxf-authored drawing has no ACAD_SCALELIST entries, and `_ensure_scale_entry` appended only the requested scale. AutoCAD repopulates its default list only when the dictionary is *empty*, so the saved file opened with a one-entry scale list and `SetVariable("CANNOSCALE", "1:1")` failed with `Error setting system variable` -- the annotation scale could no longer be changed in AutoCAD, while the tool reported `ok: True` and the test pinned the degraded state (`len(keys) == 1`). An empty list is now seeded with AutoCAD's own default metric list before the requested scale is considered: the 17 entries a fresh AutoCAD 2026 drawing carries (`Documents.Add()` saved as DXF, MEASUREMENT 1), in that order and under AutoCAD's own keys A0 ... A9, B0 ... B6. New entries take the next key in that sequence (`_scale_list_key`) instead of being keyed by their name; presence is still decided by group 300, so a non-empty AutoCAD-authored list is never re-seeded or duplicated. Verified live: the headless file now opens in AutoCAD 2026 with the 17 keys, CANNOSCALE 1:50 current, and switching to 1:1 and 1:100 works. Tests: the fresh-drawing test asserts the seeded names and keys, that setting 1:50 appends nothing (it is a default), that 1:25 lands under B7 once, and that the list survives save + reopen with 1:1 present; a new test pins that *reading* the annotation scale creates nothing (the read side stays pure, AutoCAD fills an empty list itself); the AutoCAD-keyed list test now expects the A3 key. The `drawing_settings` docstring says what each engine does and that AutoCAD refuses the live write on a paper-space layout with no active viewport (measured). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/ezdxf_backend.py | 92 +++++++++++++++++------- server.py | 4 +- tests/test_settings_facade.py | 129 ++++++++++++++++++++++++++++++---- 3 files changed, 186 insertions(+), 39 deletions(-) diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 55712aa..93f5cc9 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -5954,39 +5954,52 @@ class EzdxfBackend(AutoCADBackend): return str(tag.value) return None - @classmethod - def _ensure_scale_entry(cls, doc, name: str, paper: float, drawing: float) -> bool: - """Add `name` to ACAD_SCALELIST unless present; True when added. + # AutoCAD's default metric ACAD_SCALELIST, in its own order (measured: + # AutoCAD 2026, MEASUREMENT 1, `Documents.Add()` saved as DXF — 17 SCALE + # objects keyed A0 … A9, B0 … B6). An imperial seat (MEASUREMENT 0) adds the + # architectural fractions on top; those are not seeded — every ratio here + # is valid in either unit system, and AutoCAD's SCALELISTEDIT can add more. + _DEFAULT_SCALE_LIST: tuple[str, ...] = ( + "1:1", + "1:2", + "1:4", + "1:5", + "1:8", + "1:10", + "1:16", + "1:20", + "1:30", + "1:40", + "1:50", + "1:100", + "2:1", + "4:1", + "8:1", + "10:1", + "100:1", + ) - Presence is decided by the SCALE objects' group-300 names, never by - the dictionary key — an AutoCAD-authored drawing keys the list `A0`, - `A1`, … (its default metric list already carries 1:1 … 1:100, 2:1), - and matching on the key appended a duplicate `1:50` to every one of - them. A new entry is keyed by its name unless that key is taken, in - which case it takes the next free `A` slot like AutoCAD does. + @staticmethod + def _scale_list_key(index: int) -> str: + """The dictionary key AutoCAD gives the ``index``-th SCALE: A0 … A9, B0 …""" + return f"{chr(ord('A') + index // 10)}{index % 10}" + + @classmethod + def _add_scale_object(cls, doc, scales, name: str, paper: float, drawing: float) -> None: + """Author one SCALE object and file it under the next free AutoCAD-style key. ezdxf has no SCALE entity class, so the object is authored as raw tags and loaded through the factory (a `DXFTagStorage` that exports verbatim). Measured: it round-trips, ezdxf registers the CLASS, and - `doc.audit()` is clean after reopen. AutoCAD only honours a CANNOSCALE - that names an entry of this list. + `doc.audit()` is clean after reopen. """ from ezdxf.entities import factory from ezdxf.lldxf.extendedtags import ExtendedTags - scales = doc.rootdict.get("ACAD_SCALELIST") - if scales is None: - scales = doc.rootdict.add_new_dict("ACAD_SCALELIST") existing_keys = set(scales.keys()) - for _key, entry in scales.items(): - if cls._scale_entry_name(entry) == name: - return False - key = name - if key in existing_keys: - index = 0 - while f"A{index}" in existing_keys: - index += 1 - key = f"A{index}" + index = 0 + while cls._scale_list_key(index) in existing_keys: + index += 1 handle = doc.entitydb.next_handle() unit = 1 if paper == drawing else 0 text = ( @@ -5996,7 +6009,38 @@ class EzdxfBackend(AutoCADBackend): entry = factory.load(ExtendedTags.from_text(text), doc) doc.entitydb.add(entry) doc.objects.add_object(entry) - scales.add(key, entry) + scales.add(cls._scale_list_key(index), entry) + + @classmethod + def _ensure_scale_entry(cls, doc, name: str, paper: float, drawing: float) -> bool: + """Add `name` to ACAD_SCALELIST unless present; True when added. + + Presence is decided by the SCALE objects' group-300 names, never by + the dictionary key — an AutoCAD-authored drawing keys the list `A0`, + `A1`, … and only group 300 says `1:50`; matching on the key appended a + duplicate `1:50` to every one of them. + + An ezdxf-authored drawing has **no** ACAD_SCALELIST entries at all. + AutoCAD repopulates its default list only when the dictionary is + empty, so appending the one requested scale to an empty list shipped a + file whose scale list *was* that one entry — measured (AutoCAD 2026): + `SetVariable("CANNOSCALE", "1:1")` then fails with `Error setting + system variable` and the annotation scale cannot be changed in the + UI. An empty list is therefore seeded with AutoCAD's own default list + first, so the saved file offers what a fresh AutoCAD drawing offers. + AutoCAD only honours a CANNOSCALE that names an entry of this list. + """ + scales = doc.rootdict.get("ACAD_SCALELIST") + if scales is None: + scales = doc.rootdict.add_new_dict("ACAD_SCALELIST") + if len(scales) == 0: + for default in cls._DEFAULT_SCALE_LIST: + _, default_paper, default_drawing = parse_scale(default) + cls._add_scale_object(doc, scales, default, default_paper, default_drawing) + for _key, entry in scales.items(): + if cls._scale_entry_name(entry) == name: + return False + cls._add_scale_object(doc, scales, name, paper, drawing) return True def _set_annotation_scale(self, doc, value: Any) -> str: diff --git a/server.py b/server.py index b4b7864..30ecf63 100644 --- a/server.py +++ b/server.py @@ -5082,7 +5082,9 @@ async def drawing_settings( headless engine (`capability: registry_sysvar` — AutoCAD keeps them in the registry, a file cannot); `dimstyle` / `textstyle` when the backend has no styles contract. On the live engine `annotation_scale` must name a scale in - the drawing's scale list (SCALELISTEDIT); the headless engine adds it. + the drawing's scale list (SCALELISTEDIT) and AutoCAD refuses the write on a + paper-space layout with no active viewport; the headless engine adds the + scale, seeding AutoCAD's default list first when the drawing has none. Headlessly, grid/snap are stored on the active VPORT and the annotation scale in the variable dictionary — the places AutoCAD reads them from. """ diff --git a/tests/test_settings_facade.py b/tests/test_settings_facade.py index 3f6cde9..a98909e 100644 --- a/tests/test_settings_facade.py +++ b/tests/test_settings_facade.py @@ -17,6 +17,7 @@ from __future__ import annotations import json import math +from types import SimpleNamespace import pytest @@ -156,13 +157,30 @@ async def test_annotation_scale_writes_the_variable_dictionary_and_scale_list(ba vardict = backend._doc.rootdict.get("AcDbVariableDictionary") assert vardict["CANNOSCALE"].dxf.value == "1:50" scales = backend._doc.rootdict.get("ACAD_SCALELIST") - assert scales.get("1:50") is not None, "AutoCAD only honours a scale that is in the list" + assert "1:50" in _scale_names(scales), "AutoCAD only honours a scale that is in the list" assert await backend.system_get_variable("CANNOSCALEVALUE") == pytest.approx(0.02) + # Regression: the list used to be *only* the requested scale. AutoCAD + # repopulates its default list only when the dictionary is empty, so the + # saved file opened with a one-entry scale list and `CANNOSCALE` could no + # longer be changed there (measured, AutoCAD 2026). An empty list is seeded + # with AutoCAD's default metric list, keyed and ordered the way AutoCAD + # writes it, so 1:50 was already there and nothing was appended. + assert _scale_names(scales) == list(_AUTOCAD_DEFAULT_SCALES) + assert list(scales.keys()) == _AUTOCAD_DEFAULT_KEYS + # re-setting the same scale is not a change and adds no second list entry again = await backend.drawing_settings({"annotation_scale": "1:50"}) assert again["changed"] == {} - assert len(list(scales.keys())) == 1 + assert len(list(scales.keys())) == 17 + + # a scale outside the default list is appended once, under the next key + # AutoCAD itself would use, and the defaults are not seeded twice + res = await backend.drawing_settings({"annotation_scale": "1:25"}) + assert res["ok"] is True, res.get("errors") + assert _scale_names(scales) == [*_AUTOCAD_DEFAULT_SCALES, "1:25"] + assert list(scales.keys()) == [*_AUTOCAD_DEFAULT_KEYS, "B7"] + await backend.drawing_settings({"annotation_scale": "1:50"}) path = str(tmp_path / "scale.dxf") await backend.drawing_save_as(path) @@ -171,9 +189,32 @@ async def test_annotation_scale_writes_the_variable_dictionary_and_scale_list(ba "name": "1:50", "value": 0.02, } + reopened = backend._doc.rootdict.get("ACAD_SCALELIST") + assert _scale_names(reopened) == [*_AUTOCAD_DEFAULT_SCALES, "1:25"] + assert "1:1" in _scale_names(reopened), "AutoCAD refuses a list without its unit scale" assert backend._doc.audit().has_errors is False +async def test_reading_the_annotation_scale_never_creates_the_scale_list(backend): + """The read side stays pure: a fresh headless drawing keeps its empty + ACAD_SCALELIST (AutoCAD fills it on open) until a scale is written.""" + assert (await backend.drawing_settings())["settings"]["annotation_scale"] == { + "name": "1:1", + "value": 1.0, + } + scales = backend._doc.rootdict.get("ACAD_SCALELIST") + assert scales is None or len(list(scales.keys())) == 0 + + +# AutoCAD 2026's default metric list, in its own order and under its own keys +# (`Documents.Add()` saved as DXF, MEASUREMENT 1): 17 SCALE objects, A0 … B6. +_AUTOCAD_DEFAULT_SCALES = ( + "1:1", "1:2", "1:4", "1:5", "1:8", "1:10", "1:16", "1:20", "1:30", "1:40", + "1:50", "1:100", "2:1", "4:1", "8:1", "10:1", "100:1", +) # fmt: skip +_AUTOCAD_DEFAULT_KEYS = [f"A{i}" for i in range(10)] + [f"B{i}" for i in range(7)] + + def _seed_autocad_style_scale_list(doc, names): """ACAD_SCALELIST the way AutoCAD saves it: keyed A0, A1, ... with the scale name only in the SCALE object's group 300 (the default metric list already @@ -222,11 +263,13 @@ async def test_annotation_scale_matches_an_autocad_keyed_scale_list_by_name(back assert _scale_names(scales).count("1:50") == 1 assert backend._doc.rootdict.get("AcDbVariableDictionary")["CANNOSCALE"].dxf.value == "1:50" - # a scale the list does not carry is still appended, exactly once, under a - # key that does not collide with AutoCAD's A keys + # a scale the list does not carry is still appended, exactly once, under + # the next key in AutoCAD's own A0 … A9, B0 … sequence; a non-empty list + # is never re-seeded with the defaults res = await backend.drawing_settings({"annotation_scale": "1:25"}) assert res["ok"] is True, res.get("errors") assert _scale_names(scales).count("1:25") == 1 + assert list(scales.keys()) == ["A0", "A1", "A2", "A3"] assert len(list(scales.keys())) == 4 await backend.drawing_settings({"annotation_scale": "1:25"}) assert len(list(scales.keys())) == 4 @@ -383,6 +426,24 @@ async def test_server_tool_carries_the_new_keys(backend): class _FakeApp: + """The ActiveX *Application*: it has an ActiveDocument and no sysvar + members at all. `GetVariable` / `SetVariable` live on `AcadDocument`; + a backend that calls them on the Application raises `AttributeError: + AutoCAD.Application.GetVariable` on a live seat before anything reaches + AutoCAD (measured, AutoCAD 2026) -- so this fake refuses them the same way + instead of quietly answering.""" + + def __init__(self): + self.ActiveDocument = _FakeDoc() + self.Documents = SimpleNamespace(Count=1) + + def __getattr__(self, name): + if name in ("GetVariable", "SetVariable"): + raise AttributeError(f"AutoCAD.Application.{name}") + raise AttributeError(name) + + +class _FakeDoc: """Records GetVariable / SetVariable; stores what AutoCAD would store.""" def __init__(self): @@ -425,6 +486,8 @@ def com_backend(monkeypatch): from backends import com_backend as module app = _FakeApp() + # Only the Application is faked: the real `_acad_doc()` runs and resolves + # `ActiveDocument`, so the test exercises the true call path. monkeypatch.setattr(module, "_acad_app", lambda: app) backend = module.ComBackend() @@ -432,11 +495,11 @@ def com_backend(monkeypatch): return func(*args, **kwargs) monkeypatch.setattr(backend, "_run", _run_inline) - return backend, app + return backend, app.ActiveDocument async def test_com_facade_sends_autocad_shaped_values(com_backend): - backend, app = com_backend + backend, doc = com_backend res = await backend.drawing_settings( { "limits": [[0, 0], [500, 350]], @@ -450,7 +513,7 @@ async def test_com_facade_sends_autocad_shaped_values(com_backend): } ) assert res["ok"] is True, res.get("errors") - sets = [call[1:] for call in app.calls if call[0] == "SetVariable"] + sets = [call[1:] for call in doc.calls if call[0] == "SetVariable"] assert sets == [ ("LIMMIN", (0.0, 0.0)), ("LIMMAX", (500.0, 350.0)), @@ -469,15 +532,15 @@ async def test_com_facade_sends_autocad_shaped_values(com_backend): async def test_com_point_variables_travel_as_variant_double_arrays(com_backend): import pythoncom - backend, app = com_backend + backend, doc = com_backend seen: dict = {} - real_set = app.SetVariable + real_set = doc.SetVariable def _capture(name, value): seen[name] = value real_set(name, value) - app.SetVariable = _capture + doc.SetVariable = _capture res = await backend.system_set_variable("LIMMAX", (300, 200)) variant = seen["LIMMAX"] assert variant.varianttype == pythoncom.VT_ARRAY | pythoncom.VT_R8 @@ -498,7 +561,7 @@ async def test_com_point_variable_reply_survives_the_mcp_output_schema(com_backe import server - backend, app = com_backend + backend, doc = com_backend async def _fake_make_backend(): return backend @@ -507,11 +570,49 @@ async def test_com_point_variable_reply_survives_the_mcp_output_schema(com_backe async with Client(server.mcp) as client: res = await client.call_tool("system_set_variable", {"name": "LIMMAX", "value": [300, 200]}) assert res.data == {"ok": True, "variable": "LIMMAX", "value": [300.0, 200.0]} - assert app.store["LIMMAX"] == (300.0, 200.0) + assert doc.store["LIMMAX"] == (300.0, 200.0) async def test_com_refuses_before_touching_activex(com_backend): - backend, app = com_backend + backend, doc = com_backend res = await backend.drawing_settings({"limits": [[0, 0], [0, 0]], "annotation_scale": "x"}) assert res["ok"] is False - assert not [call for call in app.calls if call[0] == "SetVariable"] + assert not [call for call in doc.calls if call[0] == "SetVariable"] + + +async def test_com_sysvars_are_read_and_written_on_the_document(com_backend): + """Regression: both methods called `GetVariable` / `SetVariable` on the + object `_acad_app()` returns, which has neither, so every + `drawing_settings` read and write on the live engine died with + `AttributeError: AutoCAD.Application.GetVariable`.""" + backend, doc = com_backend + assert await backend.system_get_variable("LIMMAX") == (420.0, 297.0) + res = await backend.system_set_variable("GRIDMODE", "1") + assert res == {"ok": True, "variable": "GRIDMODE", "value": 1} + assert doc.store["GRIDMODE"] == 1 + assert doc.calls == [ + ("GetVariable", "LIMMAX"), + ("GetVariable", "GRIDMODE"), # the type probe + ("SetVariable", "GRIDMODE", 1), + ] + + +async def test_com_sysvars_need_an_open_drawing(monkeypatch): + """With no document open there is nothing to read the variable from: + `_acad_doc()`'s clear refusal is what surfaces, not an ActiveX error.""" + pytest.importorskip("win32com.client", reason="pywin32 not installed") + from backends import com_backend as module + + app = _FakeApp() + app.Documents = SimpleNamespace(Count=0) + monkeypatch.setattr(module, "_acad_app", lambda: app) + backend = module.ComBackend() + + async def _run_inline(func, *args, **kwargs): + return func(*args, **kwargs) + + monkeypatch.setattr(backend, "_run", _run_inline) + with pytest.raises(RuntimeError, match="No drawing is open"): + await backend.system_get_variable("LIMMAX") + with pytest.raises(RuntimeError, match="No drawing is open"): + await backend.system_set_variable("GRIDMODE", 1) From f3ff70314452d253bf8d25cbf82257d8da4ce593 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Thu, 17 Sep 2026 10:14:05 +0300 Subject: [PATCH 06/12] fix(task-14): refuse CANNOSCALE on an R12 file headlessly instead of losing it at save Measured: an R12 (AC1009) document has no OBJECTS section, so ezdxf exports neither AcDbVariableDictionary/DICTIONARYVAR nor the seeded SCALE objects, and drawing_save_as keeps AC1009. `_set_annotation_scale` reported `ok: True, changed 1:1 -> 1:50` and the file reloaded as 1:1 -- the header-only write that vanishes on reload, the class this task exists to remove, and a breach of the backend's own R12 honesty convention (viewport_set_scale / lock). The write is now refused with a ValueError naming R12 and "save as R2000 or newer", after parse_scale (a malformed value is still named as such) and before either dictionary is touched, so a refused call leaves the root dictionary as it found it and drawing_settings still applies the sibling keys (limits / grid / ortho survive R12). The refusal is listed with the others in the contract and server docstrings. Pinned by two tests that save, reopen and read the R12 file back. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/contracts/settings.py | 4 ++- backends/ezdxf_backend.py | 12 +++++++ server.py | 4 ++- tests/test_settings_facade.py | 60 ++++++++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 2 deletions(-) diff --git a/backends/contracts/settings.py b/backends/contracts/settings.py index fe81457..ec2dcc4 100644 --- a/backends/contracts/settings.py +++ b/backends/contracts/settings.py @@ -42,7 +42,9 @@ class SettingsContract: Refusals (per key, in ``errors``; the other keys still apply): an unknown key; a value outside ``_SETTING_RANGES``; a malformed - ``limits`` / ``annotation_scale``; ``polar`` / ``polar_angle`` on the + ``limits`` / ``annotation_scale``; ``annotation_scale`` on an R12 file + headlessly (no OBJECTS section, so the value would vanish at save — + save as R2000 or newer first); ``polar`` / ``polar_angle`` on the headless engine (registry-saved — ``capability: registry_sysvar``); ``dimstyle`` / ``textstyle`` when the backend has no styles contract. diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 93f5cc9..2b7148a 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -6045,6 +6045,18 @@ class EzdxfBackend(AutoCADBackend): def _set_annotation_scale(self, doc, value: Any) -> str: name, paper, drawing = parse_scale(value) # refused before any write + if self._is_r12(): + # Measured: an R12 (AC1009) file has no OBJECTS section, so ezdxf + # exports neither AcDbVariableDictionary/DICTIONARYVAR nor the + # SCALE objects. The in-memory write "succeeded" and reloaded as + # 1:1 -- the header-only-write-that-vanishes class again. Refused + # here, before either dictionary is created, so a refused call + # leaves the root dictionary as it found it. + raise ValueError( + "CANNOSCALE: R12 has no OBJECTS section, so the annotation scale " + "(AcDbVariableDictionary and ACAD_SCALELIST) would be lost on save. " + "Save as R2000 or newer first." + ) self._ensure_scale_entry(doc, name, paper, drawing) vardict = self._variable_dictionary(doc, create=True) entry = vardict.get("CANNOSCALE") diff --git a/server.py b/server.py index 30ecf63..152544d 100644 --- a/server.py +++ b/server.py @@ -5084,7 +5084,9 @@ async def drawing_settings( styles contract. On the live engine `annotation_scale` must name a scale in the drawing's scale list (SCALELISTEDIT) and AutoCAD refuses the write on a paper-space layout with no active viewport; the headless engine adds the - scale, seeding AutoCAD's default list first when the drawing has none. + scale, seeding AutoCAD's default list first when the drawing has none, + and refuses it on an R12 file (no OBJECTS section, so the value would + vanish at save — save as R2000 or newer first; limits/grid/ortho survive). Headlessly, grid/snap are stored on the active VPORT and the annotation scale in the variable dictionary — the places AutoCAD reads them from. """ diff --git a/tests/test_settings_facade.py b/tests/test_settings_facade.py index a98909e..fbb5508 100644 --- a/tests/test_settings_facade.py +++ b/tests/test_settings_facade.py @@ -387,6 +387,66 @@ async def test_cannoscalevalue_is_read_only(backend): await backend.system_set_variable("CANNOSCALEVALUE", 0.5) +async def _r12_backend(tmp_path): + import ezdxf + + from backends.ezdxf_backend import EzdxfBackend + + path = tmp_path / "r12.dxf" + ezdxf.new("R12").saveas(str(path)) + backend = EzdxfBackend() + await backend.connect() + await backend.drawing_open(str(path)) + return backend + + +async def test_annotation_scale_on_r12_is_refused_rather_than_lost(tmp_path): + """Measured: an R12 (AC1009) file has no OBJECTS section, so ezdxf exports + neither AcDbVariableDictionary nor ACAD_SCALELIST — a CANNOSCALE written to + the in-memory document reported `changed 1:1 -> 1:50` and reloaded as 1:1. + The write is refused up front, before any dictionary is created, and the + other settings on the same call still apply (limits survive R12).""" + backend = await _r12_backend(tmp_path) + try: + # ezdxf seeds an *empty* ACAD_SCALELIST at new(); the refusal must not + # populate it (the default-list seeding) nor create the variable dict. + assert len(backend._doc.rootdict.get("ACAD_SCALELIST")) == 0 + with pytest.raises(ValueError, match="R12") as excinfo: + await backend.system_set_variable("CANNOSCALE", "1:50") + assert "R2000 or newer" in str(excinfo.value) + assert backend._doc.rootdict.get("AcDbVariableDictionary") is None + assert len(backend._doc.rootdict.get("ACAD_SCALELIST")) == 0 + + res = await backend.drawing_settings( + {"annotation_scale": "1:50", "limits": [[0, 0], [420, 297]]} + ) + assert res["ok"] is False + assert "R12" in res["errors"]["annotation_scale"] + assert "annotation_scale" not in res["applied"] + assert res["applied"]["limits"] == [[0, 0], [420, 297]] + + out = tmp_path / "r12_out.dxf" + await backend.drawing_save_as(str(out)) + await backend.drawing_open(str(out)) + assert backend._doc.dxfversion == "AC1009" + read = (await backend.drawing_settings())["settings"] + assert read["annotation_scale"] == {"name": "1:1", "value": 1.0} + assert read["limits"] == [[0.0, 0.0], [420.0, 297.0]] + finally: + await backend.disconnect() + + +async def test_annotation_scale_on_r12_refuses_a_malformed_value_by_the_value(tmp_path): + """A malformed scale is the caller's mistake whatever the file version, so + it is named as such — the R12 refusal is not allowed to mask it.""" + backend = await _r12_backend(tmp_path) + try: + with pytest.raises(ValueError, match="paper:drawing"): + await backend.system_set_variable("CANNOSCALE", "fifty") + finally: + await backend.disconnect() + + def test_both_capability_maps_declare_registry_sysvar(): from backends.com_backend import ComBackend from backends.ezdxf_backend import EzdxfBackend From 125a8ed3b8da8d3df97ab8e8a8e0b0928d2c9d5f Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Thu, 17 Sep 2026 14:56:25 +0300 Subject: [PATCH 07/12] feat(settings): SYSVAR_CATALOG + system_variable_describe; system_set_variable refuses out-of-range values 90 authored rows (type, range/enum, default, meaning, saved-in, per-engine support, friendly key); every ezdxf claim is checked against the real backend and the registry-saved set is pinned to what the backend refuses. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- README.md | 2 +- discovery/aliases.py | 12 + docs/tool-inventory.json | 18 +- engineering/standards/sysvars.py | 772 +++++++++++++++++++++++++++++++ server.py | 77 ++- tests/test_sysvar_catalog.py | 315 +++++++++++++ tests/test_tool_registry.py | 10 +- 7 files changed, 1198 insertions(+), 8 deletions(-) create mode 100644 engineering/standards/sysvars.py create mode 100644 tests/test_sysvar_catalog.py diff --git a/README.md b/README.md index 0864612..11bfe92 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ 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. +> **v1.5 release snapshot:** 167 tools · 8 resources · 5 prompt templates · 2423 collected tests. > 166 is the **registered** count; a default install advertises 161 over `tools/list`, > because `ENABLE_3D` is unset. `system_about` is the runtime authority. diff --git a/discovery/aliases.py b/discovery/aliases.py index e1a6c2c..694c96c 100644 --- a/discovery/aliases.py +++ b/discovery/aliases.py @@ -1313,6 +1313,18 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "health check", ), ), + "system_variable_describe": ToolAliases( + acad=("SETVAR", "SYSVARMONITOR", "SYSVDLG"), + synonyms=( + "sysvar", + "what does ltscale do", + "explain a system variable", + "system variable reference", + "which variables are saved in the drawing", + "valid range of a system variable", + "list system variables", + ), + ), # ── Transactions ──────────────────────────────────────────────────────── "transaction_begin": ToolAliases( synonyms=( diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index 264451c..7aa17d6 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": 167, "resources": 8, "prompts": 5, "groups": 20 @@ -24,7 +24,7 @@ "pid": 9, "premium": 12, "solids": 5, - "system": 7, + "system": 8, "templates": 2, "transactions": 3, "validation": 1, @@ -2099,6 +2099,20 @@ "readOnlyHint": true } }, + { + "name": "system_variable_describe", + "group": "system", + "cost": "read", + "summary": "Explain a system variable: type, range, default, where it is saved, which engine honours it.", + "tags": [ + "settings", + "system" + ], + "annotations": { + "title": "Describe System Variable", + "readOnlyHint": true + } + }, { "name": "template_apply_layers", "group": "templates", diff --git a/engineering/standards/sysvars.py b/engineering/standards/sysvars.py new file mode 100644 index 0000000..be6adc0 --- /dev/null +++ b/engineering/standards/sysvars.py @@ -0,0 +1,772 @@ +"""AutoCAD system-variable catalogue: what each one is, where it lives, who honours it. + +Authored data, pinned by ``tests/test_sysvar_catalog.py``. Every entry says +its type, range or enum, AutoCAD's default, a one-line meaning, where AutoCAD +saves it (``drawing`` / ``registry`` / ``not_saved``), whether each engine can +read *and write* it (``engines``), and the ``drawing_settings`` key that wraps +it when one exists (``friendly_key``). + +``engines["ezdxf"]`` is a measured claim, not a hope: it is ``True`` only when +the headless backend has a real home for the value in the DXF file - a header +variable ezdxf will export, the active VPORT (grid/snap), the variable +dictionary (CANNOSCALE) or the model-space layout (limits). Registry-saved +and never-saved variables are ``False`` because a file has nowhere to keep +them, and ``EzdxfBackend.system_set_variable`` refuses them with +``capability: registry_sysvar``. A handful of drawing-saved variables are also +``False`` because ezdxf 1.4 has no header slot for them (CTAB, VIEWCTR, +VIEWSIZE, ISOLINES, FACETRES); that refusal is a plain ``ValueError``. + +Sources: the AutoCAD 2026 System Variables reference (type / saved-in / +initial value columns), the DXF Reference HEADER and VPORT sections, and +``ezdxf.sections.headervars.HEADER_VAR_MAP`` for what the headless engine +exports. +""" + +from __future__ import annotations + +import difflib +import math +from dataclasses import asdict, dataclass, field +from typing import Any + +__all__ = [ + "SAVED_IN", + "SYSVAR_CATALOG", + "SYSVAR_TYPES", + "SysVar", + "check_sysvar_value", + "describe_sysvar", + "search_sysvars", +] + +SYSVAR_TYPES = ("int", "float", "str", "bool", "point", "enum") +SAVED_IN = ("drawing", "registry", "not_saved") + + +@dataclass(frozen=True) +class SysVar: + name: str + type: str + range: tuple[float, float] | None + enum: dict[int, str] | None + default: Any + meaning: str + saved_in: str + engines: dict[str, bool] = field(default_factory=lambda: {"ezdxf": True, "com": True}) + friendly_key: str | None = None + read_only: bool = False + + +_BOTH = {"ezdxf": True, "com": True} +_COM_ONLY = {"ezdxf": False, "com": True} + + +def _v( + name: str, + type_: str, + meaning: str, + *, + range_: tuple[float, float] | None = None, + enum: dict[int, str] | None = None, + default: Any = None, + saved_in: str = "drawing", + engines: dict[str, bool] | None = None, + friendly_key: str | None = None, + read_only: bool = False, +) -> SysVar: + if engines is None: + engines = _BOTH if saved_in == "drawing" else _COM_ONLY + return SysVar( + name=name, + type=type_, + range=range_, + enum=enum, + default=default, + meaning=meaning, + saved_in=saved_in, + engines=dict(engines), + friendly_key=friendly_key, + read_only=read_only, + ) + + +_POSITIVE = (1e-9, 1e9) +_NON_NEGATIVE = (0.0, 1e9) +_ACI = (0, 256) +_LINEWEIGHT = (-3, 211) + +_ENTRIES: tuple[SysVar, ...] = ( + # ── dimension variables (the dimstyle whitelist) ──────────────────────── + _v( + "DIMTXT", + "float", + "dimension text height", + range_=_POSITIVE, + default=2.5, + friendly_key="dim_text_height", + ), + _v( + "DIMASZ", + "float", + "arrowhead size", + range_=_POSITIVE, + default=2.5, + friendly_key="dim_arrow_size", + ), + _v( + "DIMEXE", + "float", + "extension line extension beyond the dimension line", + range_=_POSITIVE, + default=1.25, + ), + _v( + "DIMEXO", + "float", + "extension line offset from the origin points", + range_=_NON_NEGATIVE, + default=0.625, + ), + _v( + "DIMGAP", + "float", + "gap between dimension line and text; a negative value draws a box around the text", + default=0.625, + ), + _v( + "DIMTAD", + "enum", + "vertical text placement relative to the dimension line", + enum={0: "centered", 1: "above", 2: "outside", 3: "JIS", 4: "below"}, + default=1, + ), + _v( + "DIMTIH", + "bool", + "text inside the extension lines is horizontal (1) or aligned with the line (0)", + default=0, + ), + _v( + "DIMTOH", + "bool", + "text outside the extension lines is horizontal (1) or aligned with the line (0)", + default=0, + ), + _v( + "DIMDEC", + "int", + "decimal places of the primary dimension value", + range_=(0, 8), + default=2, + friendly_key="dim_decimals", + ), + _v( + "DIMDSEP", + "str", + "decimal separator character for decimal dimensions (the headless engine stores its character code: 44 is ',')", + default=",", + friendly_key="decimal_separator", + ), + _v( + "DIMLUNIT", + "enum", + "linear unit format for dimensions", + enum={ + 1: "scientific", + 2: "decimal", + 3: "engineering", + 4: "architectural", + 5: "fractional", + 6: "windows desktop", + }, + default=2, + ), + _v( + "DIMZIN", + "int", + "zero suppression bitmask: 1 feet, 2 inches, 4 leading, 8 trailing", + range_=(0, 15), + default=8, + friendly_key="zero_suppression", + ), + _v("DIMBLK", "str", "arrowhead block name; empty means closed filled", default=""), + _v("DIMTXSTY", "str", "text style used by dimension text", default="Standard"), + _v( + "DIMLWD", + "int", + "dimension line lineweight code (-3 default, -2 ByBlock, -1 ByLayer, else hundredths of mm)", + range_=_LINEWEIGHT, + default=-2, + ), + _v( + "DIMLWE", + "int", + "extension line lineweight code (-3 default, -2 ByBlock, -1 ByLayer, else hundredths of mm)", + range_=_LINEWEIGHT, + default=-2, + ), + _v( + "DIMSCALE", + "float", + "overall scale factor applied to dimension sizes; 0 scales to the viewport", + range_=(0.0, 1e6), + default=1.0, + friendly_key="dimscale", + ), + _v("DIMTOL", "bool", "append tolerances to dimension text", default=0), + _v("DIMTP", "float", "upper tolerance limit", default=0.0), + _v("DIMTM", "float", "lower tolerance limit", default=0.0), + _v( + "DIMTOLJ", + "enum", + "vertical justification of tolerance text", + enum={0: "bottom", 1: "middle", 2: "top"}, + default=1, + ), + _v( + "DIMTFAC", + "float", + "tolerance text height as a factor of DIMTXT", + range_=_POSITIVE, + default=1.0, + ), + _v( + "DIMLFAC", + "float", + "linear measurement scale factor; negative applies in paper space only", + default=1.0, + ), + _v( + "DIMRND", + "float", + "rounding increment for linear dimensions; 0 rounds nothing", + range_=_NON_NEGATIVE, + default=0.0, + ), + _v( + "DIMATFIT", + "enum", + "what moves outside when text and arrows do not fit", + enum={0: "text and arrows", 1: "arrows first", 2: "text first", 3: "best fit"}, + default=3, + ), + _v( + "DIMTMOVE", + "enum", + "how the dimension line behaves when text is moved", + enum={0: "move line with text", 1: "add a leader", 2: "text moves freely"}, + default=0, + ), + _v( + "DIMCLRD", + "int", + "dimension line colour (ACI: 0 ByBlock, 256 ByLayer)", + range_=_ACI, + default=0, + ), + _v( + "DIMCLRE", + "int", + "extension line colour (ACI: 0 ByBlock, 256 ByLayer)", + range_=_ACI, + default=0, + ), + _v( + "DIMCLRT", + "int", + "dimension text colour (ACI: 0 ByBlock, 256 ByLayer)", + range_=_ACI, + default=0, + ), + _v("DIMSAH", "bool", "use DIMBLK1 / DIMBLK2 as separate arrowheads", default=0), + _v("DIMBLK1", "str", "first arrowhead block when DIMSAH is on", default=""), + _v("DIMBLK2", "str", "second arrowhead block when DIMSAH is on", default=""), + _v( + "DIMCEN", + "float", + "centre mark size for radial dimensions; 0 none, negative draws centre lines", + default=2.5, + ), + # ── scales, units, precision ───────────────────────────────────────────── + _v( + "LTSCALE", + "float", + "global linetype scale factor", + range_=_POSITIVE, + default=1.0, + friendly_key="ltscale", + ), + _v( + "PSLTSCALE", + "bool", + "scale linetypes by the viewport scale in paper space", + default=1, + friendly_key="psltscale", + ), + _v( + "INSUNITS", + "enum", + "drawing units for inserted content and scaling", + enum={ + 0: "unitless", + 1: "inches", + 2: "feet", + 3: "miles", + 4: "millimeters", + 5: "centimeters", + 6: "meters", + 7: "kilometers", + 8: "microinches", + 9: "mils", + 10: "yards", + 11: "angstroms", + 12: "nanometers", + 13: "microns", + 14: "decimeters", + 15: "decameters", + 16: "hectometers", + 17: "gigameters", + 18: "astronomical units", + 19: "light years", + 20: "parsecs", + }, + default=4, + friendly_key="units", + ), + _v( + "LUNITS", + "enum", + "linear unit display format", + enum={1: "scientific", 2: "decimal", 3: "engineering", 4: "architectural", 5: "fractional"}, + default=2, + friendly_key="linear_units", + ), + _v( + "LUPREC", + "int", + "linear unit display precision (decimal places)", + range_=(0, 8), + default=4, + friendly_key="linear_precision", + ), + _v( + "AUNITS", + "enum", + "angular unit display format", + enum={ + 0: "decimal degrees", + 1: "degrees/minutes/seconds", + 2: "grads", + 3: "radians", + 4: "surveyor", + }, + default=0, + friendly_key="angular_units", + ), + _v( + "AUPREC", + "int", + "angular unit display precision (decimal places)", + range_=(0, 8), + default=0, + friendly_key="angular_precision", + ), + _v( + "CANNOSCALE", + "str", + "current annotation scale name, e.g. '1:50' (a DICTIONARYVAR in AcDbVariableDictionary, not a header variable)", + default="1:1", + friendly_key="annotation_scale", + ), + _v( + "CANNOSCALEVALUE", + "float", + "current annotation scale as a number (paper / drawing), derived from CANNOSCALE", + default=1.0, + read_only=True, + ), + # ── limits, grid, snap, tracking ───────────────────────────────────────── + _v( + "LIMMIN", + "point", + "lower-left corner of the drawing limits (LIMITS)", + default=(0.0, 0.0), + friendly_key="limits", + ), + _v( + "LIMMAX", + "point", + "upper-right corner of the drawing limits (LIMITS)", + default=(420.0, 297.0), + friendly_key="limits", + ), + _v( + "GRIDMODE", + "bool", + "grid display on/off (stored on the active VPORT, not in the header)", + default=0, + friendly_key="grid", + ), + _v( + "GRIDUNIT", + "point", + "grid spacing [x, y] (stored on the active VPORT)", + default=(10.0, 10.0), + friendly_key="grid_spacing", + ), + _v( + "SNAPMODE", + "bool", + "snap on/off (stored on the active VPORT)", + default=0, + friendly_key="snap", + ), + _v( + "SNAPUNIT", + "point", + "snap spacing [x, y] (stored on the active VPORT)", + default=(10.0, 10.0), + friendly_key="snap_spacing", + ), + _v( + "ORTHOMODE", + "bool", + "ortho mode: cursor movement constrained to horizontal/vertical", + default=0, + friendly_key="ortho", + ), + _v( + "POLARMODE", + "int", + "polar tracking bitmask: 1 relative angles, 2 polar in osnap tracking, 4 additional angles, 8 tooltips (polar on/off itself is AUTOSNAP bit 8)", + range_=(0, 15), + default=0, + saved_in="registry", + ), + _v( + "POLARANG", + "float", + "polar angle increment in radians (90 degrees = 1.5708)", + range_=(1e-9, 2 * math.pi), + default=math.pi / 2, + saved_in="registry", + friendly_key="polar_angle", + ), + _v( + "AUTOSNAP", + "int", + "AutoSnap bitmask: 1 marker, 2 magnet, 4 tooltip, 8 polar tracking on, 16 object snap tracking, 32 tracking tooltips, 64 dynamic input aperture", + range_=(0, 127), + default=63, + saved_in="registry", + friendly_key="polar", + ), + _v( + "OSMODE", + "int", + "running object snap bitmask (1 end, 2 mid, 4 cen, 8 nod, 16 qua, 32 int, 64 ins, 128 per, 256 tan, 512 nea, 1024 quick, 2048 app, 4096 ext, 8192 par, 16384 off); headlessly held in memory only, DXF R2000+ does not carry it", + range_=(0, 32767), + default=4133, + friendly_key="osmode", + ), + # ── current properties ─────────────────────────────────────────────────── + _v( + "TEXTSIZE", + "float", + "default height for new TEXT entities (not dimension text)", + range_=_POSITIVE, + default=2.5, + friendly_key="text_size", + ), + _v("TEXTSTYLE", "str", "current text style name", default="Standard", friendly_key="textstyle"), + _v( + "DIMSTYLE", + "str", + "current dimension style name (read-only: change it with dimstyle_set_current or drawing_settings {'dimstyle': ...})", + default="ISO-25", + friendly_key="dimstyle", + read_only=True, + ), + _v("CLAYER", "str", "current layer name", default="0"), + _v( + "CELTYPE", + "str", + "current entity linetype (ByLayer, ByBlock or a loaded linetype)", + default="ByLayer", + ), + _v( + "CECOLOR", + "str", + "current entity colour ('BYLAYER', 'BYBLOCK' or an ACI number; the headless engine stores the ACI code, 256 is ByLayer)", + default="BYLAYER", + ), + _v( + "CELWEIGHT", + "int", + "current entity lineweight code (-1 ByLayer, -2 ByBlock, -3 default, else hundredths of mm)", + range_=_LINEWEIGHT, + default=-1, + ), + _v( + "PDMODE", + "int", + "point display mode (0-4, or those plus 32 / 64 / 96)", + range_=(0, 100), + default=0, + friendly_key="point_mode", + ), + _v( + "PDSIZE", + "float", + "point display size; 0 is 5 percent of the view, negative is a percentage of the viewport", + default=0.0, + friendly_key="point_size", + ), + _v( + "FILLETRAD", + "float", + "current fillet radius", + range_=_NON_NEGATIVE, + default=0.0, + friendly_key="fillet_radius", + ), + _v("MIRRTEXT", "bool", "mirror text with MIRROR (1) or keep it readable (0)", default=0), + _v("ATTREQ", "bool", "prompt for attribute values on INSERT", default=1, saved_in="registry"), + _v( + "ATTDIA", + "bool", + "use a dialog for attribute values on INSERT", + default=0, + saved_in="registry", + ), + _v("PLINEWID", "float", "default width for new polylines", range_=_NON_NEGATIVE, default=0.0), + _v( + "ANGBASE", + "float", + "direction of angle 0 relative to the current UCS, in radians", + default=0.0, + ), + _v("ANGDIR", "bool", "positive angle direction: 0 counter-clockwise, 1 clockwise", default=0), + # ── layouts, views, UCS ────────────────────────────────────────────────── + _v("TILEMODE", "bool", "1 model tab active, 0 a layout tab active", default=1), + _v( + "CTAB", + "str", + "name of the current tab (Model or a layout); headlessly use layout_list / layout_set_current", + default="Model", + engines=_COM_ONLY, + ), + _v( + "UCSNAME", + "str", + "name of the current UCS; empty when unnamed or world", + default="", + read_only=True, + ), + _v( + "VIEWCTR", + "point", + "centre of the current view (read-only; VPORT centre in a file)", + default=(0.0, 0.0), + engines=_COM_ONLY, + read_only=True, + ), + _v( + "VIEWSIZE", + "float", + "height of the current view in drawing units (read-only)", + default=100.0, + engines=_COM_ONLY, + read_only=True, + ), + _v("REGENMODE", "bool", "automatic regeneration on", default=1), + _v("LWDISPLAY", "bool", "display lineweights on screen", default=0), + # ── file, session ──────────────────────────────────────────────────────── + _v( + "DWGNAME", + "str", + "name of the current drawing file (read-only)", + default="Drawing1.dwg", + saved_in="not_saved", + read_only=True, + ), + _v( + "DWGPREFIX", + "str", + "folder of the current drawing file (read-only)", + default="", + saved_in="not_saved", + read_only=True, + ), + _v( + "SAVETIME", + "int", + "automatic save interval in minutes; 0 disables", + range_=(0, 600), + default=10, + saved_in="registry", + ), + _v( + "FILEDIA", + "bool", + "show file dialogs (1) or take file names from the command line (0)", + default=1, + saved_in="registry", + ), + _v( + "CMDECHO", + "bool", + "echo prompts and input during AutoLISP command execution", + default=1, + saved_in="not_saved", + ), + _v("HIGHLIGHT", "bool", "highlight selected objects", default=1, saved_in="not_saved"), + _v( + "XREFCTL", + "bool", + "write external reference log (.xlg) files", + default=0, + saved_in="registry", + ), + _v( + "PROXYSHOW", + "enum", + "how proxy objects are displayed", + enum={0: "not shown", 1: "shown", 2: "bounding box"}, + default=1, + saved_in="registry", + ), + # ── hatch ──────────────────────────────────────────────────────────────── + _v( + "HPNAME", + "str", + "default hatch pattern name for HATCH", + default="ANSI31", + saved_in="not_saved", + ), + _v( + "HPSCALE", + "float", + "default hatch pattern scale", + range_=_POSITIVE, + default=1.0, + saved_in="not_saved", + ), + _v( + "HPANG", + "float", + "default hatch pattern angle in degrees", + default=0.0, + saved_in="not_saved", + ), + # ── 3D display ─────────────────────────────────────────────────────────── + _v( + "ISOLINES", + "int", + "contour lines per surface on 3D solids", + range_=(0, 2047), + default=4, + engines=_COM_ONLY, + ), + _v( + "FACETRES", + "float", + "smoothness of shaded and rendered curved solids", + range_=(0.01, 10.0), + default=0.5, + engines=_COM_ONLY, + ), + _v("DISPSILH", "bool", "display silhouette edges of 3D solids in wireframe", default=0), +) + +SYSVAR_CATALOG: dict[str, SysVar] = {entry.name: entry for entry in _ENTRIES} + + +def _as_dict(var: SysVar) -> dict: + out = asdict(var) + out["known"] = True + out["range"] = list(var.range) if var.range is not None else None + out["default"] = list(var.default) if isinstance(var.default, tuple) else var.default + return out + + +def describe_sysvar(name: str) -> dict: + """The catalogue row for ``name`` (case-insensitive), or ``known: False`` plus the nearest names.""" + key = str(name).strip().upper().lstrip("$") + var = SYSVAR_CATALOG.get(key) + if var is not None: + return _as_dict(var) + names = sorted(SYSVAR_CATALOG) + nearest = difflib.get_close_matches(key, names, n=5, cutoff=0.6) + for candidate in names: + if len(nearest) >= 5: + break + if key and candidate.startswith(key[:3]) and candidate not in nearest: + nearest.append(candidate) + return {"known": False, "name": key, "nearest": nearest} + + +def search_sysvars(text: str) -> list[dict]: + """Catalogue rows whose name, meaning or friendly key contains every word of ``text``.""" + words = [word for word in str(text).lower().split() if word] + if not words: + return [] + hits = [] + for var in SYSVAR_CATALOG.values(): + haystack = " ".join((var.name, var.meaning, var.friendly_key or "")).lower() + if all(word in haystack for word in words): + hits.append(_as_dict(var)) + return sorted(hits, key=lambda row: row["name"]) + + +_TRUTHY = {"0", "1", "on", "off", "true", "false"} + + +def check_sysvar_value(name: str, value: Any) -> str | None: + """A refusal message when ``value`` cannot be ``name``'s value; ``None`` when fine or unknown. + + Soft by design: a variable the catalogue has not heard of is passed + through untouched, so ``system_set_variable`` keeps working for the + hundreds of variables nobody has authored a row for. + """ + var = SYSVAR_CATALOG.get(str(name).strip().upper().lstrip("$")) + if var is None: + return None + if var.read_only: + return f"{var.name} is read-only: {var.meaning}" + if var.type in ("int", "float"): + if isinstance(value, bool) or not isinstance(value, (int, float, str)): + return f"{var.name}: expected a number, got {value!r}" + try: + number = float(value) + except ValueError: + return f"{var.name}: expected a number, got {value!r}" + if not math.isfinite(number): + return f"{var.name}: must be a finite number" + if var.type == "int" and number != int(number): + return f"{var.name}: expected an integer, got {value!r}" + if var.range is not None: + low, high = var.range + if not low <= number <= high: + return f"{var.name}: {number:g} is out of range {low:g}..{high:g} - {var.meaning}" + return None + if var.type == "bool": + if value in (0, 1) or str(value).strip().lower() in _TRUTHY: + return None + return f"{var.name}: expected 0 or 1 (on/off), got {value!r}" + if var.type == "enum": + try: + code = int(value) + except (TypeError, ValueError): + return f"{var.name}: expected one of {sorted(var.enum)}, got {value!r}" + if code not in var.enum: + choices = ", ".join(f"{k}={v}" for k, v in var.enum.items()) + return f"{var.name}: {code} is not a valid code ({choices})" + return None + if var.type == "point": + try: + float(value[0]), float(value[1]) + except (TypeError, IndexError, ValueError): + return f"{var.name}: expected an [x, y] point, got {value!r}" + return None + return None diff --git a/server.py b/server.py index 152544d..bcaa319 100644 --- a/server.py +++ b/server.py @@ -5024,7 +5024,22 @@ async def system_set_variable( value: Annotated[Any, "New variable value"], ctx: Context = None, ) -> dict: - """Set an AutoCAD system variable (e.g. DIMSCALE, LTSCALE, MEASUREMENT).""" + """Set an AutoCAD system variable (e.g. DIMSCALE, LTSCALE, MEASUREMENT). + + Refusals, before anything is written: a value outside the catalogued + range or enum of a known variable, or a write to a read-only variable + (DIMSTYLE, CANNOSCALEVALUE, DWGNAME, …), is refused with the catalogue's + message — `system_variable_describe(name)` shows the range. A variable the + catalogue does not know is passed through unchanged. The headless engine + additionally refuses registry-saved variables (`capability: + registry_sysvar`) because a file has nowhere to keep them, and names that + ezdxf has no header slot for (a `ValueError` naming the variable). + """ + from engineering.standards.sysvars import check_sysvar_value + + message = check_sysvar_value(name, value) + if message: + raise ToolError(f"system_set_variable refused: {message}") return await _backend(ctx).system_set_variable(name, value) @@ -6836,6 +6851,66 @@ async def pid_tag_parse( return parse_tag(tag, kind, equipment_prefixes) +# --------------------------------------------------------------------------- +# ── SECTION 20: Environment (1 tool) ──────────────────────────────────────── +# --------------------------------------------------------------------------- +# Group C (settings) opens this section with `system_variable_describe` and, +# in Task 16, the two `drawing_properties_*` tools; group V's merge extends the +# header count to the full environment surface (documents, layer states, +# named views, UCS, launch, preferences, interactive prompts). + + +@cad_tool( + summary="Explain a system variable: type, range, default, where it is saved, which engine honours it.", + cost="read", +) +@mcp.tool( + annotations={"title": "Describe System Variable", "readOnlyHint": True}, + tags={"system", "settings"}, +) +async def system_variable_describe( + name: Annotated[ + str | None, "System variable name, e.g. LTSCALE (case-insensitive, '$' optional)" + ] = None, + search: Annotated[ + str | None, "Free text matched against names and meanings, e.g. 'decimal separator'" + ] = None, + ctx: Context = None, +) -> dict: + """What an AutoCAD system variable means, from an authored catalogue of 90. + + With `name`: `{known, name, type, range|enum, default, meaning, saved_in + (drawing|registry|not_saved), engines: {ezdxf, com}, friendly_key, + read_only}` plus `current` when the active backend can read it (omitted, + with `current_unavailable`, when it cannot — no document open, or a + registry variable headlessly). An unknown name is never an error: `known: + false` and the nearest catalogue names come back. With `search`: every row + whose name or meaning contains all the words. With neither: the index of + names. `friendly_key` names the `drawing_settings` key that wraps the + variable, which is the preferred way to set it; `engines.ezdxf: false` + means the headless engine refuses a write (registry-saved, or no header + slot in ezdxf). + """ + from engineering.standards.sysvars import SYSVAR_CATALOG, describe_sysvar, search_sysvars + + if name: + row = describe_sysvar(name) + backend = ctx.lifespan_context.get("backend") if ctx is not None else None + if row["known"] and backend is not None: + try: + row["current"] = await backend.system_get_variable(row["name"]) + except Exception as exc: # no document, or the engine cannot hold it + row["current_unavailable"] = str(exc) + return row + if search: + return {"query": search, "matches": search_sysvars(search)} + return { + "count": len(SYSVAR_CATALOG), + "names": sorted(SYSVAR_CATALOG), + "hint": "pass name= for one row or search= to filter", + } + + # --------------------------------------------------------------------------- # ── RESOURCES ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_sysvar_catalog.py b/tests/test_sysvar_catalog.py new file mode 100644 index 0000000..acba209 --- /dev/null +++ b/tests/test_sysvar_catalog.py @@ -0,0 +1,315 @@ +"""SYSVAR_CATALOG: authored facts about AutoCAD system variables, pinned to the engines. + +Every `engines["ezdxf"]` claim is checked against the real headless backend +(read, and write of the default where the variable is writable); every +`saved_in` that is not "drawing" is checked to be exactly the set the backend +refuses with `capability: registry_sysvar`. The catalogue cannot say a thing +the backend does not do. +""" + +from __future__ import annotations + +import pytest + +from backends.base import UnsupportedCapabilityError +from backends.contracts.settings import _SETTING_MAP +from backends.ezdxf_backend import _UNSAVED_SYSVARS +from engineering.standards.sysvars import ( + SAVED_IN, + SYSVAR_CATALOG, + SYSVAR_TYPES, + SysVar, + check_sysvar_value, + describe_sysvar, + search_sysvars, +) + +asyncio_test = pytest.mark.asyncio + +DIM_WHITELIST = ( + "DIMTXT", + "DIMASZ", + "DIMEXE", + "DIMEXO", + "DIMGAP", + "DIMTAD", + "DIMTIH", + "DIMTOH", + "DIMDEC", + "DIMDSEP", + "DIMLUNIT", + "DIMZIN", + "DIMBLK", + "DIMTXSTY", + "DIMLWD", + "DIMLWE", + "DIMSCALE", + "DIMTOL", + "DIMTP", + "DIMTM", + "DIMTOLJ", + "DIMTFAC", + "DIMLFAC", + "DIMRND", + "DIMATFIT", + "DIMTMOVE", + "DIMCLRD", + "DIMCLRE", + "DIMCLRT", + "DIMSAH", + "DIMBLK1", + "DIMBLK2", + "DIMCEN", +) + +SPEC_NAMES = ( + "LTSCALE PSLTSCALE INSUNITS LUNITS LUPREC AUNITS AUPREC LIMMIN LIMMAX GRIDMODE GRIDUNIT " + "SNAPMODE SNAPUNIT ORTHOMODE POLARMODE POLARANG OSMODE CANNOSCALE TEXTSIZE TEXTSTYLE " + "DIMSTYLE CLAYER CELTYPE CECOLOR CELWEIGHT PDMODE PDSIZE FILLETRAD MIRRTEXT ATTREQ ATTDIA " + "TILEMODE CTAB DWGNAME DWGPREFIX SAVETIME FILEDIA CMDECHO HIGHLIGHT REGENMODE UCSNAME " + "VIEWCTR VIEWSIZE ANGBASE ANGDIR PLINEWID HPNAME HPSCALE HPANG LWDISPLAY XREFCTL PROXYSHOW " + "ISOLINES FACETRES DISPSILH" +).split() + +#: The two engines legitimately represent these differently (a character vs. +#: its code; a colour name vs. its ACI number), so their default cannot be +#: written verbatim headlessly. Everything else round-trips its default. +_REPRESENTATION_DIFFERS = {"DIMDSEP", "CECOLOR"} + + +# ── the data is complete ──────────────────────────────────────────────────── + + +def test_catalogue_is_authored_not_sparse(): + assert len(SYSVAR_CATALOG) >= 60 + + +def test_every_dim_whitelist_variable_and_every_spec_name_is_present(): + missing = [n for n in (*DIM_WHITELIST, *SPEC_NAMES) if n not in SYSVAR_CATALOG] + assert missing == [] + + +def test_every_entry_is_complete_and_well_typed(): + for name, var in SYSVAR_CATALOG.items(): + assert isinstance(var, SysVar) and var.name == name + assert var.type in SYSVAR_TYPES, name + assert var.meaning.strip(), name + assert var.saved_in in SAVED_IN, name + assert set(var.engines) == {"ezdxf", "com"}, name + assert var.friendly_key is None or var.friendly_key in _SETTING_MAP, name + if var.type == "enum": + assert var.enum and all(isinstance(k, int) for k in var.enum), name + assert var.default in var.enum, name + else: + assert var.enum is None, name + if var.range is not None: + low, high = var.range + assert low < high, name + + +def test_friendly_keys_cover_the_whole_facade(): + covered = {var.friendly_key for var in SYSVAR_CATALOG.values() if var.friendly_key} + assert covered == set(_SETTING_MAP) + for key, (variable, _kind) in _SETTING_MAP.items(): + assert SYSVAR_CATALOG[variable].friendly_key == key, (key, variable) + + +def test_non_drawing_variables_are_exactly_the_ones_the_headless_engine_refuses(): + unsaved = {n for n, v in SYSVAR_CATALOG.items() if v.saved_in != "drawing"} + assert unsaved == set(_UNSAVED_SYSVARS) + for name in unsaved: + assert SYSVAR_CATALOG[name].engines["ezdxf"] is False, name + + +# ── describe / search / check ─────────────────────────────────────────────── + + +def test_describe_is_case_insensitive_and_complete(): + row = describe_sysvar("ltscale") + assert row["known"] is True and row["name"] == "LTSCALE" + assert row["type"] == "float" and row["range"][0] > 0 + assert row["saved_in"] == "drawing" + assert row["engines"] == {"ezdxf": True, "com": True} + assert row["friendly_key"] == "ltscale" + assert describe_sysvar("$LIMMAX")["default"] == [420.0, 297.0] + + +def test_describe_unknown_names_the_nearest_without_raising(): + row = describe_sysvar("LTSCAL") + assert row["known"] is False and row["name"] == "LTSCAL" + assert "LTSCALE" in row["nearest"] + assert describe_sysvar("")["known"] is False + + +def test_search_matches_names_and_meanings(): + names = [row["name"] for row in search_sysvars("scale")] + for expected in ("LTSCALE", "DIMSCALE", "CANNOSCALE", "PSLTSCALE", "HPSCALE"): + assert expected in names + assert [row["name"] for row in search_sysvars("decimal separator")] == ["DIMDSEP"] + assert search_sysvars(" ") == [] + + +@pytest.mark.parametrize( + "name, value, fragment", + [ + ("DIMDEC", 9, "0..8"), + ("dimdec", -1, "0..8"), + ("DIMDEC", 2.5, "integer"), + ("DIMTXT", "big", "number"), + ("DIMTXT", 0, "out of range"), + ("LUNITS", 7, "architectural"), + ("ORTHOMODE", 2, "0 or 1"), + ("LIMMAX", 5, "[x, y]"), + ("DIMSTYLE", "ANSI", "read-only"), + ("CANNOSCALEVALUE", 0.5, "read-only"), + ("POLARANG", 0, "out of range"), + ], +) +def test_check_refuses_with_a_message_naming_the_variable(name, value, fragment): + message = check_sysvar_value(name, value) + assert message is not None + assert name.upper() in message and fragment in message + + +@pytest.mark.parametrize( + "name, value", + [ + ("DIMDEC", 3), + ("DIMTXT", "3.5"), + ("LUNITS", 4), + ("ORTHOMODE", "on"), + ("LIMMAX", [297, 210]), + ("DIMDSEP", ","), + ("NOSUCHVARIABLE", 12345), + ], +) +def test_check_passes_good_values_and_unknown_names(name, value): + assert check_sysvar_value(name, value) is None + + +# ── the engine claims are true ────────────────────────────────────────────── + + +@asyncio_test +async def test_every_ezdxf_true_entry_reads_on_a_fresh_document(backend): + unreadable = [] + for name, var in SYSVAR_CATALOG.items(): + if var.engines["ezdxf"] and await backend.system_get_variable(name) is None: + unreadable.append(name) + assert unreadable == [] + + +@asyncio_test +async def test_every_writable_ezdxf_true_entry_round_trips_its_default(backend): + for name, var in SYSVAR_CATALOG.items(): + if not var.engines["ezdxf"] or var.read_only or name in _REPRESENTATION_DIFFERS: + continue + await backend.system_set_variable(name, var.default) + back = await backend.system_get_variable(name) + if var.type == "point": + assert tuple(float(v) for v in back[:2]) == tuple(float(v) for v in var.default), name + elif var.type in ("bool", "int", "enum"): + assert int(back) == int(var.default), name + elif var.type == "float": + assert float(back) == pytest.approx(float(var.default)), name + else: + assert back == var.default, name + + +@asyncio_test +async def test_every_ezdxf_false_entry_is_refused_before_any_write(backend): + for name, var in SYSVAR_CATALOG.items(): + if var.engines["ezdxf"]: + continue + if var.saved_in == "drawing": + with pytest.raises(ValueError, match=name): # no header slot in ezdxf + await backend.system_set_variable(name, var.default) + else: + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await backend.system_set_variable(name, var.default) + assert excinfo.value.capability == "registry_sysvar", name + + +# ── server tools ──────────────────────────────────────────────────────────── + + +class _FakeCtx: + def __init__(self, backend): + self.lifespan_context = {"backend": backend} + + async def info(self, *a, **k): + pass + + +@asyncio_test +async def test_set_variable_tool_refuses_out_of_range_before_the_backend_sees_it(backend): + from fastmcp.exceptions import ToolError + + import server + + ctx = _FakeCtx(backend) + with pytest.raises(ToolError, match="DIMDEC"): + await server.system_set_variable(name="DIMDEC", value=99, ctx=ctx) + assert await backend.system_get_variable("DIMDEC") == 2 + with pytest.raises(ToolError, match="read-only"): + await server.system_set_variable(name="DIMSTYLE", value="ANSI", ctx=ctx) + # a name the catalogue does not know still passes straight through + out = await server.system_set_variable(name="MEASUREMENT", value=0, ctx=ctx) + assert out["ok"] is True + assert await backend.system_get_variable("MEASUREMENT") == 0 + + +@asyncio_test +async def test_set_variable_tool_refuses_before_a_live_backend_is_called(monkeypatch): + pytest.importorskip("win32com.client", reason="pywin32 not installed") + from fastmcp.exceptions import ToolError + + import server + from backends import com_backend as module + + calls: list[tuple] = [] + + class _App: + def GetVariable(self, name): + calls.append(("GetVariable", name)) + return 2 + + def SetVariable(self, name, value): + calls.append(("SetVariable", name, value)) + + monkeypatch.setattr(module, "_acad_app", lambda: _App()) + backend = module.ComBackend() + + async def _run_inline(func, *args, **kwargs): + return func(*args, **kwargs) + + monkeypatch.setattr(backend, "_run", _run_inline) + with pytest.raises(ToolError, match="DIMDEC"): + await server.system_set_variable(name="DIMDEC", value=40, ctx=_FakeCtx(backend)) + assert calls == [] + + +@asyncio_test +async def test_describe_tool_returns_the_row_with_the_current_value(backend): + import server + + ctx = _FakeCtx(backend) + row = await server.system_variable_describe(name="dimdec", ctx=ctx) + assert row["known"] is True and row["name"] == "DIMDEC" and row["current"] == 2 + unknown = await server.system_variable_describe(name="DIMDEK", ctx=ctx) + assert unknown["known"] is False and "DIMDEC" in unknown["nearest"] + found = await server.system_variable_describe(search="decimal separator", ctx=ctx) + assert [row["name"] for row in found["matches"]] == ["DIMDSEP"] + index = await server.system_variable_describe(ctx=ctx) + assert index["count"] == len(SYSVAR_CATALOG) and "LTSCALE" in index["names"] + + +@asyncio_test +async def test_describe_tool_works_without_a_backend(): + import server + + class _NoBackendCtx: + lifespan_context = {"backend": None} + + row = await server.system_variable_describe(name="LTSCALE", ctx=_NoBackendCtx()) + assert row["known"] is True and "current" not in row diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index 13ab419..194f674 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -517,8 +517,10 @@ 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 - other number here has been unchanged since the snapshot was taken. + `pid` rather than under their secondary `query` / `create` tags. `system` + moved 7 -> 8 when v1.6's `system_variable_describe` (the sysvar catalogue + as a read-only tool) opened SECTION 20. Every other number here has been + unchanged since the snapshot was taken. """ sizes = {label: len(names) for label, names in (await server._tool_groups()).items()} assert sizes == { @@ -537,10 +539,10 @@ async def test_tool_group_sizes_are_unchanged(): "pid": 9, "premium": 12, "solids": 5, - "system": 7, + "system": 8, "templates": 2, "transactions": 3, "validation": 1, "view": 4, } - assert sum(sizes.values()) == 166 + assert sum(sizes.values()) == 167 From 75c13196b90f9c09fdfd081f1bd5a9fa5ada2fcb Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Thu, 17 Sep 2026 15:20:02 +0300 Subject: [PATCH 08/12] fix(task-15): angle sysvars are radians on both engines; OSMODE is registry-saved and refused headlessly Three authored claims in SYSVAR_CATALOG were false against the engines: - HPANG said "degrees" but ActiveX GetVariable/SetVariable (and AutoLISP getvar) hold radians - measured on AutoCAD 2026 in a scratch document: SetVariable(pi/4) reads back 0.7853981633974483 on both paths. The row now says radians, like POLARANG. - ANGBASE said "radians" with both engines True, but the DXF header stores $ANGBASE in degrees (AutoCAD saved the 1.5708-radian seat value as 90.0) and ezdxf read/wrote it raw, so the two engines disagreed by a factor of 57.3. EzdxfBackend now translates that header variable at its boundary (_HEADER_DEGREE_SYSVARS): the tool speaks radians on both engines, the file keeps degrees. - OSMODE claimed engines.ezdxf True, but $OSMODE is an R12-only header variable ezdxf never exports for R2000+: a headless write reported ok: True and read back None after save + reopen. It is registry-saved in AutoCAD, so it joins _UNSAVED_SYSVARS (refused with capability: registry_sysvar; the facade's `osmode` key refuses and snapshots None headlessly, like polar / polar_angle). Headless reads of every registry-saved variable now report None instead of whatever the ezdxf template holds in memory. The gate that should have caught all three, tests/test_sysvar_catalog.py's default round-trip, wrote 56 no-ops out of 66 and never saved: it now writes a value that differs from the fresh document's for every writable ezdxf-True entry, saves, reopens and reads back from the file. Two new tests pin OSMODE's refusal and ANGBASE's radians-at-the-tool / degrees-in-the-file split. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/contracts/settings.py | 7 +- backends/ezdxf_backend.py | 27 +++++- engineering/standards/sysvars.py | 25 ++++-- server.py | 11 +-- tests/test_sysvar_catalog.py | 142 ++++++++++++++++++++++++++----- 5 files changed, 175 insertions(+), 37 deletions(-) diff --git a/backends/contracts/settings.py b/backends/contracts/settings.py index ec2dcc4..1a12036 100644 --- a/backends/contracts/settings.py +++ b/backends/contracts/settings.py @@ -44,8 +44,9 @@ class SettingsContract: unknown key; a value outside ``_SETTING_RANGES``; a malformed ``limits`` / ``annotation_scale``; ``annotation_scale`` on an R12 file headlessly (no OBJECTS section, so the value would vanish at save — - save as R2000 or newer first); ``polar`` / ``polar_angle`` on the - headless engine (registry-saved — ``capability: registry_sysvar``); + save as R2000 or newer first); ``osmode`` / ``polar`` / ``polar_angle`` + on the headless engine (registry-saved — ``capability: registry_sysvar``; + a snapshot reports them as ``None`` there, a file holds no value); ``dimstyle`` / ``textstyle`` when the backend has no styles contract. Note that a no-argument snapshot is one ``system_get_variable`` per key @@ -145,6 +146,8 @@ _SETTING_MAP: dict[str, tuple[str, str]] = { "text_size": ("TEXTSIZE", "float"), "point_mode": ("PDMODE", "int"), "point_size": ("PDSIZE", "float"), + # OSMODE is registry-saved (a DXF R2000+ file has no $OSMODE), so the + # headless engine refuses it like the polar pair below. "osmode": ("OSMODE", "int"), "fillet_radius": ("FILLETRAD", "float"), # ── track E: the environment a drawing lives in ────────────────────── diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 2b7148a..247e882 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -73,12 +73,18 @@ _VPORT_SYSVARS: dict[str, tuple[str, str]] = { #: Registry-saved or never-saved (AutoCAD System Variables reference, "Saved #: in" column). A file has nowhere to keep them, so the headless engine refuses #: a write with `capability: registry_sysvar` instead of storing a value that -#: would vanish; `engineering.standards.sysvars` pins this set to its catalogue. +#: would vanish, and reads report `None` rather than the value ezdxf's document +#: template happens to hold in memory; `engineering.standards.sysvars` pins this +#: set to its catalogue. OSMODE / ATTREQ / ATTDIA are the trap: ezdxf accepts +#: `doc.header["$OSMODE"]` (an R12 header variable, `maxdxf="AC1009"` in +#: `HEADER_VAR_MAP`) but never exports it for R2000+ - measured 2026-09-17: a +#: write of 4134 to a fresh AC1024 document read back `None` after save + open. _UNSAVED_SYSVARS: frozenset[str] = frozenset( { "AUTOSNAP", "POLARANG", "POLARMODE", + "OSMODE", "ATTREQ", "ATTDIA", "SAVETIME", @@ -95,6 +101,13 @@ _UNSAVED_SYSVARS: frozenset[str] = frozenset( } ) +#: Header variables the DXF file stores in degrees (group code 50) while the +#: sysvar boundary speaks radians - the unit ActiveX `GetVariable` and AutoLISP +#: `getvar` use, so both engines report the same number. Measured on AutoCAD +#: 2026 (2026-09-17): `SETVAR ANGBASE 90` reads back 1.5707963267948966 over +#: COM and the DXF AutoCAD saves carries `$ANGBASE = 90.0`. +_HEADER_DEGREE_SYSVARS: frozenset[str] = frozenset({"ANGBASE"}) + log = logging.getLogger(__name__) @@ -5573,7 +5586,12 @@ class EzdxfBackend(AutoCADBackend): return self._dictionary_variable(doc, "CANNOSCALE", "1:1") if key == "CANNOSCALEVALUE": return scale_value(self._dictionary_variable(doc, "CANNOSCALE", "1:1")) - return doc.header.get(f"${key}", None) + if key in _UNSAVED_SYSVARS: + return None # a file holds no value; the template's memory is not one + raw = doc.header.get(f"${key}", None) + if key in _HEADER_DEGREE_SYSVARS and raw is not None: + return math.radians(float(raw)) + return raw return await self._async(_sync) @@ -5607,8 +5625,11 @@ class EzdxfBackend(AutoCADBackend): "CANNOSCALEVALUE is read-only; set CANNOSCALE (e.g. '1:50') instead." ) else: + stored = value + if key in _HEADER_DEGREE_SYSVARS: + stored = math.degrees(float(value)) try: - doc.header[f"${key}"] = value + doc.header[f"${key}"] = stored except DXFKeyError as exc: # Keeps the pinned "$$DIMTXT" text (tests/test_dimension_header_vars.py) # while saying why the write is refused rather than dropped. diff --git a/engineering/standards/sysvars.py b/engineering/standards/sysvars.py index be6adc0..026036d 100644 --- a/engineering/standards/sysvars.py +++ b/engineering/standards/sysvars.py @@ -12,9 +12,21 @@ variable ezdxf will export, the active VPORT (grid/snap), the variable dictionary (CANNOSCALE) or the model-space layout (limits). Registry-saved and never-saved variables are ``False`` because a file has nowhere to keep them, and ``EzdxfBackend.system_set_variable`` refuses them with -``capability: registry_sysvar``. A handful of drawing-saved variables are also -``False`` because ezdxf 1.4 has no header slot for them (CTAB, VIEWCTR, -VIEWSIZE, ISOLINES, FACETRES); that refusal is a plain ``ValueError``. +``capability: registry_sysvar`` (OSMODE is one of them: ``$OSMODE`` is an R12 +header variable that ezdxf never exports for R2000+, so a headless write +looked accepted and vanished at save until it joined the refused set). A +handful of drawing-saved variables are also ``False`` because ezdxf 1.4 has no +header slot for them (CTAB, VIEWCTR, VIEWSIZE, ISOLINES, FACETRES); that +refusal is a plain ``ValueError``. + +Angles are **radians** at the ``system_get_variable`` / ``system_set_variable`` +boundary on both engines - the unit ActiveX ``GetVariable`` / ``SetVariable`` +and AutoLISP ``getvar`` use (measured on AutoCAD 2026: ``SETVAR ANGBASE 90`` +reads back 1.5707963267948966, and so does HPANG after ``SETVAR HPANG 45``). +The DXF header stores ``$ANGBASE`` in degrees (group code 50; the same +document saved by AutoCAD carries ``$ANGBASE = 90.0``), so the headless engine +translates that one variable at its boundary and the two engines report the +same number. ``drawing_settings`` speaks degrees for its friendly keys. Sources: the AutoCAD 2026 System Variables reference (type / saved-in / initial value columns), the DXF Reference HEADER and VPORT sections, and @@ -464,9 +476,10 @@ _ENTRIES: tuple[SysVar, ...] = ( _v( "OSMODE", "int", - "running object snap bitmask (1 end, 2 mid, 4 cen, 8 nod, 16 qua, 32 int, 64 ins, 128 per, 256 tan, 512 nea, 1024 quick, 2048 app, 4096 ext, 8192 par, 16384 off); headlessly held in memory only, DXF R2000+ does not carry it", + "running object snap bitmask (1 end, 2 mid, 4 cen, 8 nod, 16 qua, 32 int, 64 ins, 128 per, 256 tan, 512 nea, 1024 quick, 2048 app, 4096 ext, 8192 par, 16384 off); registry-saved, and a DXF R2000+ file carries no $OSMODE, so the headless engine refuses it", range_=(0, 32767), default=4133, + saved_in="registry", friendly_key="osmode", ), # ── current properties ─────────────────────────────────────────────────── @@ -543,7 +556,7 @@ _ENTRIES: tuple[SysVar, ...] = ( _v( "ANGBASE", "float", - "direction of angle 0 relative to the current UCS, in radians", + "direction of angle 0 relative to the current UCS, in radians on both engines (90 degrees = 1.5708; the DXF header stores degrees and the headless engine translates)", default=0.0, ), _v("ANGDIR", "bool", "positive angle direction: 0 counter-clockwise, 1 clockwise", default=0), @@ -655,7 +668,7 @@ _ENTRIES: tuple[SysVar, ...] = ( _v( "HPANG", "float", - "default hatch pattern angle in degrees", + "default hatch pattern angle in radians, as ActiveX and AutoLISP hold it (45 degrees = 0.7854)", default=0.0, saved_in="not_saved", ), diff --git a/server.py b/server.py index bcaa319..1a81ae3 100644 --- a/server.py +++ b/server.py @@ -5093,11 +5093,12 @@ async def drawing_settings( Refusals, per key, nothing else rolled back: an unknown key; a value outside its range (grid/snap spacing > 0, polar_angle 0–360, precision 0–8, …); a - malformed `limits` or `annotation_scale`; `polar` / `polar_angle` on the - headless engine (`capability: registry_sysvar` — AutoCAD keeps them in the - registry, a file cannot); `dimstyle` / `textstyle` when the backend has no - styles contract. On the live engine `annotation_scale` must name a scale in - the drawing's scale list (SCALELISTEDIT) and AutoCAD refuses the write on a + malformed `limits` or `annotation_scale`; `osmode` / `polar` / `polar_angle` + on the headless engine (`capability: registry_sysvar` — AutoCAD keeps them in + the registry, a file cannot, and a headless snapshot reports them as `None`); + `dimstyle` / `textstyle` when the backend has no styles contract. On the + live engine `annotation_scale` must name a scale in the drawing's scale list + (SCALELISTEDIT) and AutoCAD refuses the write on a paper-space layout with no active viewport; the headless engine adds the scale, seeding AutoCAD's default list first when the drawing has none, and refuses it on an R12 file (no OBJECTS section, so the value would diff --git a/tests/test_sysvar_catalog.py b/tests/test_sysvar_catalog.py index acba209..e55cd2b 100644 --- a/tests/test_sysvar_catalog.py +++ b/tests/test_sysvar_catalog.py @@ -1,14 +1,18 @@ """SYSVAR_CATALOG: authored facts about AutoCAD system variables, pinned to the engines. Every `engines["ezdxf"]` claim is checked against the real headless backend -(read, and write of the default where the variable is writable); every -`saved_in` that is not "drawing" is checked to be exactly the set the backend -refuses with `capability: registry_sysvar`. The catalogue cannot say a thing -the backend does not do. +(read, and - where the variable is writable - a write of a value that differs +from the fresh document's, followed by a save and a reopen, so a header slot +ezdxf holds in memory but never exports cannot pass); every `saved_in` that is +not "drawing" is checked to be exactly the set the backend refuses with +`capability: registry_sysvar`. The catalogue cannot say a thing the backend +does not do. """ from __future__ import annotations +import math + import pytest from backends.base import UnsupportedCapabilityError @@ -71,10 +75,47 @@ SPEC_NAMES = ( "ISOLINES FACETRES DISPSILH" ).split() -#: The two engines legitimately represent these differently (a character vs. -#: its code; a colour name vs. its ACI number), so their default cannot be -#: written verbatim headlessly. Everything else round-trips its default. -_REPRESENTATION_DIFFERS = {"DIMDSEP", "CECOLOR"} +#: A value that is not the fresh document's, for every writable headless entry +#: the generic rule below cannot pick one for: the two engines represent DIMDSEP +#: and CECOLOR differently (a character vs. its code; a colour name vs. its ACI +#: number), so the headless form is written; the name-valued ones must name +#: something the drawing has (`drawing_new` bootstraps GEOMETRY and CENTER; +#: `_set_annotation_scale` seeds AutoCAD's default scale list). +_PROBE_VALUES: dict[str, object] = { + "DIMDSEP": 44, # ',' as the character code the header holds (a fresh document has 46) + "CECOLOR": 1, # ACI red; the header holds the number + "CLAYER": "GEOMETRY", + "CELTYPE": "CENTER", + "CANNOSCALE": "1:2", +} + + +def _probe_value(var: SysVar, fresh): + """A legal value for ``var`` that differs from ``fresh``, so the write is not a no-op.""" + if var.name in _PROBE_VALUES: + return _PROBE_VALUES[var.name] + if var.type == "bool": + return 0 if int(fresh) else 1 + if var.type == "enum": + return next(code for code in var.enum if code != int(fresh)) + if var.type in ("int", "float"): + step = 1 if var.type == "int" else 1.0 + low, high = var.range if var.range is not None else (-math.inf, math.inf) + candidate = fresh + step + return candidate if candidate <= high else fresh - step + if var.type == "point": + return (float(fresh[0]) + 1.0, float(fresh[1]) + 2.0) + return f"{fresh}_PROBE" + + +def _same(var: SysVar, back, expected) -> bool: + if var.type == "point": + return tuple(float(v) for v in back[:2]) == tuple(float(v) for v in expected[:2]) + if var.type in ("bool", "int", "enum"): + return int(back) == int(expected) + if var.type == "float": + return float(back) == pytest.approx(float(expected)) + return back == expected # ── the data is complete ──────────────────────────────────────────────────── @@ -200,20 +241,79 @@ async def test_every_ezdxf_true_entry_reads_on_a_fresh_document(backend): @asyncio_test -async def test_every_writable_ezdxf_true_entry_round_trips_its_default(backend): - for name, var in SYSVAR_CATALOG.items(): - if not var.engines["ezdxf"] or var.read_only or name in _REPRESENTATION_DIFFERS: - continue - await backend.system_set_variable(name, var.default) +async def test_every_writable_ezdxf_true_entry_survives_a_save_and_reopen(backend, tmp_path): + """The write must land in the file, not only in memory. + + The earlier form of this gate wrote each entry's *default* and read it back + without saving: 56 of its 66 writes were no-ops (default == fresh value) + and the rest could not tell a header slot ezdxf exports from one it holds + in memory and drops at save (`$OSMODE`, R12-only). Every write here moves + the value, and every read-back is from a reopened file. + """ + writable = { + name: var + for name, var in SYSVAR_CATALOG.items() + if var.engines["ezdxf"] and not var.read_only + } + probes: dict[str, object] = {} + for name, var in writable.items(): + fresh = await backend.system_get_variable(name) + probe = _probe_value(var, fresh) + assert not _same(var, fresh, probe), f"{name}: the probe is a no-op write" + out = await backend.system_set_variable(name, probe) + assert out["ok"] is True, name + probes[name] = probe + + path = str(tmp_path / "sysvars.dxf") + await backend.drawing_save_as(path) + await backend.drawing_open(path) + + lost = {} + for name, probe in probes.items(): back = await backend.system_get_variable(name) - if var.type == "point": - assert tuple(float(v) for v in back[:2]) == tuple(float(v) for v in var.default), name - elif var.type in ("bool", "int", "enum"): - assert int(back) == int(var.default), name - elif var.type == "float": - assert float(back) == pytest.approx(float(var.default)), name - else: - assert back == var.default, name + if back is None or not _same(writable[name], back, probe): + lost[name] = (probe, back) + assert lost == {}, f"engines.ezdxf is True but the value did not survive the file: {lost}" + + +@asyncio_test +async def test_osmode_is_registry_saved_and_the_headless_engine_says_so(backend): + """`$OSMODE` is an R12 header variable; ezdxf keeps it in memory and never + exports it for R2000+, so a write used to report `ok: True` and vanish at + save. It is registry-saved in AutoCAD and refused headlessly like POLARANG; + the read reports `None` instead of the template's memory.""" + row = describe_sysvar("OSMODE") + assert row["saved_in"] == "registry" and row["engines"] == {"ezdxf": False, "com": True} + assert await backend.system_get_variable("OSMODE") is None + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await backend.system_set_variable("OSMODE", 4134) + assert excinfo.value.capability == "registry_sysvar" + # the facade follows: no guess in the snapshot, a per-key refusal on write + assert (await backend.drawing_settings())["settings"]["osmode"] is None + res = await backend.drawing_settings({"osmode": 4134}) + assert "registry" in res["errors"]["osmode"] + + +@asyncio_test +async def test_angbase_is_radians_at_the_boundary_and_degrees_in_the_file(backend, tmp_path): + """ActiveX `GetVariable("ANGBASE")` and AutoLISP `getvar` hold radians + (measured: `SETVAR ANGBASE 90` reads back 1.5707963267948966), while the + DXF header stores `$ANGBASE` in degrees (the same drawing saved by AutoCAD + carries 90.0). The headless engine translates, so both engines report the + same number and the catalogue's "radians" is true on both.""" + assert "radians" in describe_sysvar("ANGBASE")["meaning"] + assert "radians" in describe_sysvar("HPANG")["meaning"] + await backend.system_set_variable("ANGBASE", math.pi / 2) + assert backend._doc.header["$ANGBASE"] == pytest.approx(90.0) + assert await backend.system_get_variable("ANGBASE") == pytest.approx(math.pi / 2) + + path = str(tmp_path / "angbase.dxf") + await backend.drawing_save_as(path) + text = open(path, encoding="utf-8").read() + start = text.index("$ANGBASE") + assert "90.0" in text[start : start + 40], text[start : start + 40] + await backend.drawing_open(path) + assert await backend.system_get_variable("ANGBASE") == pytest.approx(math.pi / 2) @asyncio_test From 10a7372ce7093dbba97004c4b6f15fb916558c97 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Mon, 21 Sep 2026 16:59:19 +0300 Subject: [PATCH 09/12] =?UTF-8?q?feat(settings):=20drawing=5Fproperties=5F?= =?UTF-8?q?get/set=20=E2=80=94=20SummaryInfo=20on=20COM,=20custom=20proper?= =?UTF-8?q?ties=20on=20both=20engines?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Summary fields are refused headlessly with capability dwgprops before any write; custom properties are $CUSTOMPROPERTYTAG/$CUSTOMPROPERTY pairs and survive save + reopen. Validation is shared, by name, and never str()-coerces. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- README.md | 2 +- backends/com_backend.py | 63 +++++++++ backends/contracts/settings.py | 50 +++++++ backends/ezdxf_backend.py | 63 ++++++++- discovery/aliases.py | 25 ++++ docs/tool-inventory.json | 32 ++++- server.py | 67 ++++++++- tests/test_drawing_properties.py | 233 +++++++++++++++++++++++++++++++ tests/test_tool_registry.py | 11 +- 9 files changed, 537 insertions(+), 9 deletions(-) create mode 100644 tests/test_drawing_properties.py diff --git a/README.md b/README.md index 11bfe92..540c09e 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ Live through COM on Windows, or headless through ezdxf anywhere — one typed co -> **v1.5 release snapshot:** 167 tools · 8 resources · 5 prompt templates · 2423 collected tests. +> **v1.5 release snapshot:** 169 tools · 8 resources · 5 prompt templates · 2423 collected tests. > 166 is the **registered** count; a default install advertises 161 over `tools/list`, > because `ENABLE_3D` is unset. `system_about` is the runtime authority. diff --git a/backends/com_backend.py b/backends/com_backend.py index 5f506d7..b72d124 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -36,6 +36,7 @@ from .base import ( rad2deg, shoelace_area, ) +from .contracts.settings import SUMMARY_FIELDS, validate_drawing_properties log = logging.getLogger(__name__) @@ -195,6 +196,9 @@ def _msp(): _BUILTIN_LINETYPES = {"continuous", "bylayer", "byblock"} +#: `drawing_properties_*` field → `IAcadSummaryInfo` property. +_SUMMARY_ATTRS = {field: field.capitalize() for field in SUMMARY_FIELDS} + def _ensure_linetype_loaded(name: str) -> None: """If `name` is not already loaded, load it via -LINETYPE behind FILEDIA=0. @@ -815,6 +819,7 @@ class ComBackend(AutoCADBackend): "solid_3d": _solid_3d_capability(), "lisp": FeatureCapability(True, "sanitized"), "registry_sysvar": FeatureCapability(True, "native"), + "dwgprops": FeatureCapability(True, "native"), }, ) @@ -4257,3 +4262,61 @@ class ComBackend(AutoCADBackend): return _entity_info(xline) return await self._run(_sync) + + # ── settings (track E, group C) ─────────────────────────────────────────── + + async def drawing_properties_get(self) -> dict: + def _sync(): + info = _acad_doc().SummaryInfo + summary = { + field: str(getattr(info, attr) or "") for field, attr in _SUMMARY_ATTRS.items() + } + custom: dict[str, str] = {} + for index in range(int(info.NumCustomInfo())): + key, value = info.GetCustomByIndex(index) + custom[str(key)] = str(value) + return { + "summary": summary, + "summary_available": True, + "custom": custom, + "backend": "com", + } + + return await self._run(_sync) + + async def drawing_properties_set( + self, summary: dict | None = None, custom: dict | None = None + ) -> dict: + # Validated before any ActiveX call: a bad field or value raises here + # and SummaryInfo is never touched. + written, to_write, to_delete = validate_drawing_properties(summary, custom) + + def _sync(): + info = _acad_doc().SummaryInfo + existing: set[str] = set() + for index in range(int(info.NumCustomInfo())): + key, _value = info.GetCustomByIndex(index) + existing.add(str(key)) + for field, value in written.items(): + setattr(info, _SUMMARY_ATTRS[field], value) + for key, value in to_write.items(): + # AddCustomInfo on an existing key raises in AutoCAD; SetCustomByKey + # on a missing one does too — route by what is really there. + if key in existing: + info.SetCustomByKey(key, value) + else: + info.AddCustomInfo(key, value) + deleted = [] + for key in to_delete: + if key in existing: + info.RemoveCustomByKey(key) + deleted.append(key) + return { + "ok": True, + "summary_written": sorted(written), + "custom_written": sorted(to_write), + "custom_deleted": deleted, + "backend": "com", + } + + return await self._run(_sync) diff --git a/backends/contracts/settings.py b/backends/contracts/settings.py index 1a12036..0362e35 100644 --- a/backends/contracts/settings.py +++ b/backends/contracts/settings.py @@ -505,3 +505,53 @@ def _decode_setting(key: str, kind: str, raw: Any) -> Any: except (TypeError, ValueError): return raw return raw + + +# --------------------------------------------------------------------------- +# Document properties (DWGPROPS) — validation shared by both engines +# --------------------------------------------------------------------------- + +#: The SummaryInfo fields AutoCAD's DWGPROPS dialog shows on its Summary tab. +SUMMARY_FIELDS = ("title", "subject", "author", "keywords", "comments") + + +def validate_drawing_properties( + summary: dict | None, custom: dict | None +) -> tuple[dict[str, str], dict[str, str], list[str]]: + """→ (summary fields to write, custom keys to write, custom keys to delete). + + Refuses the whole request by name before either engine writes anything: + an unknown summary field is a ``ValueError``; a non-string summary value, + a non-string or empty custom key, or a custom value that is neither a + string nor ``None`` (``None`` deletes) is a ``TypeError``. Values are never + coerced with ``str()`` — a number the caller meant as text is theirs to + format. + """ + if summary is not None and not isinstance(summary, dict): + raise TypeError("summary: must be an object of {field: text}") + written: dict[str, str] = {} + for field, value in (summary or {}).items(): + if field not in SUMMARY_FIELDS: + raise ValueError(f"summary: unknown field {field!r} (valid: {list(SUMMARY_FIELDS)})") + if value is None: + continue + if not isinstance(value, str): + raise TypeError(f"summary.{field}: must be a string, got {type(value).__name__}") + written[field] = value + if custom is not None and not isinstance(custom, dict): + raise TypeError("custom: must be an object of {key: text | null}") + to_write: dict[str, str] = {} + to_delete: list[str] = [] + for key, value in (custom or {}).items(): + if not isinstance(key, str) or not key.strip(): + raise TypeError(f"custom: key {key!r} must be a non-empty string") + if value is None: + to_delete.append(key) + elif isinstance(value, str): + to_write[key] = value + else: + raise TypeError( + f"custom[{key!r}]: must be a string or null (null deletes the key), " + f"got {type(value).__name__}" + ) + return written, to_write, to_delete diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 247e882..b33eb18 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -39,7 +39,12 @@ from .base import ( normalize_lineweight, shoelace_area, ) -from .contracts.settings import parse_scale, scale_value +from .contracts.settings import ( + SUMMARY_FIELDS, + parse_scale, + scale_value, + validate_drawing_properties, +) from .quarantine import ( AbandonedCall, DocumentQuarantineError, @@ -1110,6 +1115,10 @@ class EzdxfBackend(AutoCADBackend): "registry_sysvar": FeatureCapability( False, reason="registry_saved_variables_have_no_home_in_a_file" ), + "dwgprops": FeatureCapability( + False, + reason="summary_fields_need_live_autocad;custom_properties_supported", + ), }, ) @@ -6086,3 +6095,55 @@ class EzdxfBackend(AutoCADBackend): else: entry.dxf.value = name return name + + async def drawing_properties_get(self) -> dict: + def _sync(): + doc = self._require_doc() + return { + "summary": {field: None for field in SUMMARY_FIELDS}, + "summary_available": False, + "custom": {tag: value for tag, value in doc.header.custom_vars}, + "backend": "ezdxf", + } + + return await self._async(_sync) + + async def drawing_properties_set( + self, summary: dict | None = None, custom: dict | None = None + ) -> dict: + written, to_write, to_delete = validate_drawing_properties(summary, custom) + if written: + # The five summary fields live in the DWG SummaryInfo stream, which + # a DXF has no slot for; refuse before the custom keys are touched. + raise UnsupportedCapabilityError( + "dwgprops", + "drawing_properties_set: the summary fields (title, subject, author, keywords, " + "comments) live in the DWG SummaryInfo, which the headless ezdxf backend cannot " + f"write (asked for {sorted(written)}). Custom properties work on both engines; " + "set the summary on the live COM backend.", + ) + + def _sync(): + doc = self._require_doc() + custom_vars = doc.header.custom_vars + for key, value in to_write.items(): + if custom_vars.has_tag(key): + custom_vars.replace(key, value) + else: + custom_vars.append(key, value) + deleted = [] + for key in to_delete: + if custom_vars.has_tag(key): + custom_vars.remove(key, all=True) + deleted.append(key) + if to_write or deleted: + self._mark_dirty() + return { + "ok": True, + "summary_written": [], + "custom_written": sorted(to_write), + "custom_deleted": deleted, + "backend": "ezdxf", + } + + return await self._async(_sync) diff --git a/discovery/aliases.py b/discovery/aliases.py index 694c96c..f0e56df 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. +# DWGPROPS -- one dialog, three tabs: General (drawing_info), Summary and +# Custom (drawing_properties_get / drawing_properties_set). # # Every other AutoCAD command must map to exactly one tool. SHARED_ACAD_COMMANDS: frozenset[str] = frozenset( { "ARRAY", "BLOCK", + "DWGPROPS", "ERASE", "INSERT", "LAYER", @@ -453,6 +456,28 @@ TOOL_ALIASES: dict[str, ToolAliases] = { "drawing intent", ), ), + "drawing_properties_get": ToolAliases( + acad=("DWGPROPS",), + synonyms=( + "drawing properties", + "document properties", + "summary info", + "title subject author keywords", + "custom properties", + "who made this drawing", + ), + ), + "drawing_properties_set": ToolAliases( + acad=("DWGPROPS",), + synonyms=( + "set the drawing title", + "set author and keywords", + "add a custom property", + "project number in the file properties", + "edit summary info", + "delete a custom property", + ), + ), "drawing_preflight": ToolAliases( synonyms=( "preflight", diff --git a/docs/tool-inventory.json b/docs/tool-inventory.json index 7aa17d6..2ffd63a 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": 167, + "tools": 169, "resources": 8, "prompts": 5, "groups": 20 @@ -14,7 +14,7 @@ "blocks": 9, "corner_ops": 4, "dimensions": 5, - "drawing": 11, + "drawing": 13, "engineering": 10, "entity_creation": 18, "entity_modification": 16, @@ -631,6 +631,34 @@ "readOnlyHint": true } }, + { + "name": "drawing_properties_get", + "group": "drawing", + "cost": "read", + "summary": "Read the drawing's title, subject, author, keywords, comments and custom properties.", + "tags": [ + "drawing", + "settings" + ], + "annotations": { + "title": "Drawing Properties (read)", + "readOnlyHint": true + } + }, + { + "name": "drawing_properties_set", + "group": "drawing", + "cost": "safe", + "summary": "Set the drawing's summary fields and add, change or delete custom properties.", + "tags": [ + "drawing", + "settings" + ], + "annotations": { + "title": "Drawing Properties (write)", + "readOnlyHint": false + } + }, { "name": "drawing_purge", "group": "drawing", diff --git a/server.py b/server.py index 1a81ae3..9b11afa 100644 --- a/server.py +++ b/server.py @@ -6853,7 +6853,7 @@ async def pid_tag_parse( # --------------------------------------------------------------------------- -# ── SECTION 20: Environment (1 tool) ──────────────────────────────────────── +# ── SECTION 20: Environment (3 tools) ─────────────────────────────────────── # --------------------------------------------------------------------------- # Group C (settings) opens this section with `system_variable_describe` and, # in Task 16, the two `drawing_properties_*` tools; group V's merge extends the @@ -6912,6 +6912,71 @@ async def system_variable_describe( } +@cad_tool( + summary="Read the drawing's title, subject, author, keywords, comments and custom properties.", + cost="read", +) +@mcp.tool( + annotations={"title": "Drawing Properties (read)", "readOnlyHint": True}, + tags={"drawing", "settings"}, +) +async def drawing_properties_get(ctx: Context = None) -> dict: + """The DWGPROPS dialog as data: `summary` (title, subject, author, keywords, + comments), `summary_available`, and `custom` ({key: value}). + + On the live engine the summary comes from the document's SummaryInfo. The + headless engine cannot read that stream, so the five summary fields are + `null` with `summary_available: false` — not empty strings, which would + claim the drawing has no title. Custom properties (`$CUSTOMPROPERTYTAG` / + `$CUSTOMPROPERTY` header pairs) are read on both engines. + """ + return await _backend(ctx).drawing_properties_get() + + +@cad_tool( + summary="Set the drawing's summary fields and add, change or delete custom properties.", + cost="safe", +) +@mcp.tool( + annotations={"title": "Drawing Properties (write)", "readOnlyHint": False}, + tags={"drawing", "settings"}, +) +async def drawing_properties_set( + title: Annotated[str | None, "SummaryInfo Title"] = None, + subject: Annotated[str | None, "SummaryInfo Subject"] = None, + author: Annotated[str | None, "SummaryInfo Author"] = None, + keywords: Annotated[str | None, "SummaryInfo Keywords"] = None, + comments: Annotated[str | None, "SummaryInfo Comments"] = None, + custom: Annotated[ + dict | None, + "Custom properties to write: {key: text}; a null value deletes the key. " + "Keys not mentioned are left alone.", + ] = None, + ctx: Context = None, +) -> dict: + """Write DWGPROPS fields. Only the arguments given are touched; returns + `summary_written`, `custom_written` and `custom_deleted` (a key that was + never there is not reported deleted). + + Refusals, before anything is written: a summary field on the headless + engine (`capability: dwgprops` — SummaryInfo lives in the DWG, a DXF has + no slot for it; custom properties still work headlessly), a non-string + value (`TypeError` naming the field or key — nothing is coerced with + `str()`), an empty custom key. On the live engine a custom key is added + with AddCustomInfo when new and changed with SetCustomByKey when present. + """ + summary = { + "title": title, + "subject": subject, + "author": author, + "keywords": keywords, + "comments": comments, + } + touched = [k for k, v in summary.items() if v is not None] + sorted(custom or {}) + await ctx.info(f"Setting drawing properties: {', '.join(touched) or 'nothing'}") + return await _backend(ctx).drawing_properties_set(summary, custom) + + # --------------------------------------------------------------------------- # ── RESOURCES ─────────────────────────────────────────────────────────────── # --------------------------------------------------------------------------- diff --git a/tests/test_drawing_properties.py b/tests/test_drawing_properties.py new file mode 100644 index 0000000..afcc3ec --- /dev/null +++ b/tests/test_drawing_properties.py @@ -0,0 +1,233 @@ +"""drawing_properties_get / set: DWG summary and custom properties on both engines. + +Headlessly the five summary fields live in the DWG SummaryInfo stream, which +ezdxf cannot write, so any non-null summary field is refused with +`capability: dwgprops` before anything is touched; custom properties are +`$CUSTOMPROPERTYTAG` / `$CUSTOMPROPERTY` header pairs and work on both +engines (measured: they survive save + reopen). +""" + +from __future__ import annotations + +import types + +import pytest + +from backends.base import UnsupportedCapabilityError +from backends.contracts.settings import SUMMARY_FIELDS, validate_drawing_properties + +pytestmark = pytest.mark.asyncio + +EMPTY_SUMMARY = {field: None for field in SUMMARY_FIELDS} + + +# ── shared validation ─────────────────────────────────────────────────────── + + +def test_validation_splits_writes_and_deletes(): + written, to_write, to_delete = validate_drawing_properties( + {"title": "Gearbox", "author": None}, {"PROJECT": "X-1", "OLD": None} + ) + assert written == {"title": "Gearbox"} + assert to_write == {"PROJECT": "X-1"} and to_delete == ["OLD"] + assert validate_drawing_properties(None, None) == ({}, {}, []) + + +@pytest.mark.parametrize( + "summary, custom, exc, fragment", + [ + ({"edition": "1"}, None, ValueError, "edition"), + ({"title": 3}, None, TypeError, "summary.title"), + (None, {"N": 3}, TypeError, "custom['N']"), + (None, {"": "x"}, TypeError, "non-empty"), + ("Gearbox", None, TypeError, "summary"), + (None, ["PROJECT"], TypeError, "custom"), + ], +) +def test_validation_refuses_by_name(summary, custom, exc, fragment): + with pytest.raises(exc) as excinfo: + validate_drawing_properties(summary, custom) + assert fragment in str(excinfo.value) + + +# ── headless engine ───────────────────────────────────────────────────────── + + +async def test_get_reports_no_summary_and_empty_custom(backend): + assert await backend.drawing_properties_get() == { + "summary": EMPTY_SUMMARY, + "summary_available": False, + "custom": {}, + "backend": "ezdxf", + } + + +async def test_custom_properties_write_replace_delete_and_survive_reopen(backend, tmp_path): + first = await backend.drawing_properties_set(custom={"PROJECT": "X-1", "REV": "B"}) + assert first == { + "ok": True, + "summary_written": [], + "custom_written": ["PROJECT", "REV"], + "custom_deleted": [], + "backend": "ezdxf", + } + second = await backend.drawing_properties_set( + custom={"REV": None, "NOPE": None, "PROJECT": "X-2"} + ) + assert second["custom_written"] == ["PROJECT"] + assert second["custom_deleted"] == ["REV"], "a key that was never there is not 'deleted'" + assert (await backend.drawing_properties_get())["custom"] == {"PROJECT": "X-2"} + assert [t for t, _ in backend._doc.header.custom_vars] == ["PROJECT"], "no duplicate tags" + + path = str(tmp_path / "props.dxf") + await backend.drawing_save_as(path) + await backend.drawing_open(path) + assert (await backend.drawing_properties_get())["custom"] == {"PROJECT": "X-2"} + + +async def test_summary_is_refused_headlessly_before_any_write(backend): + with pytest.raises(UnsupportedCapabilityError) as excinfo: + await backend.drawing_properties_set(summary={"title": "T"}, custom={"LATE": "no"}) + assert excinfo.value.capability == "dwgprops" + assert "title" in str(excinfo.value) + assert (await backend.drawing_properties_get())["custom"] == {} + + +async def test_all_null_summary_is_not_a_summary_write(backend): + res = await backend.drawing_properties_set(summary={"title": None, "author": None}) + assert res["ok"] is True and res["summary_written"] == [] + + +async def test_malformed_custom_is_refused_by_name_before_any_write(backend): + with pytest.raises(TypeError, match=r"custom\['N'\]"): + await backend.drawing_properties_set(custom={"OK": "1", "N": 3}) + assert (await backend.drawing_properties_get())["custom"] == {} + + +def test_both_capability_maps_declare_dwgprops(): + from backends.com_backend import ComBackend + from backends.ezdxf_backend import EzdxfBackend + + assert EzdxfBackend().capabilities().to_dict()["features"]["dwgprops"]["supported"] is False + assert ComBackend().capabilities().to_dict()["features"]["dwgprops"]["supported"] is True + + +# ── server tools ──────────────────────────────────────────────────────────── + + +class _FakeCtx: + def __init__(self, backend): + self.lifespan_context = {"backend": backend} + + async def info(self, *a, **k): + pass + + +async def test_server_tools_round_trip_custom_and_refuse_summary(backend): + import server + + ctx = _FakeCtx(backend) + res = await server.drawing_properties_set(custom={"PROJECT": "X-9"}, ctx=ctx) + assert res["custom_written"] == ["PROJECT"] + got = await server.drawing_properties_get(ctx=ctx) + assert got["custom"] == {"PROJECT": "X-9"} and got["summary_available"] is False + with pytest.raises(UnsupportedCapabilityError): + await server.drawing_properties_set(title="Gearbox", ctx=ctx) + + +# ── live engine, against a fake SummaryInfo ───────────────────────────────── + + +class _FakeSummaryInfo: + def __init__(self): + self.Title = "Old title" + self.Subject = "" + self.Author = "" + self.Keywords = "gear" + self.Comments = "" + self._custom: list[tuple[str, str]] = [("PROJECT", "X-1"), ("OLD", "1")] + self.calls: list[tuple] = [] + + def NumCustomInfo(self): + self.calls.append(("NumCustomInfo",)) + return len(self._custom) + + def GetCustomByIndex(self, index): + self.calls.append(("GetCustomByIndex", index)) + return self._custom[index] # pywin32 hands [out] BSTR pairs back as a tuple + + def AddCustomInfo(self, key, value): + self.calls.append(("AddCustomInfo", key, value)) + self._custom.append((key, value)) + + def SetCustomByKey(self, key, value): + self.calls.append(("SetCustomByKey", key, value)) + self._custom = [(k, value if k == key else v) for k, v in self._custom] + + def RemoveCustomByKey(self, key): + self.calls.append(("RemoveCustomByKey", key)) + self._custom = [(k, v) for k, v in self._custom if k != key] + + +@pytest.fixture +def com_backend(monkeypatch): + pytest.importorskip("win32com.client", reason="pywin32 not installed") + from backends import com_backend as module + + info = _FakeSummaryInfo() + document = types.SimpleNamespace(SummaryInfo=info) + monkeypatch.setattr(module, "_acad_doc", lambda: document) + backend = module.ComBackend() + + async def _run_inline(func, *args, **kwargs): + return func(*args, **kwargs) + + monkeypatch.setattr(backend, "_run", _run_inline) + return backend, info + + +async def test_com_get_reads_summary_and_custom(com_backend): + backend, info = com_backend + assert await backend.drawing_properties_get() == { + "summary": { + "title": "Old title", + "subject": "", + "author": "", + "keywords": "gear", + "comments": "", + }, + "summary_available": True, + "custom": {"PROJECT": "X-1", "OLD": "1"}, + "backend": "com", + } + + +async def test_com_set_writes_summary_and_routes_custom_by_existence(com_backend): + backend, info = com_backend + res = await backend.drawing_properties_set( + summary={"title": "Gearbox", "author": "U. Can", "subject": None}, + custom={"PROJECT": "X-2", "REV": "B", "OLD": None, "NOPE": None}, + ) + assert res == { + "ok": True, + "summary_written": ["author", "title"], + "custom_written": ["PROJECT", "REV"], + "custom_deleted": ["OLD"], + "backend": "com", + } + assert info.Title == "Gearbox" and info.Author == "U. Can" and info.Keywords == "gear" + mutations = [c for c in info.calls if c[0] not in ("NumCustomInfo", "GetCustomByIndex")] + assert mutations == [ + ("SetCustomByKey", "PROJECT", "X-2"), # existed → set + ("AddCustomInfo", "REV", "B"), # new → add + ("RemoveCustomByKey", "OLD"), # existed → remove; NOPE never touched + ] + + +async def test_com_validates_before_touching_activex(com_backend): + backend, info = com_backend + with pytest.raises(TypeError): + await backend.drawing_properties_set(summary={"title": "T"}, custom={"N": 3}) + with pytest.raises(ValueError): + await backend.drawing_properties_set(summary={"edition": "1"}) + assert info.calls == [] and info.Title == "Old title" diff --git a/tests/test_tool_registry.py b/tests/test_tool_registry.py index 194f674..ed2259b 100644 --- a/tests/test_tool_registry.py +++ b/tests/test_tool_registry.py @@ -519,8 +519,11 @@ async def test_tool_group_sizes_are_unchanged(): tag is ranked first in `_GROUP_TAG_PRIORITY` so those tools file under `pid` rather than under their secondary `query` / `create` tags. `system` moved 7 -> 8 when v1.6's `system_variable_describe` (the sysvar catalogue - as a read-only tool) opened SECTION 20. Every other number here has been - unchanged since the snapshot was taken. + as a read-only tool) opened SECTION 20. `drawing` moved 11 -> 13 when + v1.6's `drawing_properties_get` / `drawing_properties_set` (DWGPROPS on + both engines) joined SECTION 20 — tagged `drawing` and `settings`, and + `settings` is not a group tag, so they file under drawing. Every other + number here has been unchanged since the snapshot was taken. """ sizes = {label: len(names) for label, names in (await server._tool_groups()).items()} assert sizes == { @@ -529,7 +532,7 @@ async def test_tool_group_sizes_are_unchanged(): "blocks": 9, "corner_ops": 4, "dimensions": 5, - "drawing": 11, + "drawing": 13, "engineering": 10, "entity_creation": 18, "entity_modification": 16, @@ -545,4 +548,4 @@ async def test_tool_group_sizes_are_unchanged(): "validation": 1, "view": 4, } - assert sum(sizes.values()) == 167 + assert sum(sizes.values()) == 169 From 2b9791fbcd9c151bfec67206e4a1e2bdde92006b Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Mon, 21 Sep 2026 18:45:02 +0300 Subject: [PATCH 10/12] fix(task-16): custom properties refuse pre-R2004 headlessly, line breaks, and AutoCAD's invalid keys before any write Three header-only-write / half-write classes in drawing_properties_set: 1. Measured: ezdxf emits $CUSTOMPROPERTYTAG / $CUSTOMPROPERTY only after $LASTSAVEDBY, which does not exist before R2004, so on an R12 (AC1009) or R2000 (AC1015) document the write reported ok, drawing_properties_get echoed it from memory, and drawing_save_as (version kept) wrote nothing -- the class f3ff703 removed for CANNOSCALE. The headless engine now refuses with a ValueError naming the DXF version and "save as R2004 or newer", before custom_vars is touched; the dwgprops capability reason no longer claims custom_properties_supported unconditionally. 2. A line break in a custom key or value passed validation, ezdxf wrote it unescaped, and the saved DXF failed to reopen (DXFStructureError) -- the whole drawing lost, reported as success. Refused by name in the shared validator. 3. Measured on AutoCAD 2026: AddCustomInfo rejects keys with leading/trailing whitespace and keys containing '=', ';' or a no-break space as 'Invalid key' -- mid-write, after the summary and earlier keys were applied, as an unnamed COM error -- while the headless engine wrote them. The shared validator now mirrors those rules, so both engines refuse before writing (internal spaces, tabs, unicode and 300-char keys stay accepted, as AutoCAD accepts them). Pinned by save+reopen tests on R12/R2000 (refused, file reads back empty), R2004 (round-trips), the newline case (refused, the file still opens), and the fake-SummaryInfo COM test (no ActiveX call reaches SummaryInfo). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/contracts/settings.py | 59 ++++++++++++++ backends/ezdxf_backend.py | 18 +++- server.py | 10 ++- tests/test_drawing_properties.py | 136 ++++++++++++++++++++++++++++++- 4 files changed, 219 insertions(+), 4 deletions(-) diff --git a/backends/contracts/settings.py b/backends/contracts/settings.py index 0362e35..d5b1b3b 100644 --- a/backends/contracts/settings.py +++ b/backends/contracts/settings.py @@ -515,6 +515,49 @@ def _decode_setting(key: str, kind: str, raw: Any) -> Any: SUMMARY_FIELDS = ("title", "subject", "author", "keywords", "comments") +#: Characters AutoCAD's ``SummaryInfo.AddCustomInfo`` rejects inside a custom +#: key with ``Invalid key`` (measured on AutoCAD 2026; internal spaces, tabs +#: and other unicode are accepted). ``\xa0`` is the no-break space. +_CUSTOM_KEY_FORBIDDEN = {"=": "'='", ";": "';'", "\xa0": "a no-break space (U+00A0)"} + + +def _check_custom_text(key: str, text: str, what: str) -> None: + """A newline in a custom key or value corrupts the DXF on save. + + ezdxf writes custom properties verbatim as ``9 / $CUSTOMPROPERTYTAG / 1 / + {key}`` and ``9 / $CUSTOMPROPERTY / 1 / {value}`` header lines — a line + break inside either text starts a new (invalid) group and the reopened + file fails with ``DXFStructureError``. Refused here so neither engine + writes it. + """ + if "\n" in text or "\r" in text: + raise ValueError( + f"custom[{key!r}]: the {what} contains a line break, which corrupts the " + "DXF on save (the whole drawing would fail to reopen, not just the " + "property). Custom properties are single-line." + ) + + +def _check_custom_key(key: str) -> None: + """Mirror AutoCAD's own custom-key rules so the live engine cannot refuse + mid-write. Measured: ``AddCustomInfo`` raises ``Invalid key`` for a key + with leading/trailing whitespace and for ``=``, ``;`` or a no-break space + anywhere in it — after the summary fields and earlier keys were already + applied — while the headless engine happily writes the same key.""" + if key != key.strip(): + raise ValueError( + f"custom: key {key!r} has leading or trailing whitespace — AutoCAD's " + "AddCustomInfo rejects it as 'Invalid key'. Strip the key." + ) + for char, name in _CUSTOM_KEY_FORBIDDEN.items(): + if char in key: + raise ValueError( + f"custom: key {key!r} contains {name} — AutoCAD's AddCustomInfo rejects " + "it as 'Invalid key'. Use another separator." + ) + _check_custom_text(key, key, "key") + + def validate_drawing_properties( summary: dict | None, custom: dict | None ) -> tuple[dict[str, str], dict[str, str], list[str]]: @@ -526,6 +569,20 @@ def validate_drawing_properties( string nor ``None`` (``None`` deletes) is a ``TypeError``. Values are never coerced with ``str()`` — a number the caller meant as text is theirs to format. + + Custom keys and values are also held to the rules the *other* engine would + enforce or the file format would break on, so the same call cannot succeed + on one engine and half-apply on the other (``ValueError`` naming the key): + + * a key with leading or trailing whitespace, or containing ``=``, ``;`` or + a no-break space — AutoCAD's ``AddCustomInfo`` rejects these as + ``Invalid key`` mid-write (measured on AutoCAD 2026), after the summary + fields and the earlier keys were already applied; the headless engine + writes them without complaint, and AutoCAD then loads them from the DXF; + * a key or value containing a line break (LF or CR) — ezdxf emits custom + properties unescaped, one header line per text, so the saved DXF is + corrupt and the whole drawing fails to reopen (``DXFStructureError``), + not just the property. """ if summary is not None and not isinstance(summary, dict): raise TypeError("summary: must be an object of {field: text}") @@ -545,9 +602,11 @@ def validate_drawing_properties( for key, value in (custom or {}).items(): if not isinstance(key, str) or not key.strip(): raise TypeError(f"custom: key {key!r} must be a non-empty string") + _check_custom_key(key) if value is None: to_delete.append(key) elif isinstance(value, str): + _check_custom_text(key, value, "value") to_write[key] = value else: raise TypeError( diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index b33eb18..5b30d66 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -1117,7 +1117,7 @@ class EzdxfBackend(AutoCADBackend): ), "dwgprops": FeatureCapability( False, - reason="summary_fields_need_live_autocad;custom_properties_supported", + reason="summary_fields_need_live_autocad;custom_properties_need_r2004_or_newer", ), }, ) @@ -6125,6 +6125,22 @@ class EzdxfBackend(AutoCADBackend): def _sync(): doc = self._require_doc() + if (to_write or to_delete) and doc.dxfversion < "AC1018": + # Measured: ezdxf emits $CUSTOMPROPERTYTAG / $CUSTOMPROPERTY only + # after $LASTSAVEDBY, a header variable that does not exist + # before R2004 (its own CustomVars docstring says so). On an + # R12 or R2000 document the in-memory write "succeeded", + # drawing_properties_get echoed it back, and drawing_save_as + # (version kept) wrote nothing -- the header-only write that + # vanishes on save, the class f3ff703 removed for CANNOSCALE. + # Refused here, before custom_vars is touched. + raise ValueError( + f"custom properties: this document is DXF {doc.dxfversion} " + f"({doc.acad_release}), and $CUSTOMPROPERTYTAG / $CUSTOMPROPERTY " + "header pairs are only written for R2004 (AC1018) or newer, so " + f"{sorted(to_write) + sorted(to_delete)} would be lost on save. " + "Save as R2004 or newer first." + ) custom_vars = doc.header.custom_vars for key, value in to_write.items(): if custom_vars.has_tag(key): diff --git a/server.py b/server.py index 9b11afa..591bc2b 100644 --- a/server.py +++ b/server.py @@ -6962,8 +6962,14 @@ async def drawing_properties_set( engine (`capability: dwgprops` — SummaryInfo lives in the DWG, a DXF has no slot for it; custom properties still work headlessly), a non-string value (`TypeError` naming the field or key — nothing is coerced with - `str()`), an empty custom key. On the live engine a custom key is added - with AddCustomInfo when new and changed with SetCustomByKey when present. + `str()`), an empty custom key, a custom key AutoCAD's AddCustomInfo would + reject mid-write (leading/trailing whitespace, `=`, `;`, no-break space — + `ValueError` naming the key, on both engines), a line break in a custom + key or value (it corrupts the DXF on save), and headlessly a document + older than R2004 (`ValueError` — ezdxf only writes the custom-property + header pairs for AC1018+, so they would vanish at save; save as R2004 or + newer first). On the live engine a custom key is added with AddCustomInfo + when new and changed with SetCustomByKey when present. """ summary = { "title": title, diff --git a/tests/test_drawing_properties.py b/tests/test_drawing_properties.py index afcc3ec..aaea9df 100644 --- a/tests/test_drawing_properties.py +++ b/tests/test_drawing_properties.py @@ -4,7 +4,15 @@ Headlessly the five summary fields live in the DWG SummaryInfo stream, which ezdxf cannot write, so any non-null summary field is refused with `capability: dwgprops` before anything is touched; custom properties are `$CUSTOMPROPERTYTAG` / `$CUSTOMPROPERTY` header pairs and work on both -engines (measured: they survive save + reopen). +engines (measured: they survive save + reopen on R2004 and newer; ezdxf +only emits the pairs for AC1018+, so an older document is refused headlessly +instead of losing the write at save). + +Custom keys and values are validated once, by name, against what would break +*either* engine: keys AutoCAD's AddCustomInfo rejects mid-write as +`Invalid key` (measured on AutoCAD 2026: leading/trailing whitespace, `=`, +`;`, no-break space) and line breaks, which ezdxf writes unescaped and +which corrupt the whole DXF. """ from __future__ import annotations @@ -42,6 +50,19 @@ def test_validation_splits_writes_and_deletes(): (None, {"": "x"}, TypeError, "non-empty"), ("Gearbox", None, TypeError, "summary"), (None, ["PROJECT"], TypeError, "custom"), + # Keys AutoCAD's AddCustomInfo rejects as 'Invalid key' (measured on + # AutoCAD 2026) -- refused here so the live engine never fails + # mid-write after the summary and earlier keys were already applied. + (None, {" BAD ": "x"}, ValueError, "' BAD '"), + (None, {"PROJECT ": "x"}, ValueError, "trailing whitespace"), + (None, {"A=B": "x"}, ValueError, "'A=B' contains '='"), + (None, {"A;B": "x"}, ValueError, "'A;B' contains ';'"), + (None, {"NB\xa0SP": "x"}, ValueError, "no-break space"), + # A line break is written unescaped by ezdxf and corrupts the DXF. + (None, {"NOTE": "line1\nline2"}, ValueError, "custom['NOTE']: the value"), + (None, {"NOTE": "line1\rline2"}, ValueError, "line break"), + (None, {"K\nEY": "x"}, ValueError, "the key contains a line break"), + (None, {"K\nEY": None}, ValueError, "the key contains a line break"), ], ) def test_validation_refuses_by_name(summary, custom, exc, fragment): @@ -50,6 +71,13 @@ def test_validation_refuses_by_name(summary, custom, exc, fragment): assert fragment in str(excinfo.value) +@pytest.mark.parametrize("key", ["IN SP", "T\tAB", "\u00dcn\u00efcode", "X" * 300]) +def test_validation_accepts_keys_autocad_accepts(key): + """Measured: internal spaces, tabs, unicode and 300-char keys pass + AddCustomInfo; the validator must not be stricter than AutoCAD.""" + assert validate_drawing_properties(None, {key: "v"}) == ({}, {key: "v"}, []) + + # ── headless engine ───────────────────────────────────────────────────────── @@ -85,6 +113,96 @@ async def test_custom_properties_write_replace_delete_and_survive_reopen(backend assert (await backend.drawing_properties_get())["custom"] == {"PROJECT": "X-2"} +async def test_line_break_is_refused_before_any_write_and_the_file_still_opens(backend, tmp_path): + """Measured: `custom={'NOTE': 'line1\\nline2', 'AFTER': 'ok'}` reported + `ok: True`, and the saved DXF failed to reopen with + `DXFStructureError: Invalid group code "line2"` -- the whole drawing lost, + reported as success. The request is refused as a whole, so AFTER is not + written either, and the file saved afterwards reopens.""" + with pytest.raises(ValueError, match=r"custom\['NOTE'\]: the value contains a line break"): + await backend.drawing_properties_set(custom={"NOTE": "line1\nline2", "AFTER": "ok"}) + assert (await backend.drawing_properties_get())["custom"] == {} + path = str(tmp_path / "clean.dxf") + await backend.drawing_save_as(path) + await backend.drawing_open(path) + assert (await backend.drawing_properties_get())["custom"] == {} + + +async def test_autocad_invalid_keys_are_refused_headlessly_too(backend): + """The headless engine used to write ' BAD ', 'A=B' and 'A;B' without + complaint (and AutoCAD loaded them from the DXF) while the live engine + half-applied the same call; both now refuse before writing.""" + with pytest.raises(ValueError, match="' BAD '"): + await backend.drawing_properties_set(custom={"AAA": "first", " BAD ": "x", "ZZZ": "n"}) + assert (await backend.drawing_properties_get())["custom"] == {} + + +async def _backend_for_version(tmp_path, version: str): + import ezdxf + + from backends.ezdxf_backend import EzdxfBackend + + path = tmp_path / f"{version}.dxf" + ezdxf.new(version).saveas(str(path)) + backend = EzdxfBackend() + await backend.connect() + await backend.drawing_open(str(path)) + return backend + + +@pytest.mark.parametrize("version, dxfversion", [("R12", "AC1009"), ("R2000", "AC1015")]) +async def test_custom_properties_before_r2004_are_refused_rather_than_lost( + tmp_path, version, dxfversion +): + """Measured: on an R12 or R2000 document `drawing_properties_set` reported + `ok: True, custom_written: ['PROJECT']`, `drawing_properties_get` echoed + it from memory, and `drawing_save_as` (version kept) wrote nothing -- + ezdxf emits $CUSTOMPROPERTYTAG / $CUSTOMPROPERTY only after $LASTSAVEDBY, + which does not exist before R2004. The header-only write that vanishes on + save, the class f3ff703 removed for CANNOSCALE. Refused up front, before + custom_vars is touched; the reopened file is read back to prove it.""" + backend = await _backend_for_version(tmp_path, version) + try: + assert backend._doc.dxfversion == dxfversion + with pytest.raises(ValueError, match=dxfversion) as excinfo: + await backend.drawing_properties_set(custom={"PROJECT": "X-1", "REV": "B"}) + assert "R2004 or newer" in str(excinfo.value) + assert "PROJECT" in str(excinfo.value) + assert list(backend._doc.header.custom_vars) == [] + assert (await backend.drawing_properties_get())["custom"] == {} + # A delete on the same document is refused for the same reason: it + # would report a key removed that the file could never have held. + with pytest.raises(ValueError, match="R2004 or newer"): + await backend.drawing_properties_set(custom={"PROJECT": None}) + # An empty request touches nothing and is not a write to refuse. + res = await backend.drawing_properties_set(custom={}) + assert res["ok"] is True and res["custom_written"] == [] + + out = tmp_path / f"{version}_out.dxf" + await backend.drawing_save_as(str(out)) + await backend.drawing_open(str(out)) + assert backend._doc.dxfversion == dxfversion + assert (await backend.drawing_properties_get())["custom"] == {} + finally: + await backend.disconnect() + + +async def test_custom_properties_on_r2004_survive_save_and_reopen(tmp_path): + """The oldest version that carries the pairs -- the boundary the refusal + sits on -- round-trips.""" + backend = await _backend_for_version(tmp_path, "R2004") + try: + res = await backend.drawing_properties_set(custom={"PROJECT": "X-1"}) + assert res["custom_written"] == ["PROJECT"] + out = tmp_path / "r2004_out.dxf" + await backend.drawing_save_as(str(out)) + await backend.drawing_open(str(out)) + assert backend._doc.dxfversion == "AC1018" + assert (await backend.drawing_properties_get())["custom"] == {"PROJECT": "X-1"} + finally: + await backend.disconnect() + + async def test_summary_is_refused_headlessly_before_any_write(backend): with pytest.raises(UnsupportedCapabilityError) as excinfo: await backend.drawing_properties_set(summary={"title": "T"}, custom={"LATE": "no"}) @@ -231,3 +349,19 @@ async def test_com_validates_before_touching_activex(com_backend): with pytest.raises(ValueError): await backend.drawing_properties_set(summary={"edition": "1"}) assert info.calls == [] and info.Title == "Old title" + + +async def test_com_refuses_autocad_invalid_keys_before_any_write(com_backend): + """Measured on AutoCAD 2026: `summary={'subject': 'PARTIAL?'}, + custom={'AAA': 'first', ' BAD ': 'x', 'ZZZ': 'never'}` raised an unnamed + COM error from AddCustomInfo(' BAD ') and left subject and AAA written, + ZZZ absent -- a half-applied write reported as an error. The shared + validator now names the key and nothing reaches SummaryInfo.""" + backend, info = com_backend + with pytest.raises(ValueError, match="' BAD '"): + await backend.drawing_properties_set( + summary={"subject": "PARTIAL?"}, + custom={"AAA": "first", " BAD ": "x", "ZZZ": "never"}, + ) + assert info.calls == [] and info.Subject == "" + assert info._custom == [("PROJECT", "X-1"), ("OLD", "1")] From 4fe2d963c01eb9f4539f3c7b2cb086c151ccdcbd Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Mon, 21 Sep 2026 21:37:44 +0300 Subject: [PATCH 11/12] fix(task-16): custom keys mirror AutoCAD's full AddCustomInfo rule, deletes are exempt, keys match case-insensitively The 2b9791f fix mirrored only the characters the first review happened to measure ('=' and ';'), so `custom={'AAA': 'first', 'A:B': 'x', 'ZZZ': 'never'}` still raised from AddCustomInfo('A:B') on the live engine after subject and AAA were applied -- the same half-write for 11 other keys. Re-measured on AutoCAD 2026 by sweeping every character 0x20-0x7E through a scratch document: exactly thirteen printable characters are 'Invalid key' anywhere in a key (" * , / : ; < = > ? \ ` |); control characters, internal spaces, a no-break space and unicode are accepted; leading/trailing whitespace (space, tab, NBSP) is refused. `_CUSTOM_KEY_FORBIDDEN` is now that set, the internal-NBSP refusal (wrong: measured accepted) is gone, and the validator / tool docstrings state the measured rule instead of a closed enumeration that was false. A delete (`{key: None}`) is exempt from the AddCustomInfo syntax rules: RemoveCustomByKey never validates syntax (measured: 'Key not found', never 'Invalid key'), and 2b9791f ran the check before the None branch, so a key an ezdxf script wrote ('A=B', ' PAD ') showed in drawing_properties_get and could never be removed. The line-break rule still applies both ways. Also measured: custom keys are case-insensitive in AutoCAD (AddCustomInfo over an existing case-variant is 'Duplicate key'; SetCustomByKey and RemoveCustomByKey match either spelling and keep the stored one). The COM routing was case-sensitive, so `{'PROJECT': ...}` over 'Project' went to AddCustomInfo and failed mid-write -- the same class. Both engines now match existing keys casefolded (update in place under the stored spelling, never a second tag), and a request naming two case-variants is refused. Tests: parametrized validator sweep over all thirteen characters, the accepted set (control chars, NBSP, unicode, the other printables), the delete exemption headlessly on a DXF carrying such keys and on the fake SummaryInfo, case-variant refusal, and case-insensitive routing against a fake that raises 'Duplicate key' / 'Key not found' like AutoCAD. Confirmed live through the real ComBackend on a scratch document (Close(False)). Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/com_backend.py | 13 ++- backends/contracts/settings.py | 75 ++++++++---- backends/ezdxf_backend.py | 19 ++- server.py | 21 ++-- tests/test_drawing_properties.py | 195 +++++++++++++++++++++++++++++-- 5 files changed, 277 insertions(+), 46 deletions(-) diff --git a/backends/com_backend.py b/backends/com_backend.py index b72d124..7cbc75a 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -4293,22 +4293,29 @@ class ComBackend(AutoCADBackend): def _sync(): info = _acad_doc().SummaryInfo + # AutoCAD's custom keys are case-insensitive (measured on 2026: + # AddCustomInfo("PROJECT") over an existing "Project" raises + # 'Duplicate key'; SetCustomByKey / RemoveCustomByKey match either + # spelling and the stored spelling is kept), so existence is + # checked casefolded — a case-sensitive check routed "PROJECT" to + # AddCustomInfo and failed mid-write after the summary fields and + # the earlier keys were already applied. existing: set[str] = set() for index in range(int(info.NumCustomInfo())): key, _value = info.GetCustomByIndex(index) - existing.add(str(key)) + existing.add(str(key).casefold()) for field, value in written.items(): setattr(info, _SUMMARY_ATTRS[field], value) for key, value in to_write.items(): # AddCustomInfo on an existing key raises in AutoCAD; SetCustomByKey # on a missing one does too — route by what is really there. - if key in existing: + if key.casefold() in existing: info.SetCustomByKey(key, value) else: info.AddCustomInfo(key, value) deleted = [] for key in to_delete: - if key in existing: + if key.casefold() in existing: info.RemoveCustomByKey(key) deleted.append(key) return { diff --git a/backends/contracts/settings.py b/backends/contracts/settings.py index d5b1b3b..0321cdd 100644 --- a/backends/contracts/settings.py +++ b/backends/contracts/settings.py @@ -515,10 +515,14 @@ def _decode_setting(key: str, kind: str, raw: Any) -> Any: SUMMARY_FIELDS = ("title", "subject", "author", "keywords", "comments") -#: Characters AutoCAD's ``SummaryInfo.AddCustomInfo`` rejects inside a custom -#: key with ``Invalid key`` (measured on AutoCAD 2026; internal spaces, tabs -#: and other unicode are accepted). ``\xa0`` is the no-break space. -_CUSTOM_KEY_FORBIDDEN = {"=": "'='", ";": "';'", "\xa0": "a no-break space (U+00A0)"} +#: The printable characters AutoCAD's ``SummaryInfo.AddCustomInfo`` rejects +#: anywhere inside a custom key with ``Invalid key``. Measured on AutoCAD 2026 +#: by sweeping every character 0x20-0x7E through ``AddCustomInfo`` on a scratch +#: document: exactly these thirteen are refused; every other printable ASCII +#: character, every control character (0x01-0x1F, including tab), internal +#: spaces, a no-break space and other unicode are accepted. Leading or +#: trailing whitespace (space, tab or no-break space) is refused separately. +_CUSTOM_KEY_FORBIDDEN = frozenset('"*,/:;<=>?\\`|') def _check_custom_text(key: str, text: str, what: str) -> None: @@ -538,24 +542,30 @@ def _check_custom_text(key: str, text: str, what: str) -> None: ) -def _check_custom_key(key: str) -> None: - """Mirror AutoCAD's own custom-key rules so the live engine cannot refuse - mid-write. Measured: ``AddCustomInfo`` raises ``Invalid key`` for a key - with leading/trailing whitespace and for ``=``, ``;`` or a no-break space - anywhere in it — after the summary fields and earlier keys were already - applied — while the headless engine happily writes the same key.""" +def _check_custom_key_for_write(key: str) -> None: + """Mirror AutoCAD's ``AddCustomInfo`` key syntax so the live engine cannot + refuse mid-write. Measured on AutoCAD 2026: ``Invalid key`` for a key with + leading/trailing whitespace (space, tab, no-break space) or any of + ``_CUSTOM_KEY_FORBIDDEN`` anywhere in it — raised after the summary fields + and the earlier keys were already applied, while the headless engine + writes the same key without complaint. Only a *write* goes through + ``AddCustomInfo``; ``RemoveCustomByKey`` does not validate syntax (measured: + ``Key not found``, never ``Invalid key``), so a delete is not held to these + rules — a key that reached the drawing by other means (a DXF written by + ezdxf, say) must stay removable.""" if key != key.strip(): raise ValueError( f"custom: key {key!r} has leading or trailing whitespace — AutoCAD's " "AddCustomInfo rejects it as 'Invalid key'. Strip the key." ) - for char, name in _CUSTOM_KEY_FORBIDDEN.items(): - if char in key: - raise ValueError( - f"custom: key {key!r} contains {name} — AutoCAD's AddCustomInfo rejects " - "it as 'Invalid key'. Use another separator." - ) - _check_custom_text(key, key, "key") + bad = sorted(set(key) & _CUSTOM_KEY_FORBIDDEN) + if bad: + shown = " ".join(repr(c) for c in bad) + raise ValueError( + f"custom: key {key!r} contains {shown} — AutoCAD's AddCustomInfo rejects " + f"it as 'Invalid key' (refused characters: {''.join(sorted(_CUSTOM_KEY_FORBIDDEN))}). " + "Use another separator." + ) def validate_drawing_properties( @@ -574,11 +584,20 @@ def validate_drawing_properties( enforce or the file format would break on, so the same call cannot succeed on one engine and half-apply on the other (``ValueError`` naming the key): - * a key with leading or trailing whitespace, or containing ``=``, ``;`` or - a no-break space — AutoCAD's ``AddCustomInfo`` rejects these as - ``Invalid key`` mid-write (measured on AutoCAD 2026), after the summary - fields and the earlier keys were already applied; the headless engine - writes them without complaint, and AutoCAD then loads them from the DXF; + * a key to *write* with leading or trailing whitespace, or containing any + of the thirteen characters ``" * , / : ; < = > ? \\ ` |`` — AutoCAD's + ``AddCustomInfo`` rejects exactly these as ``Invalid key`` mid-write + (measured on AutoCAD 2026 over every printable ASCII character), after + the summary fields and the earlier keys were already applied; the + headless engine writes them without complaint, and AutoCAD then loads + them from the DXF. A *delete* (``None``) is exempt: ``RemoveCustomByKey`` + never validates syntax, and such keys do reach drawings by other routes, + so they must stay removable; + * two keys in one request that differ only by case — AutoCAD's custom keys + are case-insensitive (measured: ``AddCustomInfo("PROJECT")`` over an + existing ``Project`` raises ``Duplicate key``; ``SetCustomByKey`` / + ``RemoveCustomByKey`` match either spelling), so the second would fail + mid-write on the live engine and write a second tag headlessly; * a key or value containing a line break (LF or CR) — ezdxf emits custom properties unescaped, one header line per text, so the saved DXF is corrupt and the whole drawing fails to reopen (``DXFStructureError``), @@ -599,13 +618,23 @@ def validate_drawing_properties( raise TypeError("custom: must be an object of {key: text | null}") to_write: dict[str, str] = {} to_delete: list[str] = [] + seen: dict[str, str] = {} # casefolded key -> the spelling seen first for key, value in (custom or {}).items(): if not isinstance(key, str) or not key.strip(): raise TypeError(f"custom: key {key!r} must be a non-empty string") - _check_custom_key(key) + _check_custom_text(key, key, "key") + folded = key.casefold() + if folded in seen: + raise ValueError( + f"custom: keys {seen[folded]!r} and {key!r} differ only by case — " + "AutoCAD's custom keys are case-insensitive (AddCustomInfo raises " + "'Duplicate key'). Mention the key once." + ) + seen[folded] = key if value is None: to_delete.append(key) elif isinstance(value, str): + _check_custom_key_for_write(key) _check_custom_text(key, value, "value") to_write[key] = value else: diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index 5b30d66..de1c5b7 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -6142,15 +6142,26 @@ class EzdxfBackend(AutoCADBackend): "Save as R2004 or newer first." ) custom_vars = doc.header.custom_vars + # AutoCAD's custom keys are case-insensitive (measured on 2026: + # SetCustomByKey("PROJECT") updates an existing "Project" and keeps + # that spelling; a second AddCustomInfo is 'Duplicate key'), and + # ezdxf's CustomVars are plain case-sensitive tuples, so the match + # is done here: an existing tag is updated in place under its + # stored spelling, never duplicated under a second one. + props = custom_vars.properties for key, value in to_write.items(): - if custom_vars.has_tag(key): - custom_vars.replace(key, value) + folded = key.casefold() + hits = [i for i, (tag, _v) in enumerate(props) if tag.casefold() == folded] + if hits: + props[hits[0]] = (props[hits[0]][0], value) else: custom_vars.append(key, value) deleted = [] for key in to_delete: - if custom_vars.has_tag(key): - custom_vars.remove(key, all=True) + folded = key.casefold() + kept = [(tag, v) for tag, v in props if tag.casefold() != folded] + if len(kept) != len(props): + props[:] = kept deleted.append(key) if to_write or deleted: self._mark_dirty() diff --git a/server.py b/server.py index 591bc2b..de11684 100644 --- a/server.py +++ b/server.py @@ -6962,13 +6962,20 @@ async def drawing_properties_set( engine (`capability: dwgprops` — SummaryInfo lives in the DWG, a DXF has no slot for it; custom properties still work headlessly), a non-string value (`TypeError` naming the field or key — nothing is coerced with - `str()`), an empty custom key, a custom key AutoCAD's AddCustomInfo would - reject mid-write (leading/trailing whitespace, `=`, `;`, no-break space — - `ValueError` naming the key, on both engines), a line break in a custom - key or value (it corrupts the DXF on save), and headlessly a document - older than R2004 (`ValueError` — ezdxf only writes the custom-property - header pairs for AC1018+, so they would vanish at save; save as R2004 or - newer first). On the live engine a custom key is added with AddCustomInfo + `str()`), an empty custom key, a custom key to *write* that AutoCAD's + AddCustomInfo would reject mid-write (measured on AutoCAD 2026: leading or + trailing whitespace, or any of the thirteen characters + `" * , / : ; < = > ? \\ ` |` anywhere in the key; internal spaces, tabs + and unicode are fine — `ValueError` naming the key, on both engines; a + delete is exempt because RemoveCustomByKey never validates syntax, so a + key that reached the drawing another way stays removable), two keys in + one request that differ only by case (custom keys are case-insensitive in + AutoCAD), a line break in a custom key or value (it corrupts the DXF on + save), and headlessly a document older than R2004 (`ValueError` — ezdxf + only writes the custom-property header pairs for AC1018+, so they would + vanish at save; save as R2004 or newer first). A key is matched to the + drawing case-insensitively on both engines and keeps its stored spelling + when updated. On the live engine a custom key is added with AddCustomInfo when new and changed with SetCustomByKey when present. """ summary = { diff --git a/tests/test_drawing_properties.py b/tests/test_drawing_properties.py index aaea9df..92e3bbb 100644 --- a/tests/test_drawing_properties.py +++ b/tests/test_drawing_properties.py @@ -10,9 +10,14 @@ instead of losing the write at save). Custom keys and values are validated once, by name, against what would break *either* engine: keys AutoCAD's AddCustomInfo rejects mid-write as -`Invalid key` (measured on AutoCAD 2026: leading/trailing whitespace, `=`, -`;`, no-break space) and line breaks, which ezdxf writes unescaped and -which corrupt the whole DXF. +`Invalid key` (measured on AutoCAD 2026 by sweeping every printable ASCII +character through a scratch document: leading/trailing whitespace and exactly +the thirteen characters `" * , / : ; < = > ? \\ ` |` anywhere in the key -- +internal spaces, tabs, control characters, a no-break space and unicode are +accepted), case-variant duplicates (AutoCAD's keys are case-insensitive: +`Duplicate key`), and line breaks, which ezdxf writes unescaped and which +corrupt the whole DXF. A delete is exempt from the AddCustomInfo syntax rules +(RemoveCustomByKey never validates: `Key not found`, never `Invalid key`). """ from __future__ import annotations @@ -22,7 +27,11 @@ import types import pytest from backends.base import UnsupportedCapabilityError -from backends.contracts.settings import SUMMARY_FIELDS, validate_drawing_properties +from backends.contracts.settings import ( + _CUSTOM_KEY_FORBIDDEN, + SUMMARY_FIELDS, + validate_drawing_properties, +) pytestmark = pytest.mark.asyncio @@ -55,9 +64,15 @@ def test_validation_splits_writes_and_deletes(): # mid-write after the summary and earlier keys were already applied. (None, {" BAD ": "x"}, ValueError, "' BAD '"), (None, {"PROJECT ": "x"}, ValueError, "trailing whitespace"), + (None, {"\tTAB": "x"}, ValueError, "leading or trailing whitespace"), + (None, {"NB\xa0": "x"}, ValueError, "leading or trailing whitespace"), (None, {"A=B": "x"}, ValueError, "'A=B' contains '='"), (None, {"A;B": "x"}, ValueError, "'A;B' contains ';'"), - (None, {"NB\xa0SP": "x"}, ValueError, "no-break space"), + (None, {"A:B/C": "x"}, ValueError, "'A:B/C' contains '/' ':'"), + # Case-variant duplicates: AutoCAD raises 'Duplicate key' on the second. + (None, {"Project": "a", "PROJECT": "b"}, ValueError, "differ only by case"), + (None, {"Project": "a", "PROJECT": None}, ValueError, "differ only by case"), + (None, {"Gr\u00fcn": "a", "GR\u00dcN": "b"}, ValueError, "differ only by case"), # A line break is written unescaped by ezdxf and corrupts the DXF. (None, {"NOTE": "line1\nline2"}, ValueError, "custom['NOTE']: the value"), (None, {"NOTE": "line1\rline2"}, ValueError, "line break"), @@ -71,13 +86,56 @@ def test_validation_refuses_by_name(summary, custom, exc, fragment): assert fragment in str(excinfo.value) -@pytest.mark.parametrize("key", ["IN SP", "T\tAB", "\u00dcn\u00efcode", "X" * 300]) +INVALID_KEY_CHARS = '"*,/:;<=>?\\`|' + + +def test_forbidden_set_is_exactly_the_thirteen_measured_characters(): + assert _CUSTOM_KEY_FORBIDDEN == frozenset(INVALID_KEY_CHARS) + assert len(_CUSTOM_KEY_FORBIDDEN) == 13 + + +@pytest.mark.parametrize("char", sorted(INVALID_KEY_CHARS)) +def test_validation_refuses_every_addcustominfo_invalid_key_character(char): + """Measured on AutoCAD 2026 (`AddCustomInfo(f"A{c}B", "v")` for every + c in 0x20-0x7E on a scratch document): exactly these thirteen raise + `Invalid key`. The earlier fix mirrored only `=` and `;`, so 'A:B', + 'REV/2', 'SIZE, mm', 'A"B' ... still half-applied on the live engine.""" + key = f"A{char}B" + with pytest.raises(ValueError, match="Invalid key") as excinfo: + validate_drawing_properties(None, {key: "x"}) + assert repr(key) in str(excinfo.value) and repr(char) in str(excinfo.value) + + +@pytest.mark.parametrize( + "key", + [ + "IN SP", + "T\tAB", + "\u00dcn\u00efcode", + "X" * 300, + "NB\xa0SP", + "A\x01B", + "A\x1fB", + "A!#$%&'()+-.@[]^_{}~B", + "\u4e2d\u2014\u200b", + ], +) def test_validation_accepts_keys_autocad_accepts(key): - """Measured: internal spaces, tabs, unicode and 300-char keys pass + """Measured: internal spaces, tabs, control characters, a no-break space, + unicode, every other printable ASCII character and 300-char keys pass AddCustomInfo; the validator must not be stricter than AutoCAD.""" assert validate_drawing_properties(None, {key: "v"}) == ({}, {key: "v"}, []) +@pytest.mark.parametrize("key", ["A=B", " PAD ", "A:B", "NB\xa0", 'A"B']) +def test_validation_exempts_a_delete_from_the_addcustominfo_syntax_rules(key): + """A delete never calls AddCustomInfo, and RemoveCustomByKey does not + validate syntax (measured: 'Key not found', never 'Invalid key'), so a key + a DXF script wrote must stay removable. The line-break rule still holds + for both directions (a delete of such a key is pinned refused above).""" + assert validate_drawing_properties(None, {key: None}) == ({}, {}, [key]) + + # ── headless engine ───────────────────────────────────────────────────────── @@ -137,6 +195,60 @@ async def test_autocad_invalid_keys_are_refused_headlessly_too(backend): assert (await backend.drawing_properties_get())["custom"] == {} +async def test_legacy_invalid_syntax_keys_stay_deletable_headlessly(tmp_path): + """Regression of 2b9791f: `_check_custom_key` ran before the `value is + None` branch, so `{'A=B': None}` on a document carrying 'A=B' (any ezdxf + script writes such keys; AutoCAD loads them from the DXF) was refused with + a reason that only applies to AddCustomInfo -- the tool showed a key it + could never remove.""" + import ezdxf + + from backends.ezdxf_backend import EzdxfBackend + + path = tmp_path / "legacy.dxf" + doc = ezdxf.new("R2010") + for tag in ("A=B", "NB\xa0SP", " PAD ", "A:B"): + doc.header.custom_vars.append(tag, "legacy") + doc.saveas(str(path)) + backend = EzdxfBackend() + await backend.connect() + try: + await backend.drawing_open(str(path)) + assert (await backend.drawing_properties_get())["custom"] == { + "A=B": "legacy", + "NB\xa0SP": "legacy", + " PAD ": "legacy", + "A:B": "legacy", + } + res = await backend.drawing_properties_set(custom={"A=B": None, " PAD ": None}) + assert res["custom_deleted"] == ["A=B", " PAD "] + assert (await backend.drawing_properties_get())["custom"] == { + "NB\xa0SP": "legacy", + "A:B": "legacy", + } + # ...but writing such a key is still refused, before anything is touched. + with pytest.raises(ValueError, match="'A:B' contains ':'"): + await backend.drawing_properties_set(custom={"A:B": "new", "OK": "x"}) + assert (await backend.drawing_properties_get())["custom"] == { + "NB\xa0SP": "legacy", + "A:B": "legacy", + } + finally: + await backend.disconnect() + + +async def test_custom_keys_match_case_insensitively_headlessly(backend): + """Mirrors AutoCAD (measured on 2026): a write to 'PROJECT' over an + existing 'Project' updates it under the stored spelling instead of adding + a second tag AutoCAD would call a duplicate; a delete matches either + spelling.""" + await backend.drawing_properties_set(custom={"Project": "X-1", "Rev": "A"}) + res = await backend.drawing_properties_set(custom={"PROJECT": "X-2", "rev": None}) + assert res["custom_written"] == ["PROJECT"] and res["custom_deleted"] == ["rev"] + assert list(backend._doc.header.custom_vars) == [("Project", "X-2")] + assert (await backend.drawing_properties_get())["custom"] == {"Project": "X-2"} + + async def _backend_for_version(tmp_path, version: str): import ezdxf @@ -274,17 +386,35 @@ class _FakeSummaryInfo: self.calls.append(("GetCustomByIndex", index)) return self._custom[index] # pywin32 hands [out] BSTR pairs back as a tuple + # Measured on AutoCAD 2026: the three mutators match keys case-insensitively, + # an existing key keeps its stored spelling, a second Add is 'Duplicate key', + # a Set/Remove of an absent key is 'Key not found'. + + def _index(self, key): + for i, (k, _v) in enumerate(self._custom): + if k.casefold() == key.casefold(): + return i + return None + def AddCustomInfo(self, key, value): self.calls.append(("AddCustomInfo", key, value)) + if self._index(key) is not None: + raise RuntimeError("AutoCAD COM error: Duplicate key") self._custom.append((key, value)) def SetCustomByKey(self, key, value): self.calls.append(("SetCustomByKey", key, value)) - self._custom = [(k, value if k == key else v) for k, v in self._custom] + i = self._index(key) + if i is None: + raise RuntimeError("AutoCAD COM error: Key not found") + self._custom[i] = (self._custom[i][0], value) def RemoveCustomByKey(self, key): self.calls.append(("RemoveCustomByKey", key)) - self._custom = [(k, v) for k, v in self._custom if k != key] + i = self._index(key) + if i is None: + raise RuntimeError("AutoCAD COM error: Key not found") + del self._custom[i] @pytest.fixture @@ -351,6 +481,53 @@ async def test_com_validates_before_touching_activex(com_backend): assert info.calls == [] and info.Title == "Old title" +@pytest.mark.parametrize( + "key", ["A:B", "REV/2", "SIZE, mm", 'A"B', "A\\B", "A", "A|B", "A*B", "A?B"] +) +async def test_com_refuses_every_addcustominfo_invalid_key_before_any_write(com_backend, key): + """Re-measured on AutoCAD 2026 after 2b9791f: `summary={'subject': + 'PARTIAL?'}, custom={'AAA': 'first', 'A:B': 'x', 'ZZZ': 'never'}` still + raised from AddCustomInfo('A:B') and left subject='PARTIAL?', + custom={'AAA': 'first'} -- the same half-write for every character the + first fix did not mirror. Nothing reaches SummaryInfo now.""" + backend, info = com_backend + with pytest.raises(ValueError, match="Invalid key"): + await backend.drawing_properties_set( + summary={"subject": "PARTIAL?"}, + custom={"AAA": "first", key: "x", "ZZZ": "never"}, + ) + assert info.calls == [] and info.Subject == "" + assert info._custom == [("PROJECT", "X-1"), ("OLD", "1")] + + +async def test_com_deletes_a_legacy_invalid_syntax_key(com_backend): + """RemoveCustomByKey does not validate syntax (measured: 'Key not found' + for an absent 'A=B', never 'Invalid key'), so a key AutoCAD loaded from a + DXF is removed, not refused.""" + backend, info = com_backend + info._custom.append(("A=B", "legacy")) + res = await backend.drawing_properties_set(custom={"A=B": None}) + assert res["custom_deleted"] == ["A=B"] + assert ("RemoveCustomByKey", "A=B") in info.calls + assert info._custom == [("PROJECT", "X-1"), ("OLD", "1")] + + +async def test_com_routes_case_variant_keys_to_set_and_remove(com_backend): + """Measured on AutoCAD 2026: AddCustomInfo('PROJECT') over an existing + 'Project' raises 'Duplicate key' -- a case-sensitive existence check + routed it there and failed mid-write. Existence is matched casefolded.""" + backend, info = com_backend + res = await backend.drawing_properties_set(custom={"project": "X-2", "old": None, "New": "n"}) + assert res["custom_written"] == ["New", "project"] and res["custom_deleted"] == ["old"] + mutations = [c for c in info.calls if c[0] not in ("NumCustomInfo", "GetCustomByIndex")] + assert mutations == [ + ("SetCustomByKey", "project", "X-2"), + ("AddCustomInfo", "New", "n"), + ("RemoveCustomByKey", "old"), + ] + assert info._custom == [("PROJECT", "X-2"), ("New", "n")], "stored spelling kept" + + async def test_com_refuses_autocad_invalid_keys_before_any_write(com_backend): """Measured on AutoCAD 2026: `summary={'subject': 'PARTIAL?'}, custom={'AAA': 'first', ' BAD ': 'x', 'ZZZ': 'never'}` raised an unnamed From dda11755cfb918e5e3e0ab1af1c06ae7244cbb83 Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Mon, 21 Sep 2026 22:04:22 +0300 Subject: [PATCH 12/12] fix(task-16): custom keys follow AutoCAD's simple case compare, and on COM AutoCAD routes the write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4fe2d96 matched custom keys with str.casefold(), but AutoCAD's key compare is a simple one-to-one case compare, not Unicode full case folding. Measured on AutoCAD 2026 (AddCustomInfo of the second spelling over the first on a scratch document, Close(False)): Project/PROJECT, Grün/GRÜN, é/É, ÿ/Ÿ, ł/Ł, σ/Σ, я/Я and dž/DŽ are one key ('Duplicate key'), while Straße/STRASSE, ẞ/ß, kelvin/Kelvin-sign, µ/Μ, σς/ΣΣ and Dž/DŽ are two keys (both stored) -- every one of those six is casefold()-equal. So with 'Straße' present, `custom={'BEFORE': 'b', 'STRASSE': 's2', 'AFTER': ...}` was routed to SetCustomByKey, AutoCAD answered 'Key not found', and the request raised with subject and BEFORE applied and AFTER never written -- the half-write class the fix claimed to close; a delete of 'STRASSE' over 'Straße' raised the same way; headlessly 'STRASSE' overwrote the 'Straße' value instead of becoming a second tag; and the validator refused {'Straße', 'STRASSE'} with a reason AutoCAD contradicts. COM no longer routes from a Python approximation. An exact spelling goes to SetCustomByKey; anything else is AddCustomInfo, and its 'Duplicate key' (excepinfo scode -2145386475, measured) falls back to SetCustomByKey. A delete is RemoveCustomByKey, and its 'Key not found' (scode -2145386476) is reported as not deleted. Any other com_error keeps propagating. The scode is matched before the English description because the HRESULT text is localised. The headless engine and the validator match on `custom_key_fold`: a per-character fold to lowercase only when lower()/upper() are single characters that round-trip and the character is one of the two; it never changes string length and reproduces every measured pair (ß, ς, µ, the Kelvin sign, Dž, ı and İ stay as they are). Docstrings in settings.py and server.py now state that rule instead of 'case-insensitive'. Tests: parametrized fold over the measured SAME / DISTINCT table (plus a pin that casefold() merges the six DISTINCT pairs, which is why 4fe2d96 regressed), validator acceptance of each DISTINCT pair, headless side-by-side storage and deletion of each pair, the fake SummaryInfo now matches by the same fold and raises pywintypes.com_error with the measured excepinfo tuples, the COM half-write scenario for each pair (no SetCustomByKey guess, 'Key not found' delete tolerated, rest of the request lands), the 'Duplicate key' fallback, and an unrelated SummaryInfo failure still raising. The scodes were measured live before the change; AutoCAD stopped answering COM (blocked in another agent's command or a modal dialog) before the post-change re-run, so that run is recorded as not done. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/com_backend.py | 61 +++++++--- backends/contracts/settings.py | 55 +++++++-- backends/ezdxf_backend.py | 21 ++-- server.py | 19 +-- tests/test_drawing_properties.py | 196 ++++++++++++++++++++++++++++--- 5 files changed, 297 insertions(+), 55 deletions(-) diff --git a/backends/com_backend.py b/backends/com_backend.py index 7cbc75a..72aea53 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -199,6 +199,27 @@ _BUILTIN_LINETYPES = {"continuous", "bylayer", "byblock"} #: `drawing_properties_*` field → `IAcadSummaryInfo` property. _SUMMARY_ATTRS = {field: field.capitalize() for field in SUMMARY_FIELDS} +#: `IAcadSummaryInfo` failure codes, as the `scode` in a `com_error`'s +#: excepinfo (measured on AutoCAD 2026; the description next to them is +#: English while the HRESULT text is localised, so the code is matched first). +_SUMMARYINFO_DUPLICATE_KEY = -2145386475 # AddCustomInfo: 'Duplicate key' +_SUMMARYINFO_KEY_NOT_FOUND = -2145386476 # Set/RemoveCustomByKey: 'Key not found' + + +def _summaryinfo_error_is(exc: BaseException, scode: int, description: str) -> bool: + """True when `exc` is the `IAcadSummaryInfo` failure `scode` / `description`. + + `com_error.args[2]` is the excepinfo tuple ``(wCode, source, description, + helpfile, helpcontext, scode)``; a missing or foreign excepinfo is never a + match, so an unrelated COM failure keeps propagating. + """ + info = exc.args[2] if len(exc.args) > 2 else None + if not isinstance(info, tuple): + return False + code = info[5] if len(info) > 5 else None + desc = str(info[2] or "") if len(info) > 2 else "" + return code == scode or desc.strip().casefold() == description.casefold() + def _ensure_linetype_loaded(name: str) -> None: """If `name` is not already loaded, load it via -LINETYPE behind FILEDIA=0. @@ -4293,31 +4314,43 @@ class ComBackend(AutoCADBackend): def _sync(): info = _acad_doc().SummaryInfo - # AutoCAD's custom keys are case-insensitive (measured on 2026: - # AddCustomInfo("PROJECT") over an existing "Project" raises - # 'Duplicate key'; SetCustomByKey / RemoveCustomByKey match either - # spelling and the stored spelling is kept), so existence is - # checked casefolded — a case-sensitive check routed "PROJECT" to - # AddCustomInfo and failed mid-write after the summary fields and - # the earlier keys were already applied. + # AutoCAD matches custom keys by its own simple per-character case + # compare (measured on 2026: AddCustomInfo("PROJECT") over an + # existing "Project" is 'Duplicate key', yet "Straße" and + # "STRASSE" are two keys -- Python's casefold() merges them, and a + # routing built on it sent "STRASSE" to SetCustomByKey, which + # failed 'Key not found' after the summary fields and the earlier + # keys were already applied). So only an exact spelling is routed + # from here; for everything else AutoCAD decides: AddCustomInfo's + # 'Duplicate key' means the key is there under another spelling + # and it is set instead, RemoveCustomByKey's 'Key not found' means + # it is not there and the delete is reported as not done. No + # routing guess can then half-write. existing: set[str] = set() for index in range(int(info.NumCustomInfo())): key, _value = info.GetCustomByIndex(index) - existing.add(str(key).casefold()) + existing.add(str(key)) for field, value in written.items(): setattr(info, _SUMMARY_ATTRS[field], value) for key, value in to_write.items(): - # AddCustomInfo on an existing key raises in AutoCAD; SetCustomByKey - # on a missing one does too — route by what is really there. - if key.casefold() in existing: + if key in existing: info.SetCustomByKey(key, value) - else: + continue + try: info.AddCustomInfo(key, value) + except _COM_ERROR as exc: + if not _summaryinfo_error_is(exc, _SUMMARYINFO_DUPLICATE_KEY, "Duplicate key"): + raise + info.SetCustomByKey(key, value) deleted = [] for key in to_delete: - if key.casefold() in existing: + try: info.RemoveCustomByKey(key) - deleted.append(key) + except _COM_ERROR as exc: + if not _summaryinfo_error_is(exc, _SUMMARYINFO_KEY_NOT_FOUND, "Key not found"): + raise + continue + deleted.append(key) return { "ok": True, "summary_written": sorted(written), diff --git a/backends/contracts/settings.py b/backends/contracts/settings.py index 0321cdd..a621b59 100644 --- a/backends/contracts/settings.py +++ b/backends/contracts/settings.py @@ -525,6 +525,43 @@ SUMMARY_FIELDS = ("title", "subject", "author", "keywords", "comments") _CUSTOM_KEY_FORBIDDEN = frozenset('"*,/:;<=>?\\`|') +def custom_key_fold(key: str) -> str: + """Fold a custom-property key the way AutoCAD compares them. + + AutoCAD's ``AddCustomInfo`` / ``SetCustomByKey`` / ``RemoveCustomByKey`` + match keys by a *simple* one-to-one case compare, not Unicode full case + folding. Measured on AutoCAD 2026 (``AddCustomInfo`` of the second + spelling over the first): ``Project``/``PROJECT``, ``Grün``/``GRÜN``, + ``é``/``É``, ``ÿ``/``Ÿ``, ``ł``/``Ł``, ``σ``/``Σ``, ``я``/``Я`` and + ``dž``/``DŽ`` are the same key (``Duplicate key``), while ``Straße``/ + ``STRASSE``, ``ẞ``/``ß``, ``kelvin``/``Kelvin`` (Kelvin sign), ``µ``/``Μ``, + ``σς``/``ΣΣ`` and ``Dž``/``DŽ`` are distinct (both stored) — every one of + which Python's ``str.casefold()`` calls equal. So this folds each character + to its lowercase only when the mapping is a single character that + round-trips (``lower().upper() == upper()``, ``upper().lower() == lower()`` + and the character is one of the two); anything else — ``ß`` (uppercases + to ``SS``), the final sigma ``ς``, the micro sign ``µ``, the Kelvin sign, + the title-case digraph ``Dž``, dotless ``ı`` and dotted ``İ`` — is kept as + is. The result never changes length. This is what the headless engine and + the validator match on; the live engine lets AutoCAD decide (see + ``ComBackend.drawing_properties_set``). + """ + out: list[str] = [] + for c in key: + lo, up = c.lower(), c.upper() + if ( + len(lo) == 1 + and len(up) == 1 + and lo.upper() == up + and up.lower() == lo + and c in (lo, up) + ): + out.append(lo) + else: + out.append(c) + return "".join(out) + + def _check_custom_text(key: str, text: str, what: str) -> None: """A newline in a custom key or value corrupts the DXF on save. @@ -593,11 +630,12 @@ def validate_drawing_properties( them from the DXF. A *delete* (``None``) is exempt: ``RemoveCustomByKey`` never validates syntax, and such keys do reach drawings by other routes, so they must stay removable; - * two keys in one request that differ only by case — AutoCAD's custom keys - are case-insensitive (measured: ``AddCustomInfo("PROJECT")`` over an - existing ``Project`` raises ``Duplicate key``; ``SetCustomByKey`` / - ``RemoveCustomByKey`` match either spelling), so the second would fail - mid-write on the live engine and write a second tag headlessly; + * two keys in one request that AutoCAD would treat as the same key — its + key compare is a simple one-to-one case compare (``custom_key_fold``; + measured: ``AddCustomInfo("PROJECT")`` over an existing ``Project`` + raises ``Duplicate key``, while ``Straße`` and ``STRASSE`` are two + keys), so the second would fail mid-write on the live engine and write + a second tag headlessly; * a key or value containing a line break (LF or CR) — ezdxf emits custom properties unescaped, one header line per text, so the saved DXF is corrupt and the whole drawing fails to reopen (``DXFStructureError``), @@ -618,16 +656,17 @@ def validate_drawing_properties( raise TypeError("custom: must be an object of {key: text | null}") to_write: dict[str, str] = {} to_delete: list[str] = [] - seen: dict[str, str] = {} # casefolded key -> the spelling seen first + seen: dict[str, str] = {} # folded key (AutoCAD's rule) -> the spelling seen first for key, value in (custom or {}).items(): if not isinstance(key, str) or not key.strip(): raise TypeError(f"custom: key {key!r} must be a non-empty string") _check_custom_text(key, key, "key") - folded = key.casefold() + folded = custom_key_fold(key) if folded in seen: raise ValueError( f"custom: keys {seen[folded]!r} and {key!r} differ only by case — " - "AutoCAD's custom keys are case-insensitive (AddCustomInfo raises " + "AutoCAD compares custom keys with a simple per-character case " + "compare and calls these the same key (AddCustomInfo raises " "'Duplicate key'). Mention the key once." ) seen[folded] = key diff --git a/backends/ezdxf_backend.py b/backends/ezdxf_backend.py index de1c5b7..b17eed4 100644 --- a/backends/ezdxf_backend.py +++ b/backends/ezdxf_backend.py @@ -41,6 +41,7 @@ from .base import ( ) from .contracts.settings import ( SUMMARY_FIELDS, + custom_key_fold, parse_scale, scale_value, validate_drawing_properties, @@ -6142,24 +6143,26 @@ class EzdxfBackend(AutoCADBackend): "Save as R2004 or newer first." ) custom_vars = doc.header.custom_vars - # AutoCAD's custom keys are case-insensitive (measured on 2026: - # SetCustomByKey("PROJECT") updates an existing "Project" and keeps - # that spelling; a second AddCustomInfo is 'Duplicate key'), and - # ezdxf's CustomVars are plain case-sensitive tuples, so the match - # is done here: an existing tag is updated in place under its + # AutoCAD compares custom keys with a simple per-character case + # compare (measured on 2026: SetCustomByKey("PROJECT") updates an + # existing "Project" and keeps that spelling, a second AddCustomInfo + # is 'Duplicate key' -- but 'Straße' and 'STRASSE' are two keys, + # which str.casefold() would merge), and ezdxf's CustomVars are + # plain case-sensitive tuples, so the match is done here with + # custom_key_fold: an existing tag is updated in place under its # stored spelling, never duplicated under a second one. props = custom_vars.properties for key, value in to_write.items(): - folded = key.casefold() - hits = [i for i, (tag, _v) in enumerate(props) if tag.casefold() == folded] + folded = custom_key_fold(key) + hits = [i for i, (tag, _v) in enumerate(props) if custom_key_fold(tag) == folded] if hits: props[hits[0]] = (props[hits[0]][0], value) else: custom_vars.append(key, value) deleted = [] for key in to_delete: - folded = key.casefold() - kept = [(tag, v) for tag, v in props if tag.casefold() != folded] + folded = custom_key_fold(key) + kept = [(tag, v) for tag, v in props if custom_key_fold(tag) != folded] if len(kept) != len(props): props[:] = kept deleted.append(key) diff --git a/server.py b/server.py index de11684..7478fcb 100644 --- a/server.py +++ b/server.py @@ -6969,14 +6969,17 @@ async def drawing_properties_set( and unicode are fine — `ValueError` naming the key, on both engines; a delete is exempt because RemoveCustomByKey never validates syntax, so a key that reached the drawing another way stays removable), two keys in - one request that differ only by case (custom keys are case-insensitive in - AutoCAD), a line break in a custom key or value (it corrupts the DXF on - save), and headlessly a document older than R2004 (`ValueError` — ezdxf - only writes the custom-property header pairs for AC1018+, so they would - vanish at save; save as R2004 or newer first). A key is matched to the - drawing case-insensitively on both engines and keeps its stored spelling - when updated. On the live engine a custom key is added with AddCustomInfo - when new and changed with SetCustomByKey when present. + one request that AutoCAD would call the same key (its key compare is a + simple per-character case compare: `Project`/`PROJECT` and `Grün`/`GRÜN` + are one key, `Straße`/`STRASSE` are two), a line break in a custom key or + value (it corrupts the DXF on save), and headlessly a document older than + R2004 (`ValueError` — ezdxf only writes the custom-property header pairs + for AC1018+, so they would vanish at save; save as R2004 or newer first). + A key is matched to the drawing by that same rule on both engines and + keeps its stored spelling when updated. On the live engine an exact + spelling is changed with SetCustomByKey; otherwise AutoCAD decides — + AddCustomInfo, and on its 'Duplicate key' SetCustomByKey; a delete is + RemoveCustomByKey, and its 'Key not found' is reported as not deleted. """ summary = { "title": title, diff --git a/tests/test_drawing_properties.py b/tests/test_drawing_properties.py index 92e3bbb..b625c7c 100644 --- a/tests/test_drawing_properties.py +++ b/tests/test_drawing_properties.py @@ -14,8 +14,10 @@ Custom keys and values are validated once, by name, against what would break character through a scratch document: leading/trailing whitespace and exactly the thirteen characters `" * , / : ; < = > ? \\ ` |` anywhere in the key -- internal spaces, tabs, control characters, a no-break space and unicode are -accepted), case-variant duplicates (AutoCAD's keys are case-insensitive: -`Duplicate key`), and line breaks, which ezdxf writes unescaped and which +accepted), two keys AutoCAD would call one key (its key compare is a simple +per-character case compare -- `Project`/`PROJECT` collide with `Duplicate +key`, `Stra\u00dfe`/`STRASSE` are both stored; `custom_key_fold` pins the +measured table), and line breaks, which ezdxf writes unescaped and which corrupt the whole DXF. A delete is exempt from the AddCustomInfo syntax rules (RemoveCustomByKey never validates: `Key not found`, never `Invalid key`). """ @@ -30,6 +32,7 @@ from backends.base import UnsupportedCapabilityError from backends.contracts.settings import ( _CUSTOM_KEY_FORBIDDEN, SUMMARY_FIELDS, + custom_key_fold, validate_drawing_properties, ) @@ -136,6 +139,64 @@ def test_validation_exempts_a_delete_from_the_addcustominfo_syntax_rules(key): assert validate_drawing_properties(None, {key: None}) == ({}, {}, [key]) +# Measured on AutoCAD 2026 (AddCustomInfo of the second spelling over the +# first on a scratch Documents.Add() document, Close(False)): SAME is +# 'Duplicate key', DISTINCT is both stored. Every DISTINCT pair is equal under +# Python's str.casefold(), which is why a casefold() routing sent 'STRASSE' to +# SetCustomByKey over 'Stra\u00dfe' and failed 'Key not found' mid-write. +SAME_KEY_PAIRS = [ + ("Project", "PROJECT"), + ("Gr\u00fcn", "GR\u00dcN"), + ("\u00e9", "\u00c9"), # é / É + ("\u00ff", "\u0178"), # ÿ / Ÿ + ("\u0142", "\u0141"), # ł / Ł + ("\u03c3", "\u03a3"), # σ / Σ + ("\u044f", "\u042f"), # я / Я + ("\u01c6", "\u01c4"), # dž / DŽ + ("i", "I"), +] +# The six pairs str.casefold() merges (measured DISTINCT in AutoCAD). +CASEFOLD_MERGED_PAIRS = [ + ("Stra\u00dfe", "STRASSE"), # ß uppercases to SS + ("\u1e9e", "\u00df"), # ẞ / ß + ("kelvin", "\u212aelvin"), # k / Kelvin sign + ("\u00b5", "\u039c"), # micro sign / Greek capital mu + ("\u03c3\u03c2", "\u03a3\u03a3"), # σς / ΣΣ (final sigma) + ("\u01c5", "\u01c4"), # Dž / DŽ (title-case digraph) +] +DISTINCT_KEY_PAIRS = CASEFOLD_MERGED_PAIRS + [ + ("\u0131", "I"), # dotless ı / I (the fold is not Turkish-aware either) + ("\u0130", "i"), # dotted İ / i (lower() is two code points) +] + + +@pytest.mark.parametrize("a, b", SAME_KEY_PAIRS) +def test_custom_key_fold_merges_what_autocad_calls_one_key(a, b): + assert custom_key_fold(a) == custom_key_fold(b) + assert len(custom_key_fold(a)) == len(a) and len(custom_key_fold(b)) == len(b) + + +@pytest.mark.parametrize("a, b", DISTINCT_KEY_PAIRS) +def test_custom_key_fold_keeps_what_autocad_stores_twice(a, b): + assert custom_key_fold(a) != custom_key_fold(b) + assert len(custom_key_fold(a)) == len(a) and len(custom_key_fold(b)) == len(b) + + +@pytest.mark.parametrize("a, b", CASEFOLD_MERGED_PAIRS) +def test_casefold_is_the_wrong_rule_for_these(a, b): + """Why 4fe2d96 regressed: every one of these is casefold()-equal.""" + assert a.casefold() == b.casefold() + + +@pytest.mark.parametrize("a, b", DISTINCT_KEY_PAIRS) +def test_validation_accepts_two_keys_autocad_stores_side_by_side(a, b): + """Measured: AddCustomInfo('Stra\u00dfe') then AddCustomInfo('STRASSE') + both succeed, so refusing them as 'differ only by case' was a false + refusal with a false reason. German \u00df keys are realistic title-block + metadata.""" + assert validate_drawing_properties(None, {a: "1", b: "2"}) == ({}, {a: "1", b: "2"}, []) + + # ── headless engine ───────────────────────────────────────────────────────── @@ -249,6 +310,20 @@ async def test_custom_keys_match_case_insensitively_headlessly(backend): assert (await backend.drawing_properties_get())["custom"] == {"Project": "X-2"} +@pytest.mark.parametrize("a, b", DISTINCT_KEY_PAIRS) +async def test_headless_keeps_keys_autocad_keeps_apart(backend, a, b): + """Measured: `custom={'Stra\u00dfe': 's1'}` then `{'STRASSE': 's2'}` gave + custom={'Stra\u00dfe': 's2'} headlessly -- the first value overwritten and + no 'STRASSE' tag -- where AutoCAD holds both. Same for the Kelvin sign.""" + await backend.drawing_properties_set(custom={a: "s1"}) + res = await backend.drawing_properties_set(custom={b: "s2"}) + assert res["custom_written"] == [b] + assert list(backend._doc.header.custom_vars) == [(a, "s1"), (b, "s2")] + res = await backend.drawing_properties_set(custom={b: None}) + assert res["custom_deleted"] == [b] + assert list(backend._doc.header.custom_vars) == [(a, "s1")] + + async def _backend_for_version(tmp_path, version: str): import ezdxf @@ -386,37 +461,55 @@ class _FakeSummaryInfo: self.calls.append(("GetCustomByIndex", index)) return self._custom[index] # pywin32 hands [out] BSTR pairs back as a tuple - # Measured on AutoCAD 2026: the three mutators match keys case-insensitively, - # an existing key keeps its stored spelling, a second Add is 'Duplicate key', - # a Set/Remove of an absent key is 'Key not found'. + # Measured on AutoCAD 2026: the three mutators match keys by AutoCAD's + # simple per-character case compare (`custom_key_fold` pins the table -- + # 'PROJECT' finds 'Project', 'STRASSE' does not find 'Stra\u00dfe'), an + # existing key keeps its stored spelling, a second Add is 'Duplicate key' + # (scode -2145386475), a Set/Remove of an absent key is 'Key not found' + # (scode -2145386476), and a key AddCustomInfo rejects is 'Invalid key' + # (scode -2145386465). The com_error shape is the one pywin32 raised live: + # a localised HRESULT text and the English description in the excepinfo. def _index(self, key): for i, (k, _v) in enumerate(self._custom): - if k.casefold() == key.casefold(): + if custom_key_fold(k) == custom_key_fold(key): return i return None def AddCustomInfo(self, key, value): self.calls.append(("AddCustomInfo", key, value)) + if set(key) & _CUSTOM_KEY_FORBIDDEN: + raise _com_error("Invalid key", -2145386465) if self._index(key) is not None: - raise RuntimeError("AutoCAD COM error: Duplicate key") + raise _com_error("Duplicate key", -2145386475) self._custom.append((key, value)) def SetCustomByKey(self, key, value): self.calls.append(("SetCustomByKey", key, value)) i = self._index(key) if i is None: - raise RuntimeError("AutoCAD COM error: Key not found") + raise _com_error("Key not found", -2145386476) self._custom[i] = (self._custom[i][0], value) def RemoveCustomByKey(self, key): self.calls.append(("RemoveCustomByKey", key)) i = self._index(key) if i is None: - raise RuntimeError("AutoCAD COM error: Key not found") + raise _com_error("Key not found", -2145386476) del self._custom[i] +def _com_error(description, scode): + import pywintypes + + return pywintypes.com_error( + -2147352567, + "\u00d6zel durum olu\u015ftu.", + (0, "AutoCAD.Application", description, "OLE_ERR.CHM", scode, scode), + None, + ) + + @pytest.fixture def com_backend(monkeypatch): pytest.importorskip("win32com.client", reason="pywin32 not installed") @@ -466,10 +559,12 @@ async def test_com_set_writes_summary_and_routes_custom_by_existence(com_backend assert info.Title == "Gearbox" and info.Author == "U. Can" and info.Keywords == "gear" mutations = [c for c in info.calls if c[0] not in ("NumCustomInfo", "GetCustomByIndex")] assert mutations == [ - ("SetCustomByKey", "PROJECT", "X-2"), # existed → set + ("SetCustomByKey", "PROJECT", "X-2"), # exact spelling present → set ("AddCustomInfo", "REV", "B"), # new → add - ("RemoveCustomByKey", "OLD"), # existed → remove; NOPE never touched + ("RemoveCustomByKey", "OLD"), # removed + ("RemoveCustomByKey", "NOPE"), # 'Key not found' → not reported deleted ] + assert info._custom == [("PROJECT", "X-2"), ("REV", "B")] async def test_com_validates_before_touching_activex(com_backend): @@ -512,22 +607,91 @@ async def test_com_deletes_a_legacy_invalid_syntax_key(com_backend): assert info._custom == [("PROJECT", "X-1"), ("OLD", "1")] -async def test_com_routes_case_variant_keys_to_set_and_remove(com_backend): - """Measured on AutoCAD 2026: AddCustomInfo('PROJECT') over an existing - 'Project' raises 'Duplicate key' -- a case-sensitive existence check - routed it there and failed mid-write. Existence is matched casefolded.""" +async def test_com_lets_autocad_route_a_case_variant_key(com_backend): + """Measured on AutoCAD 2026: AddCustomInfo('project') over an existing + 'PROJECT' raises 'Duplicate key' -- a case-sensitive existence check + routed it there and failed mid-write; a casefold() check then routed + 'STRASSE' over 'Stra\u00dfe' to SetCustomByKey and failed the same way. + Now only an exact spelling is routed here; otherwise AutoCAD's own + compare decides: 'Duplicate key' falls back to SetCustomByKey.""" backend, info = com_backend res = await backend.drawing_properties_set(custom={"project": "X-2", "old": None, "New": "n"}) assert res["custom_written"] == ["New", "project"] and res["custom_deleted"] == ["old"] mutations = [c for c in info.calls if c[0] not in ("NumCustomInfo", "GetCustomByIndex")] assert mutations == [ - ("SetCustomByKey", "project", "X-2"), + ("AddCustomInfo", "project", "X-2"), # AutoCAD: 'Duplicate key' + ("SetCustomByKey", "project", "X-2"), # → set under the stored spelling ("AddCustomInfo", "New", "n"), ("RemoveCustomByKey", "old"), ] assert info._custom == [("PROJECT", "X-2"), ("New", "n")], "stored spelling kept" +@pytest.mark.parametrize("stored, written", DISTINCT_KEY_PAIRS) +async def test_com_writes_a_key_autocad_keeps_apart_without_half_writing( + com_backend, stored, written +): + """Measured live: with 'Stra\u00dfe' present, `summary={'subject': 'HALF?'}, + custom={'BEFORE': 'b', 'STRASSE': 's2', 'AFTER': 'never?'}` routed + 'STRASSE' to SetCustomByKey on the casefold() match, AutoCAD answered + 'Key not found', and the request raised with subject and BEFORE applied + and AFTER never written. AddCustomInfo of both spellings succeeds in + AutoCAD; the route must not be guessed from a Python fold.""" + backend, info = com_backend + info._custom.append((stored, "s1")) + # A delete of the other spelling is 'Key not found' in AutoCAD (measured: + # RuntimeError after the request's writes were applied): reported as not + # deleted, no error, and the rest of the request still lands. + res = await backend.drawing_properties_set(custom={written: None, "OLD": None}) + assert res["custom_deleted"] == ["OLD"] and res["ok"] is True + assert info._custom == [("PROJECT", "X-1"), (stored, "s1")] + res = await backend.drawing_properties_set( + summary={"subject": "HALF?"}, + custom={"BEFORE": "b", written: "s2", "AFTER": "never?"}, + ) + assert res["custom_written"] == sorted(["BEFORE", written, "AFTER"]) + assert info.Subject == "HALF?" + assert info._custom == [ + ("PROJECT", "X-1"), + (stored, "s1"), + ("BEFORE", "b"), + (written, "s2"), + ("AFTER", "never?"), + ] + assert ("SetCustomByKey", written, "s2") not in info.calls + # Each spelling is now its own key: deleting one keeps the other. + res = await backend.drawing_properties_set(custom={written: None}) + assert res["custom_deleted"] == [written] + assert (written, "s2") not in info._custom and (stored, "s1") in info._custom + + +async def test_com_propagates_an_unrelated_summaryinfo_failure(com_backend): + """Only 'Duplicate key' is answered with SetCustomByKey and only 'Key not + found' is swallowed on a delete; any other COM failure keeps raising (the + fixture runs the COM callable inline, so the raw com_error is what + surfaces here; `_run` wraps it in RuntimeError on the real path).""" + import pywintypes + + backend, info = com_backend + + def _boom(key, value): + info.calls.append(("AddCustomInfo", key, value)) + raise _com_error("Some other failure", -2145386400) + + info.AddCustomInfo = _boom + with pytest.raises(pywintypes.com_error, match="Some other failure"): + await backend.drawing_properties_set(custom={"NEW": "x"}) + assert ("SetCustomByKey", "NEW", "x") not in info.calls + + def _boom_remove(key): + info.calls.append(("RemoveCustomByKey", key)) + raise _com_error("Some other failure", -2145386400) + + info.RemoveCustomByKey = _boom_remove + with pytest.raises(pywintypes.com_error, match="Some other failure"): + await backend.drawing_properties_set(custom={"OLD": None}) + + async def test_com_refuses_autocad_invalid_keys_before_any_write(com_backend): """Measured on AutoCAD 2026: `summary={'subject': 'PARTIAL?'}, custom={'AAA': 'first', ' BAD ': 'x', 'ZZZ': 'never'}` raised an unnamed