Scripts
AutoCAD commands and AutoLISP
Every tool is an AutoCAD command, so it can be clicked, typed, put in a macro or a script file, or called from AutoLISP with arguments and a return value.
A pyACAD button is not just a ribbon button. At boot, and again on every Reload, the
runtime registers one AutoCAD command per pushbutton-shaped bundle through AutoCAD's runtime
command API. The ribbon itself uses that command: a click sends the command name to the
drawing, exactly as if you had typed it. So there is one road into every tool, and everything
AutoCAD can do with a command (the command line, macros, .scr files, tool
palettes, menus, AutoLISP) works with yours.
This page covers how the names are chosen, what happens when one is taken, how a tool runs
as a command, the (pyacad ...) AutoLISP function with its type conversions, the
pyacad.lisp module for going the other way, and the commands the loader itself
defines.
Which bundles get a command#
Every bundle that has a script.py and behaves like a button:
| Bundle kind | Command |
|---|---|
*.pushbutton, including those inside a *.stack, a
*.pulldown, a *.splitbutton, a *.splitpushbutton or
a *.slideout | Yes |
*.nobutton | Yes. With no ribbon presence, the command (or a keyboard chord) is the only way in. |
*.toggle, *.smartbutton | Yes |
A bundle with a config.py | Yes, plus a second command for the
config.py, named with CFG on the end |
*.urlbutton, *.linkbutton, *.dockpane |
No. They have no script.py to run as a command (a dock pane's script
runs once, when its palette is built). |
Because registration happens through the runtime API rather than compiled attributes, a new bundle needs a Reload, never an AutoCAD restart. Reload first removes every command it registered last time, then registers the new set, so a bundle you deleted stops answering to its name at once.
How a command is named#
Two sources, in this order:
command:inbundle.yaml. Used as written, uppercased, with everything except letters, digits,_and-removed. NoPYis added:command: BLKXLgivesBLKXL.- The title.
PYfollowed by the title's letters and digits, uppercased. Spaces, punctuation and everything else are dropped. The title is the resolved one (title:inbundle.yaml, else__title__in the script, else the folder name with underscores read as spaces).
| Bundle | Title | Command |
|---|---|---|
Blocks_to_Excel.pushbutton | Blocks to Excel | PYBLOCKSTOEXCEL |
Font_Proxy_Report.pushbutton | Font and Proxy Report | PYFONTANDPROXYREPORT |
Drawing_Cleanup.pushbutton with config.py | Drawing Cleanup | PYDRAWINGCLEANUP and PYDRAWINGCLEANUPCFG |
Export.pushbutton with command: dwg-export | Export | DWG-EXPORT |
| A title with no letters or digits at all | ... | PYTOOL |
Names are capped at 64 characters. The config.py twin is always the primary
name with CFG appended, so a command: override renames both.
Retitle a bundle and its command is renamed with it, which silently breaks every macro,
script and LISP routine that typed the old name. If anything outside the ribbon calls your
tool, give it a command: in bundle.yaml so the title is free to
change.
title: Blocks to Excel
command: BLOCKSTOXL
tooltip: Exports every block reference to an Excel workbook.The shipped commands#
None of the shipped bundles sets command:, so every one is derived:
| Panel | Commands |
|---|---|
| pyACAD | PYSETTINGS, PYSHORTCUTS, PYCONSOLE, PYRELOAD |
| AI (beta) | PYAISETTINGS, PYOPENAIFOLDER (Ask AI is a dock pane, so it has none) |
| Cleanup | PYDRAWINGCLEANUP, PYDRAWINGCLEANUPCFG, PYFONTANDPROXYREPORT |
| Data | PYBLOCKSCHEDULE, PYBLOCKSTOEXCEL, PYEXCELTOBLOCKS, PYFINDANDREPLACEATTRIBUTES |
| Layers | PYLAYERMAPPER, PYLAYERSNAPSHOT, PYLAYERSNAPSHOTCFG (Layer Manager is a dock pane) |
| Output | PYBATCHPLOT, PYLAYOUTMANAGER |
| Takeoff | PYLENGTHTAKEOFF, PYTAGAUDIT |
| Xrefs | PYXREFMANAGER, PYXREFREPATH |
When a name is taken#
Bundles are named in load order: extension roots in the order config.json
lists them, extensions within a root by folder name, then tabs, panels and items in ribbon
order. The first bundle to ask for a name gets it. Three things can stop a
bundle getting its command:
| Cause | What you see |
|---|---|
| An earlier bundle already took the name | Command PYEXPORT is already taken, so '<bundle key>' has no command. Give it a
different command: in bundle.yaml. |
The name is one of the loader's own: PYACAD, PYACADRELOAD,
PYACADCONSOLE, PYACADRUN or PYACADSETTINGS |
The same message. A bundle titled "Acad Reload" derives PYACADRELOAD and
loses. |
AutoCAD already answers to the name: a native command, an AutoLISP
C: function, another add-in |
Command LINE belongs to something else in AutoCAD, so '<bundle key>' is
ribbon-only. Set command: in bundle.yaml. |
Each problem is written to the log, and a warning toast follows the boot or Reload:
One command was not registered (or N commands were not registered) with the
first message as its detail. A config.py twin that collides is reported the same
way and only the twin is lost.
A bundle without a command still works from the ribbon and from its keyboard chord: the click runs the script directly under a document lock instead of sending a command. What it loses is everything on this page: it cannot be typed, scripted or called from AutoLISP, and it gets no automatic undo group.
Aliases#
A full name like PYBLOCKSTOEXCEL is easy to find and hard to type forty times
a day. alias: BX in bundle.yaml registers BX as a second
command for the same tool (see alias).
Users set their own in config.json:
{
"aliases": {
"pyACAD.tab/Data.panel/Blocks_to_Excel.pushbutton": "BXL",
"pyACAD.tab/Data.panel/Excel_to_Blocks.pushbutton": null
}
}A string replaces the bundle's aliases; null or "" drops them.
Changes land on the next Reload. Aliases are handed out after every full command name, first
claim wins, and the tool's tooltip ends with what it answers to:
Command: PYBLOCKSTOEXCEL (alias BXL).
pyACAD reads acad.pgp from the support path at boot and on Reload, and never
writes to it. An alias is left out when:
| Cause | What happens |
|---|---|
| An earlier bundle, or any tool's full command name, already has it | Reported like a taken command name. |
acad.pgp defines it, for example CO |
Left out; CO still runs COPY. |
AutoCAD already answers to it: a native command, a loaded LISP C:
function, another add-in |
Left out; the existing command still runs. |
A drawing's LISP defines C: with the same name after pyACAD loaded |
Typing it runs that LISP command, not the tool. |
When the alias came from the bundle, a left-out alias only goes to the log. When it came
from the user's own config.json, it is toasted, since they asked for it. Either way
the full command name and the button keep working.
How a tool runs as a command#
The commands are registered with the Modal and UsePickSet flags,
in a command group named PYACAD. That gives every tool the same footing as a
native AutoCAD command:
- Document context. AutoCAD locks the drawing before your script's first
line, so a script started as a command never needs
pyacad.doc.lock(). - One undo step. AutoCAD opens an undo group around the command, so a single U or Ctrl+Z takes back everything the tool did, however many transactions it used.
- The pickfirst set survives. Objects selected before the command starts
are still selected inside it, so
pyacad.sel.current()sees them. - Not transparent. A pyACAD command cannot run in the middle of another
command (
'PYNAME).
Ribbon clicks and chords#
A click, or a keyboard chord, does not run the script itself. It sends
_.PYNAME and Enter to the active drawing, with echo on, so the command line shows
what ran exactly as a typed command would. The underscore asks for the global name, so a
localised AutoCAD still finds it; the dot bypasses any UNDEFINE, so a redefined
name never traps a pyACAD tool.
Shift+Click sends the CFG twin. Alt+Click opens the bundle folder in Explorer
and sends nothing. A chord always sends the primary command, never the twin.
With no drawing open there is nothing to send to: the click shows an Open a drawing first. toast and nothing runs.
Because the click goes through the command line, it behaves like typing the name. Finish or cancel (Esc) whatever command is active before you click a pyACAD tool, as you would before typing one.
Typing it, macros and script files#
Type the name at the command line, or use it anywhere AutoCAD takes a command. In a CUI macro or a tool palette command string:
^C^C_.PYBLOCKSTOEXCELIn a .scr script, one command per line:
_.PYDRAWINGCLEANUP
_.PYBLOCKSTOEXCEL
_.QSAVEA command typed this way receives no arguments: __args__ is an empty list,
exactly as for a click. To hand a tool arguments, use PYACAD
or AutoLISP.
Link buttons#
The reverse direction needs no code. A *.linkbutton with
plugin: QSAVE in its bundle.yaml sends that command name to the
drawing as _.QSAVE, the same way a pyACAD button sends its own, so it can start a
native command or another add-in's. See Buttons, stacks and pulldowns.
The loader's own commands#
The loader defines five commands statically, so they exist even when the runtime failed to build a ribbon. They are the way in when nothing else works.
| Command | Does |
|---|---|
PYACAD | Runs a .py file by path, or a bundle by its
command name, with optional arguments. See below. |
PYACADRELOAD | Rescans the extension folders and rebuilds the
ribbon, commands, hooks, dock panes and shortcuts. The same as the Reload button, which
is the PYRELOAD bundle; this one works when the ribbon does not. |
PYACADCONSOLE | A diagnostic message box: the IronPython version,
the AutoCAD version and how many extensions loaded. It runs even when no extension
loaded. It is not the interactive console, which is the Console button
(PYCONSOLE). |
PYACADRUN | Internal: the entry point pyacad run uses inside AutoCAD Core Console. It takes the path of a request file the command line wrote; there is no reason to type it. |
(pyacad ...) | Not a command but an AutoLISP function, defined by the loader. See below. |
PYACADSETTINGS is reserved (no bundle may take it) but is not defined by
anything: the Settings window is the PYSETTINGS bundle.
The PYACAD command#
PYACAD prompts Script path or pyACAD command: and reads the whole
line, spaces included, up to Enter. The first word is the target and the rest are arguments.
Double quotes group words with spaces and are removed.
Command: PYACAD
Script path or pyACAD command: "C:\Tools\renumber.py" A-DOOR 100 "two words"The script sees __args__ as ['A-DOOR', '100', 'two words'].
Arguments from the command line are always strings; convert them yourself.
How the target is resolved:
- If it ends in
.py, or names a file that exists, it is run as a file. The file's folder becomes its bundle folder and search path, its file name without.pyits__title__. It always runs on IronPython, since there is nobundle.yamlto say otherwise, and nothing in the ribbon changes. - Otherwise it is looked up as a registered pyACAD command, first as typed and then with
PYin front.PYACAD blockstoexcelandPYACAD PYBLOCKSTOEXCELboth run Blocks to Excel. ACFGtwin name runs the bundle'sconfig.py(without arguments). - Otherwise
No script file or pyACAD command named '...'.
When the run fails, the reason (or the traceback) is printed on the command line after
pyACAD:, as well as in the output window and the log. PYACAD is itself
a modal command with UsePickSet, so everything under
How a tool runs as a command applies to what it
runs, including the single undo step.
In a macro, end the line with a semicolon (Enter), since PYACAD accepts
spaces as part of its input:
^C^C_.PYACAD "C:\Tools\renumber.py" A-DOOR 100;Calling a tool from AutoLISP#
The loader defines an AutoLISP function, pyacad:
(pyacad "PYNAME" arg1 arg2 ...)
(pyacad "C:\\Tools\\renumber.py" arg1 arg2 ...)The first argument names the target, resolved exactly as for
PYACAD: a .py path or an existing file runs as a
file, anything else is a command name with or without its PY. The remaining
arguments become __args__, keeping their LISP types. Whatever the script assigns to
__result__ is converted back to LISP data and returned.
"""Counts the entities on a layer. From AutoLISP it returns the count."""
from pyacad import script, sel, toast
args = script.get_args()
layer = args[0] if args else '0'
handles = sel.get(layer=layer)
__result__ = {'layer': layer, 'count': len(handles)}
if not args: # clicked or typed: tell the user
toast.info('%d entities on layer %s' % (len(handles), layer))(defun c:wallcount ( / r)
(setq r (pyacad "PYLAYERCOUNT" "A-WALL"))
(princ (strcat "\n" (cadr (assoc "layer" r)) ": "
(itoa (cadr (assoc "count" r)))))
(princ))Here (pyacad "PYLAYERCOUNT" "A-WALL") returns
(("layer" "A-WALL") ("count" 12)).
script.get_args() (or lisp.args(), the same thing) returns a plain
Python list. The raw __args__ global is a .NET list, which indexes and iterates
like one but is not a Python list.
LISP to Python: what arrives in __args__#
| AutoLISP value | In __args__ |
|---|---|
| String | str |
| Integer | int |
| Real | float |
nil | None |
T | True |
| A list | A nested list (a .NET list, even inside the list
get_args() returns) |
| Anything else, such as an entity name or a point AutoCAD passes as a point | The raw .NET value AutoCAD handed over (an ObjectId, a
Point3d), not converted |
Entity names and points are not converted, and dotted pairs are not supported. The
reliable shapes are strings, numbers and plain lists: pass a handle with
(cdr (assoc 5 (entget e))) rather than the entity name, and the coordinates
of a point as separate reals.
Python to LISP: what __result__ becomes#
__result__ is serialised with json.dumps in the script's own
interpreter and then converted:
__result__ | Returned to LISP |
|---|---|
| Not set | nil |
None, False | nil |
True | T |
str | String |
int that fits in 32 bits | Integer |
Any larger int | Real |
float | Real |
list or tuple | A list, nested lists kept |
dict | An association list of two-element lists,
(("key" value) ...): read it with (cadr (assoc "key" r)), not
cdr |
Anything json refuses: a set, an ObjectId, a
Point3d, float('nan') | nil, and a log line
Script global '__result__' was not captured |
A single value comes back bare; a list or a dict comes back as a list. A point is a tuple:
__result__ = (x, y, z) returns a list of three reals, which AutoLISP accepts
wherever it takes a point.
(pyacad ...) returns nil when the script fails, when the target is
not found, when another pyACAD script is already running, or when the bundle is blocked by
min_host_version or max_host_version. The reason goes to the log as
(pyacad "...") failed: ..., so a nil you did not expect is worth a
look at %APPDATA%\pyACAD\logs\pyACAD.log. A CFG twin always returns
nil.
Calling AutoLISP from Python#
pyacad.lisp goes the other way.
| Function | Does |
|---|---|
lisp.call(function, *args) | Calls an AutoLISP function and returns
its result as Python values. It goes through acedInvoke, so it reaches only
functions LISP has exposed: commands defined as C:NAME and functions
registered with (vl-acad-defun 'name). |
lisp.evaluate(expression) | Queues an expression on the command
line (wrapping it in parentheses if needed). It runs after your script returns, so the
result is not available; it returns None. |
lisp.args() | The arguments of the (pyacad ...) call
that ran this script, as a Python list. The same as script.get_args(). |
lisp.encode(value), lisp.decode(pairs) | The pure conversion between Python values and (type code, value) pairs, usable outside AutoCAD. |
from pyacad import lisp, toast
# (defun c:stamp () ...) loaded from acaddoc.lsp
lisp.call('c:stamp')
# (vl-acad-defun 'acme-next-number) exposes a plain function
number = lisp.call('acme-next-number', 'DOOR')
toast.info('Next door number: %s' % number)
# fire and forget: runs after this script ends
lisp.evaluate('(setvar "OSMODE" 0)')What lisp.call hands back: strings, integers and reals as themselves,
nil as None, T as True, lists as lists, a
point as an (x, y, z) or (x, y) tuple, and an entity name as its
handle, a hex string. One value comes back bare; several come back as a list. When it sends
arguments it uses the same encoding as __result__.
Python functions cannot be registered as AutoLISP functions at run time. A script is
reachable from LISP only as a bundle or a file, through (pyacad ...). To queue a
whole command line rather than a LISP expression, use doc.send_command(text).
Scripts, commands and pyacad run#
Inside pyacad run, which drives AutoCAD Core
Console, no bundle commands are registered (Core Console has no command host for them and no
ribbon). A headless script cannot call a bundle by name; import the shared code from a
*.lib folder instead.