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 kindCommand
*.pushbutton, including those inside a *.stack, a *.pulldown, a *.splitbutton, a *.splitpushbutton or a *.slideoutYes
*.nobuttonYes. With no ribbon presence, the command (or a keyboard chord) is the only way in.
*.toggle, *.smartbuttonYes
A bundle with a config.pyYes, 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:

  1. command: in bundle.yaml. Used as written, uppercased, with everything except letters, digits, _ and - removed. No PY is added: command: BLKXL gives BLKXL.
  2. The title. PY followed by the title's letters and digits, uppercased. Spaces, punctuation and everything else are dropped. The title is the resolved one (title: in bundle.yaml, else __title__ in the script, else the folder name with underscores read as spaces).
BundleTitleCommand
Blocks_to_Excel.pushbuttonBlocks to ExcelPYBLOCKSTOEXCEL
Font_Proxy_Report.pushbuttonFont and Proxy ReportPYFONTANDPROXYREPORT
Drawing_Cleanup.pushbutton with config.pyDrawing Cleanup PYDRAWINGCLEANUP and PYDRAWINGCLEANUPCFG
Export.pushbutton with command: dwg-exportExportDWG-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.

A derived name follows the title

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.

Blocks_to_Excel.pushbutton\bundle.yaml
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:

PanelCommands
pyACADPYSETTINGS, PYSHORTCUTS, PYCONSOLE, PYRELOAD
AI (beta)PYAISETTINGS, PYOPENAIFOLDER (Ask AI is a dock pane, so it has none)
CleanupPYDRAWINGCLEANUP, PYDRAWINGCLEANUPCFG, PYFONTANDPROXYREPORT
DataPYBLOCKSCHEDULE, PYBLOCKSTOEXCEL, PYEXCELTOBLOCKS, PYFINDANDREPLACEATTRIBUTES
LayersPYLAYERMAPPER, PYLAYERSNAPSHOT, PYLAYERSNAPSHOTCFG (Layer Manager is a dock pane)
OutputPYBATCHPLOT, PYLAYOUTMANAGER
TakeoffPYLENGTHTAKEOFF, PYTAGAUDIT
XrefsPYXREFMANAGER, 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:

CauseWhat 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:

%APPDATA%\pyACAD\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:

CauseWhat 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.

A click is typed text

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:

text
^C^C_.PYBLOCKSTOEXCEL

In a .scr script, one command per line:

export.scr
_.PYDRAWINGCLEANUP
_.PYBLOCKSTOEXCEL
_.QSAVE

A 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.

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.

CommandDoes
PYACADRuns a .py file by path, or a bundle by its command name, with optional arguments. See below.
PYACADRELOADRescans 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.
PYACADCONSOLEA 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).
PYACADRUNInternal: 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.

text
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:

  1. 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 .py its __title__. It always runs on IronPython, since there is no bundle.yaml to say otherwise, and nothing in the ribbon changes.
  2. Otherwise it is looked up as a registered pyACAD command, first as typed and then with PY in front. PYACAD blockstoexcel and PYACAD PYBLOCKSTOEXCEL both run Blocks to Excel. A CFG twin name runs the bundle's config.py (without arguments).
  3. 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:

text
^C^C_.PYACAD "C:\Tools\renumber.py" A-DOOR 100;

Calling a tool from AutoLISP#

The loader defines an AutoLISP function, pyacad:

text
(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.

Layer_Count.pushbutton\script.py
"""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))
layercount.lsp
(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 valueIn __args__
Stringstr
Integerint
Realfloat
nilNone
TTrue
A listA 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
Pass handles and numbers, not entity names and points

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 setnil
None, Falsenil
TrueT
strString
int that fits in 32 bitsInteger
Any larger intReal
floatReal
list or tupleA list, nested lists kept
dictAn 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.

FunctionDoes
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.
python
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.