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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
This commit is contained in:
Umut CAN
2026-09-22 12:25:11 +03:00
co-authored by Claude Opus 5
parent 70afd57831
commit f7528b2fa5
9 changed files with 531 additions and 119 deletions
+96 -82
View File
@@ -365,41 +365,74 @@ def _summaryinfo_error_is(exc: BaseException, scode: int, description: str) -> b
return code == scode or desc.strip().casefold() == description.casefold() 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 <name> <file>\\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: 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 Called by attribute setters so users can write `linetype="CENTER"` without
having to remember to load it first. Must run on the COM thread. having to remember to load it first. Must run on the COM thread.
""" """
if not name or name.lower() in _BUILTIN_LINETYPES: if not name or name.lower() in _BUILTIN_LINETYPES:
return return
# Before any COM call: this name is about to be interpolated into a # Before any COM call: a linetype is a symbol-table name, and the DXF
# SendCommand macro, where a newline or ';' would start a second command. # 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") name = sanitize_symbol_name(name, kind="linetype")
doc = _acad_doc() doc = _acad_doc()
existing = {doc.Linetypes.Item(i).Name.lower() for i in range(doc.Linetypes.Count)} existing = {doc.Linetypes.Item(i).Name.lower() for i in range(doc.Linetypes.Count)}
if name.lower() in existing: if name.lower() in existing:
return return
app = _acad_app() _load_linetype(doc, name, _default_lin_file(doc))
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)
def _apply_entity_attrs(entity, layer: str | None, color: int | None, linetype: str | None): 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 block_count = doc.Blocks.Count
unit_map = {0: "Unitless", 1: "Inches", 2: "Feet", 4: "mm", 5: "cm", 6: "m"} 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: try:
units = unit_map.get(int(app.GetVariable("INSUNITS")), "Unknown") units = unit_map.get(int(doc.GetVariable("INSUNITS")), "Unknown")
except Exception as exc: except Exception as exc:
log.debug("INSUNITS variable read failed: %s", exc) log.debug("doc.GetVariable(INSUNITS) failed, reporting Unknown: %s", exc)
units = "Unknown" units = "Unknown"
return DrawingInfo( return DrawingInfo(
@@ -4000,20 +4036,25 @@ class ComBackend(AutoCADBackend):
def _sync(): def _sync():
doc = _acad_doc() 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: try:
previous = app.GetVariable("AUDITCTL") previous = doc.GetVariable("AUDITCTL")
except Exception: # not every AutoCAD-compatible host exposes it except Exception as exc: # not every AutoCAD-compatible host exposes it
log.debug("doc.GetVariable(AUDITCTL) failed; AUDIT runs unlogged: %s", exc)
previous = None previous = None
app.SetVariable("AUDITCTL", 1) if previous is not None:
doc.SetVariable("AUDITCTL", 1)
try: try:
doc.SendCommand("_AUDIT Y\n") doc.SendCommand("_AUDIT Y\n")
finally: finally:
if previous is not None: if previous is not None:
try: try:
app.SetVariable("AUDITCTL", previous) doc.SetVariable("AUDITCTL", previous)
except Exception: 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 # 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 # 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 # imperial drawings need acad.lin. We pick automatically from MEASUREMENT
# if the caller didn't specify a file. # if the caller didn't specify a file.
# #
# Both arguments end up inside a SendCommand macro, so both are checked # Both arguments are checked before the COM thread is entered: the
# before the COM thread is entered: the name against the DXF # name against the DXF symbol-table rule, the path against the same
# symbol-table rule, the path against the same allowlist every other # allowlist every other file-taking tool uses. This was the one
# file-taking tool uses. This was the one path-taking tool that reached # path-taking tool that reached the filesystem with neither (and,
# the filesystem with neither. # until v1.6, both went into a SendCommand macro).
safe_name = sanitize_symbol_name(name, kind="linetype") safe_name = sanitize_symbol_name(name, kind="linetype")
safe_file = None safe_file = None
if file is not None: if file is not None:
@@ -5091,38 +5132,17 @@ class ComBackend(AutoCADBackend):
safe_file = sanitize_macro_argument(str(resolved), kind="linetype file") safe_file = sanitize_macro_argument(str(resolved), kind="linetype file")
def _sync(): def _sync():
app = _acad_app()
doc = _acad_doc() doc = _acad_doc()
existing = {doc.Linetypes.Item(i).Name.lower() for i in range(doc.Linetypes.Count)} existing = {doc.Linetypes.Item(i).Name.lower() for i in range(doc.Linetypes.Count)}
if safe_name.lower() in existing: if safe_name.lower() in existing:
return {"ok": True, "name": safe_name, "already_loaded": True} return {"ok": True, "name": safe_name, "already_loaded": True}
if safe_file is None: # The default file follows MEASUREMENT, read on the document; the
try: # load itself is ``Linetypes.Load`` (see _load_linetype for why
measurement = int(app.GetVariable("MEASUREMENT")) # the -LINETYPE macro behind FILEDIA=0 is gone).
except Exception as exc: lin_file = _default_lin_file(doc) if safe_file is None else safe_file
log.debug("MEASUREMENT read failed, defaulting to acadiso: %s", exc) _load_linetype(doc, safe_name, lin_file)
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)
after = {doc.Linetypes.Item(i).Name.lower() for i in range(doc.Linetypes.Count)} after = {doc.Linetypes.Item(i).Name.lower() for i in range(doc.Linetypes.Count)}
if safe_name.lower() not in after: 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 # otherwise SendCommand returns but AutoCAD stays at a prompt and the
# next COM call deadlocks. Example: "_-LINETYPE _LOAD CENTER acad.lin\n\n" # next COM call deadlocks. Example: "_-LINETYPE _LOAD CENTER acad.lin\n\n"
def _sync(): def _sync():
app = _acad_app()
doc = _acad_doc() doc = _acad_doc()
try: # CMDACTIVE is read on the document (the Application has no
cmd_active = int(app.GetVariable("CMDACTIVE")) # GetVariable); through the Application this guard never fired.
except Exception as exc: cmd_active = int(doc.GetVariable("CMDACTIVE"))
log.debug("CMDACTIVE read failed, proceeding anyway: %s", exc)
cmd_active = 0
if cmd_active: if cmd_active:
raise RuntimeError( raise RuntimeError(
"AutoCAD has an active command or prompt (CMDACTIVE=" "AutoCAD has an active command or prompt (CMDACTIVE="
@@ -6432,28 +6449,25 @@ class ComBackend(AutoCADBackend):
async def system_run_lisp(self, expression) -> dict: async def system_run_lisp(self, expression) -> dict:
def _sync(): def _sync():
app = _acad_app()
doc = _acad_doc() doc = _acad_doc()
# R23: refuse to send while a command/prompt is active (raw SendCommand # R23: refuse to send while a command/prompt is active (raw SendCommand
# of a prompting form would deadlock the single STA thread). # of a prompting form would deadlock the single STA thread). CMDACTIVE
try: # is a document sysvar; read through the Application (which has no
if int(app.GetVariable("CMDACTIVE")): # GetVariable) this guard never fired.
raise RuntimeError( if int(doc.GetVariable("CMDACTIVE")):
"AutoCAD has an active command/prompt (CMDACTIVE). " raise RuntimeError(
"Press ESC in AutoCAD and retry." "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)
# N6: SendCommand is void-returning, so the value can't be read from its # 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 # return. Stash it in USERS1 and read it back after the form completes
# (CMDACTIVE-polled by _safe_send_command). # (CMDACTIVE-polled by _safe_send_command).
wrapped = f'(vl-load-com)(setvar "USERS1" (vl-princ-to-string (progn {expression})))' wrapped = f'(vl-load-com)(setvar "USERS1" (vl-princ-to-string (progn {expression})))'
self._safe_send_command(doc, wrapped) self._safe_send_command(doc, wrapped)
try: try:
result = app.GetVariable("USERS1") result = doc.GetVariable("USERS1")
except Exception: except Exception as exc: # optional read: the expression already ran
log.debug("doc.GetVariable(USERS1) failed, reporting nil: %s", exc)
result = None result = None
return { return {
"ok": True, "ok": True,
+19 -1
View File
@@ -62,11 +62,29 @@ async def apply_standard(
Every item reports whether it was created or already present; ``settings`` Every item reports whether it was created or already present; ``settings``
reports only the variables that moved. An existing style of the standard's 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. 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) key, row = resolve_standard(standard)
if not isinstance(layers, bool) or not isinstance(units, bool): if not isinstance(layers, bool) or not isinstance(units, bool):
raise TypeError("layers and units must be booleans") 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()} text_names = {entry["name"].lower() for entry in await backend.textstyle_list()}
textstyle_created = row["textstyle"].lower() not in text_names textstyle_created = row["textstyle"].lower() not in text_names
if textstyle_created: if textstyle_created:
@@ -86,7 +104,7 @@ async def apply_standard(
changed: dict[str, list] = {} changed: dict[str, list] = {}
if units: if units:
for var, value in STANDARD_SYSVARS: for var, value in STANDARD_SYSVARS:
old = await backend.system_get_variable(var) old = current[var]
if _same(old, value): if _same(old, value):
continue continue
await backend.system_set_variable(var, value) await backend.system_set_variable(var, value)
+5 -3
View File
@@ -365,11 +365,13 @@ def sanitize_lisp(expression: str) -> str:
# #
# The scoped-to-the-CHANNEL argument above assumed `sanitize_command` and # The scoped-to-the-CHANNEL argument above assumed `sanitize_command` and
# `sanitize_lisp` were the only routes into SendCommand. They were not. # `sanitize_lisp` were the only routes into SendCommand. They were not.
# `_ensure_linetype_loaded` builds `_-LINETYPE _LOAD {name} {file}` from a # `_ensure_linetype_loaded` built `_-LINETYPE _LOAD {name} {file}` from a
# caller's `linetype=` argument, and `ComBackend.linetype_load` adds a # caller's `linetype=` argument, and `ComBackend.linetype_load` added a
# caller-supplied path. SendCommand takes a whole macro and treats a newline # caller-supplied path. SendCommand takes a whole macro and treats a newline
# and `;` as segment separators, so either string could append commands that # 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 # 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 # already forbids these characters in a table name, so nothing this rejects was
+10 -6
View File
@@ -3044,10 +3044,13 @@ async def linetype_load(
"""Load a single linetype safely. """Load a single linetype safely.
Use this instead of `system_run_command('_-LINETYPE _LOAD ...')` — that Use this instead of `system_run_command('_-LINETYPE _LOAD ...')` — that
raw form can deadlock on the FILEDIA file-picker dialog and on the raw form deadlocks on the FILEDIA file-picker dialog and on the
-LINETYPE option-menu prompt. This tool sets FILEDIA=0 around the call, -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 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) 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` Every item reports `created` or already present; `settings.changed`
names only the variables that moved, so a second call reports nothing. 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 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 `dimstyle_modify` to change one. Refuses an unknown standard, or a units
write; the underlying style refusals (`dimstyle_create`, variable it cannot read back, before any write; the underlying style
`textstyle_create`) apply unchanged. In the `lean` profile (Task 13). refusals (`dimstyle_create`, `textstyle_create`) apply unchanged. In the
`lean` profile (Task 13).
""" """
from engineering.standards.apply import apply_standard from engineering.standards.apply import apply_standard
+283
View File
@@ -785,3 +785,286 @@ def test_com_geometry_bbox_is_marked_approximate_when_a_rotated_member_cannot_be
rotation=math.radians(30.0), rotation=math.radians(30.0),
) )
assert _com_geometry_bbox(ent)["approximate"] is True 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)
+44 -15
View File
@@ -73,31 +73,60 @@ async def test_set_variable_coerces_string_to_sysvar_int_type(monkeypatch):
# ── R23 / N6 — run_lisp guard + value capture ─────────────────────────────── # ── 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): 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() 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) 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() b = _backend_no_executor()
out = await b.system_run_lisp("(+ 1 2)") 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 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 ──────────────────────────── # ── R16 — transaction flag cleared even on error ────────────────────────────
+65 -6
View File
@@ -982,6 +982,18 @@ class _FakeDocument:
self._active_text = style 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 @pytest.fixture
def com_backend(monkeypatch, tmp_path): def com_backend(monkeypatch, tmp_path):
pytest.importorskip("win32com.client", reason="pywin32 not installed") 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)) (support / font).write_bytes(minimal_sfnt(family, subfamily))
monkeypatch.setattr(_FakeStyle, "support_folder", support) monkeypatch.setattr(_FakeStyle, "support_folder", support)
document = _FakeDocument() document = _FakeDocument()
app = types.SimpleNamespace( # The Application carries Preferences and nothing that reads or writes a
Preferences=types.SimpleNamespace(Files=types.SimpleNamespace(SupportPath=str(support))), # system variable: ``hasattr(app, "GetVariable") is False`` on AutoCAD
GetVariable=document.GetVariable, # 2026 (measured 2026-09-22). GetVariable / SetVariable are members of
SetVariable=document.SetVariable, # ``_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_doc", lambda: document)
monkeypatch.setattr(module, "_acad_app", lambda: app) monkeypatch.setattr(module, "_acad_app", lambda: app)
monkeypatch.setattr(module, "_regen", lambda: None) 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( document.variables.update(
{"INSUNITS": 1, "LUNITS": 4, "AUNITS": 0, "LTSCALE": 1.0, "DIMSCALE": 1.0} {"INSUNITS": 1, "LUNITS": 4, "AUNITS": 0, "LTSCALE": 1.0, "DIMSCALE": 1.0}
) )
_record_reads(document)
result = await apply_standard(backend, "iso", layers=False) result = await apply_standard(backend, "iso", layers=False)
assert result["dimstyle"]["created"] is True and result["textstyle"]["created"] is True assert result["dimstyle"]["created"] is True and result["textstyle"]["created"] is True
assert result["settings"]["changed"] == {"INSUNITS": [1, 4], "LUNITS": [4, 2]} assert result["settings"]["changed"] == {"INSUNITS": [1, 4], "LUNITS": [4, 2]}
names = [call[0] for call in document.calls] 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 ("ActiveTextStyle", "ISOCP") in document.calls
assert document.ActiveDimStyle.Name == "ISO-25" assert document.ActiveDimStyle.Name == "ISO-25"
assert document.variables["DIMDSEP"] == "," and document.variables["INSUNITS"] == 4 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 ──────────────────────────────────────────────────────── # ── profile decision ────────────────────────────────────────────────────────
+7 -4
View File
@@ -4,13 +4,16 @@
each have exactly ONE caller, both of them the COM-only free-text escape hatch 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. into SendCommand. The policy is scoped to the CHANNEL." That premise was false.
`_ensure_linetype_loaded` builds ``_-LINETYPE _LOAD {name} {lin_file}`` and `_ensure_linetype_loaded` built ``_-LINETYPE _LOAD {name} {lin_file}`` and
hands it to `doc.SendCommand`, and it is reached from the ``linetype=`` handed it to `doc.SendCommand`, and it is reached from the ``linetype=``
argument of every COM entity-creation and layer tool — plus 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 as well. SendCommand treats a newline and ``;`` as segment separators, so a
linetype name carrying either injects further AutoCAD commands, and the 36-verb 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: 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 AutoCAD already forbids these characters in a table name, so anything this
+2 -2
View File
@@ -369,7 +369,7 @@ async def test_set_variable_tool_refuses_before_a_live_backend_is_called(monkeyp
calls: list[tuple] = [] calls: list[tuple] = []
class _App: class _Doc: # the sysvar host is AcadDocument; the Application has no such members
def GetVariable(self, name): def GetVariable(self, name):
calls.append(("GetVariable", name)) calls.append(("GetVariable", name))
return 2 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): def SetVariable(self, name, value):
calls.append(("SetVariable", name, value)) calls.append(("SetVariable", name, value))
monkeypatch.setattr(module, "_acad_app", lambda: _App()) monkeypatch.setattr(module, "_acad_doc", lambda: _Doc())
backend = module.ComBackend() backend = module.ComBackend()
async def _run_inline(func, *args, **kwargs): async def _run_inline(func, *args, **kwargs):