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