From f7528b2fa570114903d36c49cbe3341fa1d5530b Mon Sep 17 00:00:00 2001 From: Umut CAN Date: Tue, 22 Sep 2026 12:25:11 +0300 Subject: [PATCH] fix(com): every system variable is read and written on the ActiveX document, not the Application AcadApplication has no GetVariable/SetVariable members; they live on AcadDocument. Measured on the running AutoCAD 2026 (25.1s, 2026-09-22, scratch Documents.Add(), Close(False)): hasattr(app, 'GetVariable') -> False hasattr(app, 'SetVariable') -> False app.GetVariable('INSUNITS') -> AttributeError: ... IAcadApplication instance ... has no attribute 'GetVariable' doc.GetVariable('INSUNITS') -> 4 doc.GetVariable('MEASUREMENT') -> 1 doc.GetVariable('FILEDIA') -> 1; doc.SetVariable('FILEDIA', 0) -> None, reads back 0, restored to 1 doc.GetVariable('AUDITCTL') -> 0, 'CMDACTIVE' -> 0, 'USERS1' -> '' Fifteen call sites in backends/com_backend.py still went through the Application inside `try/except ... log.debug`, so on a live seat they silently degraded: MEASUREMENT defaulted every seat to metric, drawing_info reported units from a failed INSUNITS read, drawing_audit never set AUDITCTL, the CMDACTIVE guards of system_run_command / system_run_lisp never fired and run_lisp never read USERS1 back. All are rerouted through `_acad_doc()`; the try/except stays only where an optional read is defaulted (INSUNITS -> "Unknown", AUDITCTL absent on a host that lacks it, USERS1 after the expression ran) and the log line says so. Linetype loading, measured the same way: `SendCommand("_-LINETYPE _LOAD CENTER acadiso.lin\n\n")` loads nothing with FILEDIA=0 and leaves the command at `Enter an option [?/Create/Load/Set]:` (LASTPROMPT), which makes the whole seat answer RPC_E_CALL_REJECTED to every client until ESC is pressed; with FILEDIA=1 it opens the modal "Select Linetype File" dialog. `doc.Linetypes.Load("CENTER", "acadiso.lin")` returns None and the table grows 3 -> 4; a repeat raises 'Duplicate record name', an unknown name 'Undefined linetype', a missing file 'File system error'. `_ensure_linetype_loaded` and `linetype_load` now call `Linetypes.Load` (MEASUREMENT still read on the document to pick the .lin), touch no FILEDIA, and report a failure with the English excepinfo description. `apply_standard` reads every variable it will write before the first style is created, so a read the engine refuses is a refusal with nothing written (it used to fail after ISO-25 / ISOCP were created and current). Fakes: every application fake refuses GetVariable/SetVariable (tests/test_styles.py's COM fixture carried them on the Application, enshrining the wrong shape); the members exist on document fakes only. Regression tests per rerouted site: linetype load via Linetypes.Load with MEASUREMENT on the document, drawing_info units from the document, AUDITCTL set/restored on the document, CMDACTIVE/USERS1 on the document, apply_standard refusing before any write. Live end-to-end with the real ComBackend on a scratch document: apply_standard('iso') -> INSUNITS 4, ISO-25 (already in acadiso.dwt, created: false) and ISOCP (created: true) current, second call created: false / changed: {}; linetype_load('CENTER') -> Linetypes 3 -> 4, FILEDIA stays 1, repeat already_loaded, 'NOSUCHLT' refused by name; _ensure_linetype_loaded('HIDDEN') -> 3 -> 4; drawing_info units 'mm'; drawing_audit AUDITCTL 0 -> 0; system_run_command ok; system_run_lisp('(+ 40 2)') -> '42'. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd --- backends/com_backend.py | 178 +++++++++++---------- engineering/standards/apply.py | 20 ++- security.py | 8 +- server.py | 16 +- tests/test_com_backend.py | 283 +++++++++++++++++++++++++++++++++ tests/test_com_sprint3.py | 59 +++++-- tests/test_styles.py | 71 ++++++++- tests/test_symbol_names.py | 11 +- tests/test_sysvar_catalog.py | 4 +- 9 files changed, 531 insertions(+), 119 deletions(-) diff --git a/backends/com_backend.py b/backends/com_backend.py index 0e60561..b26eba3 100644 --- a/backends/com_backend.py +++ b/backends/com_backend.py @@ -365,41 +365,74 @@ def _summaryinfo_error_is(exc: BaseException, scode: int, description: str) -> b return code == scode or desc.strip().casefold() == description.casefold() +def _com_error_description(exc: BaseException) -> str: + """The English excepinfo description of a `com_error`, else ``str(exc)``. + + The HRESULT text is localised (``'Özel durum oluştu.'`` on a Turkish + seat) while the excepinfo description is AutoCAD's own English message + (``'Undefined linetype'``, ``'File system error'``), so the latter is + what a caller can act on. + """ + info = exc.args[2] if len(exc.args) > 2 else None + if isinstance(info, tuple) and len(info) > 2 and info[2]: + return str(info[2]).strip() + return str(exc) + + +def _default_lin_file(doc) -> str: + """``acadiso.lin`` on a metric seat, ``acad.lin`` on an imperial one. + + MEASUREMENT is a document system variable: ``GetVariable`` is a member + of AcadDocument and the Application has none (measured on AutoCAD 2026, + ``hasattr(app, "GetVariable") is False``). Read through the Application + inside a try/except this always failed and every seat was "metric"; a + read the document itself refuses is an error now, not a silent default. + """ + return "acadiso.lin" if int(doc.GetVariable("MEASUREMENT")) == 1 else "acad.lin" + + +def _load_linetype(doc, name: str, lin_file: str) -> None: + """``Linetypes.Load(name, file)`` — the ActiveX member for the job. + + The previous route, ``SendCommand("_-LINETYPE _LOAD \\n\\n")`` + behind FILEDIA=0, was measured on AutoCAD 2026 (2026-09-22, scratch + document): with FILEDIA=0 the macro loads nothing and leaves ``-LINETYPE`` + at ``Enter an option [?/Create/Load/Set]:`` — the whole seat then rejects + every COM call (RPC_E_CALL_REJECTED) until someone presses ESC — and with + FILEDIA=1 it opens the modal "Select Linetype File" dialog. ``Load`` + returns at once, touches no system variable, and raises a `com_error` + whose excepinfo names the cause: ``'Undefined linetype'`` for a name the + file does not carry, ``'File system error'`` for a file that is not on + the support path, ``'Duplicate record name'`` for one already loaded + (the callers check the table first, so that one is not reached). + """ + try: + doc.Linetypes.Load(name, lin_file) + except Exception as exc: + raise RuntimeError( + f"Failed to load linetype '{name}' from '{lin_file}': " + f"{_com_error_description(exc)}. Check the linetype name spelling " + "and that the .lin file is on AutoCAD's support path." + ) from exc + + def _ensure_linetype_loaded(name: str) -> None: - """If `name` is not already loaded, load it via -LINETYPE behind FILEDIA=0. + """If `name` is not already loaded, load it with ``Linetypes.Load``. Called by attribute setters so users can write `linetype="CENTER"` without having to remember to load it first. Must run on the COM thread. """ if not name or name.lower() in _BUILTIN_LINETYPES: return - # Before any COM call: this name is about to be interpolated into a - # SendCommand macro, where a newline or ';' would start a second command. + # Before any COM call: a linetype is a symbol-table name, and the DXF + # rule refuses what AutoCAD could never load (and what a SendCommand + # macro would have executed as a second command). name = sanitize_symbol_name(name, kind="linetype") doc = _acad_doc() existing = {doc.Linetypes.Item(i).Name.lower() for i in range(doc.Linetypes.Count)} if name.lower() in existing: return - app = _acad_app() - try: - measurement = int(app.GetVariable("MEASUREMENT")) - except Exception as exc: - log.debug("MEASUREMENT read failed, defaulting to acadiso: %s", exc) - measurement = 1 - lin_file = "acadiso.lin" if measurement == 1 else "acad.lin" - try: - old_filedia = int(app.GetVariable("FILEDIA")) - except Exception as exc: - log.debug("FILEDIA read failed, assuming 1: %s", exc) - old_filedia = 1 - try: - app.SetVariable("FILEDIA", 0) - doc.SendCommand(f"_-LINETYPE _LOAD {name} {lin_file}\n\n") - finally: - try: - app.SetVariable("FILEDIA", old_filedia) - except Exception as exc: - log.debug("FILEDIA restore failed: %s", exc) + _load_linetype(doc, name, _default_lin_file(doc)) def _apply_entity_attrs(entity, layer: str | None, color: int | None, linetype: str | None): @@ -1605,10 +1638,13 @@ class ComBackend(AutoCADBackend): block_count = doc.Blocks.Count unit_map = {0: "Unitless", 1: "Inches", 2: "Feet", 4: "mm", 5: "cm", 6: "m"} + # INSUNITS is read on the document (the Application has no + # GetVariable). ``Unknown`` is the honest default for an optional + # read the document refuses, or a code the map does not carry. try: - units = unit_map.get(int(app.GetVariable("INSUNITS")), "Unknown") + units = unit_map.get(int(doc.GetVariable("INSUNITS")), "Unknown") except Exception as exc: - log.debug("INSUNITS variable read failed: %s", exc) + log.debug("doc.GetVariable(INSUNITS) failed, reporting Unknown: %s", exc) units = "Unknown" return DrawingInfo( @@ -4000,20 +4036,25 @@ class ComBackend(AutoCADBackend): def _sync(): doc = _acad_doc() - app = _acad_app() + # AUDITCTL is a document sysvar (the Application has no + # GetVariable / SetVariable). The read is optional only for a host + # that does not expose the variable at all; then nothing is set + # and nothing needs restoring. try: - previous = app.GetVariable("AUDITCTL") - except Exception: # not every AutoCAD-compatible host exposes it + previous = doc.GetVariable("AUDITCTL") + except Exception as exc: # not every AutoCAD-compatible host exposes it + log.debug("doc.GetVariable(AUDITCTL) failed; AUDIT runs unlogged: %s", exc) previous = None - app.SetVariable("AUDITCTL", 1) + if previous is not None: + doc.SetVariable("AUDITCTL", 1) try: doc.SendCommand("_AUDIT Y\n") finally: if previous is not None: try: - app.SetVariable("AUDITCTL", previous) + doc.SetVariable("AUDITCTL", previous) except Exception: - log.debug("could not restore AUDITCTL", exc_info=True) + log.debug("could not restore AUDITCTL on the document", exc_info=True) # Only report a log we can actually see. SendCommand queues, so at # this point AUDIT has very likely not run yet and the .adt does not @@ -5079,11 +5120,11 @@ class ComBackend(AutoCADBackend): # imperial drawings need acad.lin. We pick automatically from MEASUREMENT # if the caller didn't specify a file. # - # Both arguments end up inside a SendCommand macro, so both are checked - # before the COM thread is entered: the name against the DXF - # symbol-table rule, the path against the same allowlist every other - # file-taking tool uses. This was the one path-taking tool that reached - # the filesystem with neither. + # Both arguments are checked before the COM thread is entered: the + # name against the DXF symbol-table rule, the path against the same + # allowlist every other file-taking tool uses. This was the one + # path-taking tool that reached the filesystem with neither (and, + # until v1.6, both went into a SendCommand macro). safe_name = sanitize_symbol_name(name, kind="linetype") safe_file = None if file is not None: @@ -5091,38 +5132,17 @@ class ComBackend(AutoCADBackend): safe_file = sanitize_macro_argument(str(resolved), kind="linetype file") def _sync(): - app = _acad_app() doc = _acad_doc() existing = {doc.Linetypes.Item(i).Name.lower() for i in range(doc.Linetypes.Count)} if safe_name.lower() in existing: return {"ok": True, "name": safe_name, "already_loaded": True} - if safe_file is None: - try: - measurement = int(app.GetVariable("MEASUREMENT")) - except Exception as exc: - log.debug("MEASUREMENT read failed, defaulting to acadiso: %s", exc) - measurement = 1 - lin_file = "acadiso.lin" if measurement == 1 else "acad.lin" - else: - lin_file = safe_file - - try: - old_filedia = int(app.GetVariable("FILEDIA")) - except Exception as exc: - log.debug("FILEDIA read failed, assuming 1: %s", exc) - old_filedia = 1 - try: - app.SetVariable("FILEDIA", 0) - # Double newline: first ends the file name, second exits - # -LINETYPE's [?/Create/Load/Set] option menu. - doc.SendCommand(f"_-LINETYPE _LOAD {safe_name} {lin_file}\n\n") - finally: - try: - app.SetVariable("FILEDIA", old_filedia) - except Exception as exc: - log.debug("FILEDIA restore failed: %s", exc) + # The default file follows MEASUREMENT, read on the document; the + # load itself is ``Linetypes.Load`` (see _load_linetype for why + # the -LINETYPE macro behind FILEDIA=0 is gone). + lin_file = _default_lin_file(doc) if safe_file is None else safe_file + _load_linetype(doc, safe_name, lin_file) after = {doc.Linetypes.Item(i).Name.lower() for i in range(doc.Linetypes.Count)} if safe_name.lower() not in after: @@ -6412,13 +6432,10 @@ class ComBackend(AutoCADBackend): # otherwise SendCommand returns but AutoCAD stays at a prompt and the # next COM call deadlocks. Example: "_-LINETYPE _LOAD CENTER acad.lin\n\n" def _sync(): - app = _acad_app() doc = _acad_doc() - try: - cmd_active = int(app.GetVariable("CMDACTIVE")) - except Exception as exc: - log.debug("CMDACTIVE read failed, proceeding anyway: %s", exc) - cmd_active = 0 + # CMDACTIVE is read on the document (the Application has no + # GetVariable); through the Application this guard never fired. + cmd_active = int(doc.GetVariable("CMDACTIVE")) if cmd_active: raise RuntimeError( "AutoCAD has an active command or prompt (CMDACTIVE=" @@ -6432,28 +6449,25 @@ class ComBackend(AutoCADBackend): async def system_run_lisp(self, expression) -> dict: def _sync(): - app = _acad_app() doc = _acad_doc() # R23: refuse to send while a command/prompt is active (raw SendCommand - # of a prompting form would deadlock the single STA thread). - try: - if int(app.GetVariable("CMDACTIVE")): - raise RuntimeError( - "AutoCAD has an active command/prompt (CMDACTIVE). " - "Press ESC in AutoCAD and retry." - ) - except RuntimeError: - raise - except Exception as exc: - log.debug("CMDACTIVE read failed, proceeding: %s", exc) + # of a prompting form would deadlock the single STA thread). CMDACTIVE + # is a document sysvar; read through the Application (which has no + # GetVariable) this guard never fired. + if int(doc.GetVariable("CMDACTIVE")): + raise RuntimeError( + "AutoCAD has an active command/prompt (CMDACTIVE). " + "Press ESC in AutoCAD and retry." + ) # N6: SendCommand is void-returning, so the value can't be read from its # return. Stash it in USERS1 and read it back after the form completes # (CMDACTIVE-polled by _safe_send_command). wrapped = f'(vl-load-com)(setvar "USERS1" (vl-princ-to-string (progn {expression})))' self._safe_send_command(doc, wrapped) try: - result = app.GetVariable("USERS1") - except Exception: + result = doc.GetVariable("USERS1") + except Exception as exc: # optional read: the expression already ran + log.debug("doc.GetVariable(USERS1) failed, reporting nil: %s", exc) result = None return { "ok": True, diff --git a/engineering/standards/apply.py b/engineering/standards/apply.py index dd6b30d..6e87880 100644 --- a/engineering/standards/apply.py +++ b/engineering/standards/apply.py @@ -62,11 +62,29 @@ async def apply_standard( Every item reports whether it was created or already present; ``settings`` reports only the variables that moved. An existing style of the standard's name is used as it is — ``dimstyle_modify`` changes one on purpose. + + Every system variable the call will write is read *before* the first + style is created, so a read the engine refuses is a refusal with nothing + written. (The units loop used to run after both styles; on the live + engine its INSUNITS read went through a member the ActiveX Application + does not have, so the call failed with ISO-25 and ISOCP already created + and current.) """ key, row = resolve_standard(standard) if not isinstance(layers, bool) or not isinstance(units, bool): raise TypeError("layers and units must be booleans") + current: dict[str, Any] = {} + if units: + for var, _value in STANDARD_SYSVARS: + try: + current[var] = await backend.system_get_variable(var) + except Exception as exc: + raise RuntimeError( + f"apply_standard: could not read {var} before writing " + f"({exc}); nothing was changed" + ) from exc + text_names = {entry["name"].lower() for entry in await backend.textstyle_list()} textstyle_created = row["textstyle"].lower() not in text_names if textstyle_created: @@ -86,7 +104,7 @@ async def apply_standard( changed: dict[str, list] = {} if units: for var, value in STANDARD_SYSVARS: - old = await backend.system_get_variable(var) + old = current[var] if _same(old, value): continue await backend.system_set_variable(var, value) diff --git a/security.py b/security.py index 453dd45..a284a7a 100644 --- a/security.py +++ b/security.py @@ -365,11 +365,13 @@ def sanitize_lisp(expression: str) -> str: # # The scoped-to-the-CHANNEL argument above assumed `sanitize_command` and # `sanitize_lisp` were the only routes into SendCommand. They were not. -# `_ensure_linetype_loaded` builds `_-LINETYPE _LOAD {name} {file}` from a -# caller's `linetype=` argument, and `ComBackend.linetype_load` adds a +# `_ensure_linetype_loaded` built `_-LINETYPE _LOAD {name} {file}` from a +# caller's `linetype=` argument, and `ComBackend.linetype_load` added a # caller-supplied path. SendCommand takes a whole macro and treats a newline # and `;` as segment separators, so either string could append commands that -# the 36-verb denylist never sees. +# the 36-verb denylist never sees. (v1.6 moved both loaders to the ActiveX +# `Linetypes.Load` member, which takes the name as data; the guard stays, +# because a name this rule refuses was never a loadable linetype either.) # # The guard is the DXF symbol-name rule rather than a second blocklist. AutoCAD # already forbids these characters in a table name, so nothing this rejects was diff --git a/server.py b/server.py index 3635868..1635d7e 100644 --- a/server.py +++ b/server.py @@ -3044,10 +3044,13 @@ async def linetype_load( """Load a single linetype safely. Use this instead of `system_run_command('_-LINETYPE _LOAD ...')` — that - raw form can deadlock on the FILEDIA file-picker dialog and on the - -LINETYPE option-menu prompt. This tool sets FILEDIA=0 around the call, + raw form deadlocks on the FILEDIA file-picker dialog and on the + -LINETYPE option-menu prompt (measured on AutoCAD 2026: it loads nothing + either way). This tool loads through the ActiveX `Linetypes.Load` member, picks the right .lin file from MEASUREMENT, and verifies the linetype - actually loaded. + actually loaded. Refuses a name that is not a valid symbol name, a file + outside the allowed paths, and reports a name the file does not carry + or a file AutoCAD cannot find as an error naming both. """ return await _backend(ctx).linetype_load(name, file) @@ -7300,9 +7303,10 @@ async def drawing_apply_standard( Every item reports `created` or already present; `settings.changed` names only the variables that moved, so a second call reports nothing. An existing ISO-25 / ANSI style is reused as it is, not reset — use - `dimstyle_modify` to change one. Refuses an unknown standard before any - write; the underlying style refusals (`dimstyle_create`, - `textstyle_create`) apply unchanged. In the `lean` profile (Task 13). + `dimstyle_modify` to change one. Refuses an unknown standard, or a units + variable it cannot read back, before any write; the underlying style + refusals (`dimstyle_create`, `textstyle_create`) apply unchanged. In the + `lean` profile (Task 13). """ from engineering.standards.apply import apply_standard diff --git a/tests/test_com_backend.py b/tests/test_com_backend.py index 3033553..07abe1b 100644 --- a/tests/test_com_backend.py +++ b/tests/test_com_backend.py @@ -785,3 +785,286 @@ def test_com_geometry_bbox_is_marked_approximate_when_a_rotated_member_cannot_be rotation=math.radians(30.0), ) assert _com_geometry_bbox(ent)["approximate"] is True + + +# --------------------------------------------------------------------------- +# System variables live on AcadDocument, never on AcadApplication +# --------------------------------------------------------------------------- +# +# Measured on AutoCAD 2026 (25.1s, 2026-09-22, scratch Documents.Add()): +# ``hasattr(app, "GetVariable") is False``, ``hasattr(app, "SetVariable") is +# False``, ``doc.GetVariable("INSUNITS") -> 4``, ``doc.GetVariable("MEASUREMENT") +# -> 1``, ``doc.SetVariable("FILEDIA", 0)`` takes and ``doc.GetVariable`` reads +# it back. Fifteen call sites went through the Application inside a +# ``try/except ... log.debug``, so on a live seat they silently degraded: +# every seat was "metric", ``drawing_info`` reported ``Unknown`` units, AUDIT +# ran with the operator's AUDITCTL. The fakes below model the measured shape +# only — an application with *no* sysvar members — so the old call shape +# cannot pass. +# +# Linetype loading, measured the same day: ``SendCommand("_-LINETYPE _LOAD +# CENTER acadiso.lin\n\n")`` loads nothing with FILEDIA=0 and leaves the +# command at ``Enter an option [?/Create/Load/Set]:`` (LASTPROMPT), which +# makes the seat reject every COM call until ESC; with FILEDIA=1 it opens the +# modal "Select Linetype File" dialog. ``doc.Linetypes.Load("CENTER", +# "acadiso.lin")`` returns None and the table grows; a second call raises +# 'Duplicate record name', an unknown name 'Undefined linetype', a missing +# file 'File system error' (excepinfo descriptions). So the loaders call +# ``Linetypes.Load`` and touch no FILEDIA at all. + + +class _StrictApp: + """The ActiveX Application: ``Version``, ``Documents``, ``ActiveDocument`` + and nothing that reads or writes a system variable.""" + + Version = "25.1s (LMS Tech)" + + def __init__(self, doc): + self.ActiveDocument = doc + self.Documents = SimpleNamespace(Count=1) + + def __getattr__(self, name): + if name in ("GetVariable", "SetVariable"): + raise AttributeError(f"AutoCAD.Application.{name}") + raise AttributeError(name) + + +class _SysvarDoc: + """An AcadDocument that records every sysvar read and write.""" + + def __init__(self, **variables): + self.variables = {"CMDACTIVE": 0, "FILEDIA": 1, "MEASUREMENT": 1, "AUDITCTL": 0} + self.variables.update(variables) + self.calls: list[tuple] = [] + self.sent: list[str] = [] + self.Name = "Drawing1.dwg" + self.FullName = "" + self.Saved = True + self.Database = SimpleNamespace(Extmin=(0.0, 0.0, 0.0), Extmax=(10.0, 20.0, 0.0)) + self.ModelSpace = SimpleNamespace(Count=3) + self.ActiveLayout = SimpleNamespace(Block=self.ModelSpace) + self.Layers = SimpleNamespace(Count=2) + self.Blocks = SimpleNamespace(Count=2) + self.Linetypes = _RecordingLinetypes(self.calls) + + def GetVariable(self, name): + self.calls.append(("GetVariable", name)) + return self.variables[name] + + def SetVariable(self, name, value): + self.calls.append(("SetVariable", name, value)) + self.variables[name] = value + + def SendCommand(self, cmd): + self.calls.append(("SendCommand", cmd, self.variables["FILEDIA"])) + self.sent.append(cmd) + + +def _com_error(description: str, scode: int): + """A pywin32 ``com_error`` shaped like AutoCAD's: localised HRESULT text, + English excepinfo description at index 2 of ``args[2]``.""" + pywintypes = pytest.importorskip("pywintypes") + return pywintypes.com_error( + -2147352567, + "Özel durum oluştu.", + (0, "AutoCAD.Application", description, "OLE_ERR.CHM", scode, scode), + None, + ) + + +class _RecordingLinetypes: + """``doc.Linetypes`` with the measured ``Load`` failures; the .lin files + it knows carry CENTER / HIDDEN / PHANTOM only.""" + + FILES = {"acadiso.lin", "acad.lin", "C:\\lin\\custom.lin"} + KNOWN = {"center", "hidden", "phantom"} + + def __init__(self, calls): + self.names = ["ByLayer", "ByBlock", "Continuous"] + self._calls = calls + + @property + def Count(self): + return len(self.names) + + def Item(self, index): + return SimpleNamespace(Name=self.names[index]) + + def Load(self, name, file): + self._calls.append(("Linetypes.Load", name, file)) + if file not in self.FILES: + raise _com_error("File system error", -2145386425) + if name.lower() in {n.lower() for n in self.names}: + raise _com_error("Duplicate record name", -2145386405) + if name.lower() not in self.KNOWN: + raise _com_error("Undefined linetype", -2145386359) + self.names.append(name.upper()) + + +@pytest.fixture +def sysvar_seat(monkeypatch): + """``(backend, doc)``: a ComBackend whose ``_acad_doc`` is a recording + document and whose ``_acad_app`` refuses every sysvar member.""" + import backends.com_backend as cb + + doc = _SysvarDoc() + app = _StrictApp(doc) + monkeypatch.setattr(cb, "_acad_app", lambda: app) + monkeypatch.setattr(cb, "_acad_doc", lambda: doc) + backend = ComBackend() + + async def _run_inline(func, *args, **kwargs): + return func(*args, **kwargs) + + monkeypatch.setattr(backend, "_run", _run_inline) + return backend, doc + + +def test_ensure_linetype_loaded_reads_measurement_on_the_document_and_calls_load(sysvar_seat): + import backends.com_backend as cb + + _, doc = sysvar_seat + cb._ensure_linetype_loaded("CENTER") + assert doc.calls == [ + ("GetVariable", "MEASUREMENT"), + ("Linetypes.Load", "CENTER", "acadiso.lin"), + ], "MEASUREMENT (on the document) picks the .lin; Linetypes.Load does the loading" + assert "CENTER" in doc.Linetypes.names + assert doc.sent == [], "no -LINETYPE macro: nothing goes through SendCommand" + assert doc.variables["FILEDIA"] == 1 and not [c for c in doc.calls if c[0] == "SetVariable"], ( + "Linetypes.Load needs no FILEDIA, so the operator's setting is never touched" + ) + doc.calls.clear() + cb._ensure_linetype_loaded("center") + assert doc.calls == [], "already loaded (any case): no read, no load" + + +def test_ensure_linetype_loaded_picks_acad_lin_from_the_documents_measurement(sysvar_seat): + import backends.com_backend as cb + + _, doc = sysvar_seat + doc.variables["MEASUREMENT"] = 0 # imperial seat + cb._ensure_linetype_loaded("HIDDEN") + assert doc.calls[-1] == ("Linetypes.Load", "HIDDEN", "acad.lin") + + +def test_ensure_linetype_loaded_refuses_when_the_document_cannot_say_its_measurement(sysvar_seat): + """The old path defaulted a failed MEASUREMENT read to metric; that read + never worked (wrong object), so every seat was metric. A read the + document refuses is an error, and nothing is loaded from a guessed file.""" + import backends.com_backend as cb + + _, doc = sysvar_seat + del doc.variables["MEASUREMENT"] + with pytest.raises(KeyError): + cb._ensure_linetype_loaded("PHANTOM") + assert not [c for c in doc.calls if c[0] == "Linetypes.Load"] + + +def test_ensure_linetype_loaded_names_the_linetype_and_file_when_load_fails(sysvar_seat): + import backends.com_backend as cb + + _, doc = sysvar_seat + with pytest.raises(RuntimeError, match=r"'NOSUCH' from 'acadiso.lin': Undefined linetype"): + cb._ensure_linetype_loaded("NOSUCH") + assert "NOSUCH" not in doc.Linetypes.names + + +@pytest.mark.asyncio +async def test_linetype_load_reads_measurement_on_the_document_and_calls_load(sysvar_seat): + backend, doc = sysvar_seat + result = await backend.linetype_load("CENTER") + assert result == {"ok": True, "name": "CENTER", "file": "acadiso.lin"} + assert doc.calls == [ + ("GetVariable", "MEASUREMENT"), + ("Linetypes.Load", "CENTER", "acadiso.lin"), + ] + assert doc.sent == [] and doc.variables["FILEDIA"] == 1 + again = await backend.linetype_load("center") + assert again == {"ok": True, "name": "center", "already_loaded": True} + + +@pytest.mark.asyncio +async def test_linetype_load_with_an_explicit_file_reads_no_measurement(sysvar_seat, monkeypatch): + import backends.com_backend as cb + + backend, doc = sysvar_seat + monkeypatch.setattr(cb, "validate_path", lambda p: p) # the allowlist is tested elsewhere + result = await backend.linetype_load("HIDDEN", "C:\\lin\\custom.lin") + assert result == {"ok": True, "name": "HIDDEN", "file": "C:\\lin\\custom.lin"} + assert doc.calls == [("Linetypes.Load", "HIDDEN", "C:\\lin\\custom.lin")] + + +@pytest.mark.asyncio +async def test_linetype_load_reports_an_unknown_name_and_a_missing_file_by_name( + sysvar_seat, monkeypatch +): + import backends.com_backend as cb + + backend, doc = sysvar_seat + with pytest.raises(RuntimeError, match=r"'NOSUCH' from 'acadiso.lin': Undefined linetype"): + await backend.linetype_load("NOSUCH") + monkeypatch.setattr(cb, "validate_path", lambda p: p) + with pytest.raises(RuntimeError, match=r"'CENTER' from 'C:\\lin\\nope.lin': File system error"): + await backend.linetype_load("CENTER", "C:\\lin\\nope.lin") + assert doc.Linetypes.names == ["ByLayer", "ByBlock", "Continuous"] + + +@pytest.mark.asyncio +async def test_linetype_load_reports_a_load_that_returned_but_did_not_take(sysvar_seat): + backend, doc = sysvar_seat + doc.Linetypes.Load = lambda name, file: doc.calls.append(("Linetypes.Load", name, file)) + with pytest.raises(RuntimeError, match="Failed to load linetype 'CENTER' from 'acadiso.lin'"): + await backend.linetype_load("CENTER") + + +@pytest.mark.asyncio +async def test_drawing_info_reports_the_documents_insunits(sysvar_seat): + backend, doc = sysvar_seat + doc.variables["INSUNITS"] = 4 + info = await backend.drawing_info() + assert info.units == "mm" + assert ("GetVariable", "INSUNITS") in doc.calls + assert info.version == "25.1s (LMS Tech)" and info.backend == "com" + doc.variables["INSUNITS"] = 1 + assert (await backend.drawing_info()).units == "Inches" + + +@pytest.mark.asyncio +async def test_drawing_info_units_are_unknown_only_when_the_document_cannot_say(sysvar_seat): + """An INSUNITS code outside the map is ``Unknown``; a read the document + refuses is ``Unknown`` too, but that is the document's failure, never the + Application's missing member.""" + backend, doc = sysvar_seat + doc.variables["INSUNITS"] = 14 # microinches: not in the map + assert (await backend.drawing_info()).units == "Unknown" + del doc.variables["INSUNITS"] + assert (await backend.drawing_info()).units == "Unknown" + + +@pytest.mark.asyncio +async def test_drawing_audit_sets_auditctl_on_the_document_and_restores_it(sysvar_seat): + backend, doc = sysvar_seat + result = await backend.drawing_audit() + assert result["ok"] is True and result["repaired"] is None + assert doc.calls == [ + ("GetVariable", "AUDITCTL"), + ("SetVariable", "AUDITCTL", 1), + ("SendCommand", "_AUDIT Y\n", 1), + ("SetVariable", "AUDITCTL", 0), + ], "AUDIT runs with the log on and the operator's AUDITCTL comes back" + assert doc.variables["AUDITCTL"] == 0 + + +@pytest.mark.asyncio +async def test_drawing_audit_restores_auditctl_when_the_command_raises(sysvar_seat): + backend, doc = sysvar_seat + + def _boom(cmd): + raise RuntimeError("SendCommand rejected") + + doc.SendCommand = _boom + with pytest.raises(RuntimeError, match="rejected"): + await backend.drawing_audit() + assert doc.variables["AUDITCTL"] == 0 + assert doc.calls[-1] == ("SetVariable", "AUDITCTL", 0) diff --git a/tests/test_com_sprint3.py b/tests/test_com_sprint3.py index 6012f40..9f27417 100644 --- a/tests/test_com_sprint3.py +++ b/tests/test_com_sprint3.py @@ -73,31 +73,60 @@ async def test_set_variable_coerces_string_to_sysvar_int_type(monkeypatch): # ── R23 / N6 — run_lisp guard + value capture ─────────────────────────────── +class _App: + """The ActiveX Application has no sysvar members (measured, AutoCAD 2026: + ``hasattr(app, "GetVariable") is False``); CMDACTIVE and USERS1 are read + on the document, so this fake refuses the old call shape outright.""" + + def __getattr__(self, name): + raise AttributeError(f"AutoCAD.Application.{name}") + + async def test_run_lisp_refuses_when_cmdactive(monkeypatch): - app = MagicMock() - app.GetVariable.side_effect = lambda n: 1 if n == "CMDACTIVE" else 0 - monkeypatch.setattr(cb, "_acad_app", lambda: app) - monkeypatch.setattr(cb, "_acad_doc", lambda: _Doc()) - - b = _backend_no_executor() - with pytest.raises(RuntimeError): - await b.system_run_lisp("(+ 1 2)") - - -async def test_run_lisp_captures_value_from_users1(monkeypatch): - app = MagicMock() - app.GetVariable.side_effect = lambda n: {"CMDACTIVE": 0, "USERS1": "3"}.get(n, 0) doc = _Doc() - monkeypatch.setattr(cb, "_acad_app", lambda: app) + doc.SetVariable("CMDACTIVE", 1) + monkeypatch.setattr(cb, "_acad_app", lambda: _App()) monkeypatch.setattr(cb, "_acad_doc", lambda: doc) + b = _backend_no_executor() + with pytest.raises(RuntimeError, match="CMDACTIVE"): + await b.system_run_lisp("(+ 1 2)") + assert doc.sent == [], "nothing reached AutoCAD" + + +async def test_run_lisp_captures_value_from_users1(monkeypatch): + doc = _Doc() + monkeypatch.setattr(cb, "_acad_app", lambda: _App()) + monkeypatch.setattr(cb, "_acad_doc", lambda: doc) + + def _send(cmd): + doc.sent.append(cmd) + if "USERS1" in cmd: + doc._vars["USERS1"] = "3" # what (setvar "USERS1" ...) leaves behind + + doc.SendCommand = _send b = _backend_no_executor() out = await b.system_run_lisp("(+ 1 2)") - assert out["result"] == "3" # captured, not the bogus "nil" + assert out["result"] == "3" # captured from the document, not the bogus "nil" assert any("USERS1" in c for c in doc.sent) # value stashed out-of-band +async def test_run_command_reads_cmdactive_on_the_document(monkeypatch): + doc = _Doc() + monkeypatch.setattr(cb, "_acad_app", lambda: _App()) + monkeypatch.setattr(cb, "_acad_doc", lambda: doc) + + b = _backend_no_executor() + out = await b.system_run_command("_ZOOM E") + assert out == {"ok": True, "command": "_ZOOM E"} and doc.sent == ["_ZOOM E\n"] + + doc.SetVariable("CMDACTIVE", 1) + with pytest.raises(RuntimeError, match="CMDACTIVE=1"): + await b.system_run_command("_ZOOM E") + assert doc.sent == ["_ZOOM E\n"], "a busy seat gets nothing new" + + # ── R16 — transaction flag cleared even on error ──────────────────────────── diff --git a/tests/test_styles.py b/tests/test_styles.py index 1c91e5b..1a5aece 100644 --- a/tests/test_styles.py +++ b/tests/test_styles.py @@ -982,6 +982,18 @@ class _FakeDocument: self._active_text = style +class _FakeApplication: + def __init__(self, support): + self.Preferences = types.SimpleNamespace( + Files=types.SimpleNamespace(SupportPath=str(support)) + ) + + def __getattr__(self, name): + if name in ("GetVariable", "SetVariable"): + raise AttributeError(f"AutoCAD.Application.{name}") + raise AttributeError(name) + + @pytest.fixture def com_backend(monkeypatch, tmp_path): pytest.importorskip("win32com.client", reason="pywin32 not installed") @@ -998,11 +1010,12 @@ def com_backend(monkeypatch, tmp_path): (support / font).write_bytes(minimal_sfnt(family, subfamily)) monkeypatch.setattr(_FakeStyle, "support_folder", support) document = _FakeDocument() - app = types.SimpleNamespace( - Preferences=types.SimpleNamespace(Files=types.SimpleNamespace(SupportPath=str(support))), - GetVariable=document.GetVariable, - SetVariable=document.SetVariable, - ) + # The Application carries Preferences and nothing that reads or writes a + # system variable: ``hasattr(app, "GetVariable") is False`` on AutoCAD + # 2026 (measured 2026-09-22). GetVariable / SetVariable are members of + # ``_FakeDocument`` only, so a backend path that still goes through the + # Application fails here the way it fails live. + app = _FakeApplication(support) monkeypatch.setattr(module, "_acad_doc", lambda: document) monkeypatch.setattr(module, "_acad_app", lambda: app) monkeypatch.setattr(module, "_regen", lambda: None) @@ -1708,16 +1721,62 @@ async def test_com_apply_standard_creates_both_styles_and_sets_units(com_backend document.variables.update( {"INSUNITS": 1, "LUNITS": 4, "AUNITS": 0, "LTSCALE": 1.0, "DIMSCALE": 1.0} ) + _record_reads(document) result = await apply_standard(backend, "iso", layers=False) assert result["dimstyle"]["created"] is True and result["textstyle"]["created"] is True assert result["settings"]["changed"] == {"INSUNITS": [1, 4], "LUNITS": [4, 2]} names = [call[0] for call in document.calls] - assert names[0] == "TextStyles.Add" and "DimStyles.Add" in names + assert names[:5] == ["GetVariable"] * 5, "every unit variable is read on the document first" + assert [c[1] for c in document.calls[:5]] == [ + "INSUNITS", + "LUNITS", + "AUNITS", + "LTSCALE", + "DIMSCALE", + ] + assert names[5] == "TextStyles.Add" and "DimStyles.Add" in names assert ("ActiveTextStyle", "ISOCP") in document.calls assert document.ActiveDimStyle.Name == "ISO-25" assert document.variables["DIMDSEP"] == "," and document.variables["INSUNITS"] == 4 +def _record_reads(document): + """Log ``GetVariable`` into ``document.calls`` too, so a test can see the + reads' place among the writes (the fake logs writes only).""" + real_get = document.GetVariable + + def _get(name): + document.calls.append(("GetVariable", name)) + return real_get(name) + + document.GetVariable = _get + + +async def test_com_apply_standard_refuses_before_writing_when_a_sysvar_read_fails(com_backend): + """Regression: the units loop ran *after* both styles were created, and its + INSUNITS read went through ``AcadApplication.GetVariable`` -- a member the + Application does not have -- so on a live seat the call failed with ISO-25 + and ISOCP already written and current. Every variable the call will write + is read first; a read the document refuses is a refusal before any write.""" + from engineering.standards.apply import apply_standard + + backend, document = com_backend + document.variables.update({"INSUNITS": 4, "LUNITS": 2, "AUNITS": 0}) # no LTSCALE + _record_reads(document) + with pytest.raises(RuntimeError, match="LTSCALE"): + await apply_standard(backend, "iso", layers=False) + writes = [c for c in document.calls if c[0] != "GetVariable"] + assert writes == [], "nothing was created, made current or set" + assert [e.Name for e in document.TextStyles.entries] == ["Standard"] + assert [e.Name for e in document.DimStyles.entries] == ["Standard"] + # ``units=False`` writes no variable, so it reads none and needs none. + result = await apply_standard(backend, "iso", layers=False, units=False) + assert result["dimstyle"]["created"] is True and result["settings"] == { + "changed": {}, + "applied": False, + } + + # ── profile decision ──────────────────────────────────────────────────────── diff --git a/tests/test_symbol_names.py b/tests/test_symbol_names.py index 6fdab51..09698c9 100644 --- a/tests/test_symbol_names.py +++ b/tests/test_symbol_names.py @@ -4,13 +4,16 @@ each have exactly ONE caller, both of them the COM-only free-text escape hatch into SendCommand. The policy is scoped to the CHANNEL." That premise was false. -`_ensure_linetype_loaded` builds ``_-LINETYPE _LOAD {name} {lin_file}`` and -hands it to `doc.SendCommand`, and it is reached from the ``linetype=`` +`_ensure_linetype_loaded` built ``_-LINETYPE _LOAD {name} {lin_file}`` and +handed it to `doc.SendCommand`, and it is reached from the ``linetype=`` argument of every COM entity-creation and layer tool — plus -`ComBackend.linetype_load`, which interpolates a caller-supplied *file path* +`ComBackend.linetype_load`, which interpolated a caller-supplied *file path* as well. SendCommand treats a newline and ``;`` as segment separators, so a linetype name carrying either injects further AutoCAD commands, and the 36-verb -denylist and `DANGEROUS_COMMANDS_ENABLED` never see that channel at all. +denylist and `DANGEROUS_COMMANDS_ENABLED` never see that channel at all. (Both +loaders now call the ActiveX ``Linetypes.Load`` member, which takes the name +as data; the guard stays in front of it, since a name the DXF rule refuses was +never a loadable linetype.) The guard here is the DXF symbol-name rule rather than a bespoke blocklist: AutoCAD already forbids these characters in a table name, so anything this diff --git a/tests/test_sysvar_catalog.py b/tests/test_sysvar_catalog.py index e55cd2b..cea197b 100644 --- a/tests/test_sysvar_catalog.py +++ b/tests/test_sysvar_catalog.py @@ -369,7 +369,7 @@ async def test_set_variable_tool_refuses_before_a_live_backend_is_called(monkeyp calls: list[tuple] = [] - class _App: + class _Doc: # the sysvar host is AcadDocument; the Application has no such members def GetVariable(self, name): calls.append(("GetVariable", name)) return 2 @@ -377,7 +377,7 @@ async def test_set_variable_tool_refuses_before_a_live_backend_is_called(monkeyp def SetVariable(self, name, value): calls.append(("SetVariable", name, value)) - monkeypatch.setattr(module, "_acad_app", lambda: _App()) + monkeypatch.setattr(module, "_acad_doc", lambda: _Doc()) backend = module.ComBackend() async def _run_inline(func, *args, **kwargs):