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:
co-authored by
Claude Opus 5
parent
70afd57831
commit
f7528b2fa5
+96
-82
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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):
|
||||||
|
|||||||
Reference in New Issue
Block a user