Start
How pyACAD works
The mental model behind every tool you will write: folders on disk become ribbon buttons, every button is an AutoCAD command, and a command runs a Python file.
pyACAD is a Python scripting host for Autodesk AutoCAD 2021 to 2026. A small .NET add-in loads when AutoCAD starts, scans folders on disk, and builds ribbon tabs from what it finds. Each button it builds is also registered as an AutoCAD command, and running that command executes a Python file against the active drawing. Hooks, dock panes, keyboard shortcuts and the AutoLISP bridge all hang off the same folder scan.
If you have written pyRevit extensions, most of this will feel familiar. If you have not,
the one idea to hold on to is that the folder structure is the configuration.
There is no manifest listing your tools, no registration call, no project file and no compile
step. A folder named Export.pushbutton containing a script.py is a
working button and a working command.
From disk to ribbon#
Each level of the folder tree maps to one level of the ribbon. The suffix on a folder name
decides what it becomes; the rest of the name becomes the visible label unless a
bundle.yaml gives a better one.
The full vocabulary is an *.extension folder (the unit you ship), holding
*.tab folders (ribbon tabs), holding *.panel folders (ribbon
panels), holding bundles. A bundle is a folder whose suffix says what kind of control it is:
twelve kinds in all, from *.pushbutton to *.dockpane.
Anatomy of an extension covers the tree and
Buttons, stacks and pulldowns covers the twelve kinds.
Every button is a command#
This is the design decision the rest of pyACAD leans on. At boot and on every Reload, each
pushbutton-shaped bundle (pushbuttons, plus stack rows, menu rows, toggles, smartbuttons and
nobuttons) is registered as an AutoCAD command through AutoCAD's runtime command API. The
name comes from command: in bundle.yaml, or else from the title:
PY followed by the title's letters and digits, uppercased. A button titled
Blocks to Excel is the command PYBLOCKSTOEXCEL. A bundle with a
config.py also gets a twin with CFG on the end.
A ribbon click does not run the script itself. It sends the command to the active drawing, exactly as an Autodesk ribbon button does, so AutoCAD enters document context, locks the drawing and opens an undo group before the script starts. The result is that a tool behaves the same however it was started:
PYACAD and the (pyacad ...) LISP function look the bundle up by
command name and run it directly, which is how they pass arguments in and get a value
back.- Clicked, typed or chorded, it is one code path. A keyboard shortcut and
a click both send the command; typing
PYCOUNTat the command line runs the same thing. - Macros and scripts can use it. A CUI macro such as
^C^C_PYCOUNTor a line in a.scrfile is an ordinary command call. - AutoLISP can call it and get an answer.
(pyacad "PYCOUNT" "E-*")runs the bundle with"E-*"in__args__and returns whatever the script put in__result__. Commands, macros and AutoLISP covers the bridge and the type conversions. - A pickfirst selection survives. Commands are registered with AutoCAD's pickfirst flag, so objects selected before the click are still selected when the script asks for them.
A click with no drawing open does nothing but toast Open a drawing first., because there is no drawing to send the command to. If a command name is already taken (by another bundle, by one of the loader's own commands, or by anything else AutoCAD already answers to) the later bundle gets no command, a toast says so, and its button runs the script directly under a document lock instead. It still works from the ribbon; it just cannot be typed.
The loader also defines four commands of its own, which exist even when the ribbon failed to build:
| Command | Does |
|---|---|
PYACAD | Prompts for a .py path or a bundle's command
name, plus optional arguments, and runs it. Quotes keep a path with spaces together. |
PYACADRELOAD | Rescans every extension folder and rebuilds the ribbon, commands, shortcuts, hooks and dock panes. |
PYACADCONSOLE | A diagnostic dialog naming the IronPython version, the AutoCAD API version and how many extensions loaded. It works when nothing else does. |
PYACADRUN | Used by pyacad run inside AutoCAD Core
Console; not meant to be typed. See The command line. |
What a script looks like#
When the command runs, the runtime picks the bundle's engine, creates a fresh scope,
injects a handful of globals (__file__, __title__,
__commandpath__, __args__ and a few more, listed in
The script environment) and executes
script.py top to bottom. Your script talks to the drawing through the
pyacad library, which wraps the AutoCAD .NET API in plain Python: handles are hex
strings, points are (x, y, z) tuples, and every object you get back is a
snapshot, so no script holds an AutoCAD object past its transaction.
"""Counts block references by name in model space."""
from pyacad import blocks, output, toast
counts = blocks.counts('model')
if not counts:
toast.info('No block references', 'Model space holds no inserted blocks.')
else:
rows = sorted(counts.items(), key=lambda pair: -pair[1])
output.set_title('Blocks in model space')
output.print_table(rows, ['Block', 'Count'])That script is a complete, shippable tool: a button titled Count from its folder
name, a tooltip taken from its docstring, a command named PYCOUNT, and a table in
pyACAD's output window. The library is organised by domain: doc,
sel, ents, layers, blocks,
xrefs, layouts, plot, audit,
lisp and units face the drawing; toast,
forms and output face the user; script and
settings face pyACAD itself. The API reference
lists every function.
The two engines#
Every bundle runs on one of two Python engines, chosen by engine: in
bundle.yaml (or once for a whole extension in extension.yaml).
| IronPython | CPython | |
|---|---|---|
| Engine id | ironpython (the default) | cpython |
| Version | 3.4, shipped with pyACAD | Whatever 64-bit 3.7 to 3.13 install is found |
| How it runs | In-process, on AutoCAD's own .NET runtime | In-process, through Python.NET 3.0 |
| Compiled packages (numpy, pandas) | No | Yes |
| Needs Python installed | No | Yes |
| Use it when | Almost always | You need a compiled package or a newer language feature |
CPython is found from the "cpython" key in config.json (a
python3XX.dll or an install folder), then the PYACAD_CPYTHON
environment variable, then the newest registered 64-bit install in that range. An engine id
other than those two is not checked when the ribbon is built; the click fails with
Unknown engine.
Start on IronPython. It is the default, it needs nothing installed, and every shipped tool
uses it. Move one bundle to CPython only when you hit a wall such as needing
numpy, and test it there: the pyacad library is written to import
on both.
Beyond the plain button#
The pushbutton is one of twelve bundle kinds. Stacks and pulldowns pack more tools into the
same ribbon width; toggles, smartbuttons and the two split buttons change how a button
behaves; nobuttons, urlbuttons and linkbuttons cover commands that are not really your
scripts. *.dockpane is an AutoCAD palette whose content is WPF from a
pane.xaml, shown and hidden by a ribbon toggle, and *.slideout moves
its contents into the flyout under the panel title.
Code can also run without a click. An extension may carry hooks\<event>.py
scripts that run when one of 20 AutoCAD events fires, from doc-opened to
system-variable-changed, and a startup.py at its root that runs at
boot and on every Reload, before the ribbon builds. Hooks covers
the events; Anatomy of an extension covers
startup.py and shared *.lib folders.
How pyACAD loads into AutoCAD#
pyACAD installs as an Autodesk ApplicationPlugins bundle in your own profile, so no administrator rights are involved. AutoCAD reads the bundle's manifest at startup and loads the one small loader assembly built for its release. The loader finds the runtime, and the runtime does everything else.
Contents\2024, AutoCAD 2026 loads Contents\2026. The loader itself
stays tiny and references only the AutoCAD API.- The manifest.
PackageContents.xmlholds oneComponentsblock per AutoCAD release, each pinned to that release's series (R24.0 for 2021 through R25.1 for 2026) with platformAutoCAD*, so the verticals built on AutoCAD match too. Each entry is markedLoadOnAutoCADStartup, because the ribbon has to build before anyone can type a command. - The loader.
Contents\<year>\PyAcad.dllis built separately for each year: against .NET Framework 4.8 for AutoCAD 2021 to 2024 and .NET 8 for AutoCAD 2025 and 2026. It defines the four loader commands and the(pyacad ...)LISP function. - The runtime. The loader looks for its runtime folder in this order: the
PYACAD_RUNTIMEenvironment variable, a"runtime2026"-style key (named for the year) inconfig.json,%APPDATA%\pyACAD\<year>\runtime, then%PROGRAMDATA%\pyACAD\<year>\runtime, which counts only when no one but administrators can write to it. - The build. The runtime reads
config.json, scans the extension roots, and once AutoCAD's ribbon exists runs each extension'sstartup.py, builds the dock panes, registers the commands, builds the ribbon tabs, wires hooks and context rules and installs the keyboard hook. Each stage fails on its own: a broken bundle costs only itself, and a toast tells you what went wrong. A workspace switch or CUIx load, which rebuilds AutoCAD's ribbon from the customisation file, is detected and the pyACAD tabs are put back.
Where everything lives#
| What | Where |
|---|---|
| Loader, one folder per release | %APPDATA%\Autodesk\ApplicationPlugins\pyACAD.bundle\Contents\<year>\ |
Runtime, Python stdlib and the pyacad library |
%APPDATA%\pyACAD\<year>\runtime\ (the library is in its
pyacadlib\ subfolder) |
| The shipped extension | %APPDATA%\pyACAD\extensions\pyACAD.extension\ |
| Your settings | %APPDATA%\pyACAD\config.json |
Per-tool settings (pyacad.settings) |
%APPDATA%\pyACAD\settings\<tool>.json |
Per-tool data files (pyacad.script) |
%APPDATA%\pyACAD\data\ |
| Logs | %APPDATA%\pyACAD\logs\ |
| The command-line tool | %APPDATA%\pyACAD\cli\pyacad.exe
(installed by the setup program) |
Extensions load from every folder listed under "extensions" in
config.json, in list order, then from two defaults when they exist:
%APPDATA%\pyACAD\extensions, where the shipped extension lives, and
%PROGRAMDATA%\pyACAD\extensions, which is how IT deploys extensions to a whole
machine. The machine-wide folder counts only when no one but administrators can write to it,
because anything in it runs (hooks and startup.py included) for every user on the
PC; otherwise it is skipped with a log line. The Settings button on the pyACAD
tab edits the list for you.
An update replaces pyACAD.extension whole. Put your work in an extension of
your own: a separate folder, or %APPDATA%\pyACAD\extensions\My pyACAD.extension.
Tabs and panels with the same name merge across extensions, so a pyACAD.tab in
your own extension adds to the pyACAD tab. The installer backs up anything it finds added to
pyACAD.extension and moves what it can into My pyACAD.extension,
but that is a safety net, not a workflow.
The development loop#
- Create or edit a file inside your extension folder.
- Click Reload on the pyACAD panel, or type
PYACADRELOAD. - Click your button, or type its command.
Reload rescans every extension root and rebuilds the ribbon without restarting AutoCAD. It
picks up new and renamed bundles, bundle.yaml changes, new icons and shortcuts,
re-registers every command, reruns each extension's startup.py, re-reads the hook
scripts and rebuilds every dock pane. It also re-reads the AutoCAD theme, so light and dark
icons follow a theme change after a Reload.
Edits to script.py, and to modules beside it or in the extension's own
lib\ folder, need nothing: those modules are dropped from the cache before every
run. Edits to the pyacad library or to modules inside a shared
*.lib folder need a Reload. Adding or removing a whole *.lib folder,
changing the engine paths in Settings, or replacing the runtime itself needs an AutoCAD
restart.
What you cannot do#
- No background threads touching the drawing. The AutoCAD API is not thread-safe. Scripts run on AutoCAD's UI thread and should stay there.
- One script at a time. A second run while one is in flight is refused with a still running toast. Long jobs block the UI, so report progress.
- No custom ribbon controls. The ribbon vocabulary is the twelve bundle
kinds. There are no combo boxes, sliders or galleries; a tool that needs persistent
controls gets a
*.dockpaneinstead. - No package installer. Vendor pure-Python dependencies into your
extension's
lib\folder or a*.libfolder, or use CPython and its own site-packages.