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.

On disk CadTools.extension CadTools.tab Blocks.panel bundle.yaml Count.pushbutton script.py icon.png Rename.pushbutton script.py tab panel buttons On the ribbon Home Insert pyACAD CadTools Blocks Count Rename Also commands PYCOUNT PYRENAME type them, put them in a macro, or call them from AutoLISP The folder name becomes the label. A layout: list in the panel's bundle.yaml decides that Count comes before Rename.
Four folder levels, four ribbon levels, and one AutoCAD command per button. Nothing else registers a tool: if the folders are right, the button and its command exist.

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:

How it starts Ribbon click Keyboard chord Typed: PYCOUNT CUI macro or script file PYACAD Count a b (pyacad "PYCOUNT" ...) _.PYCOUNT registered AutoCAD command: document context, drawing locked, one undo group by name, with arguments in __args__ __result__ goes back to LISP script.py runs on IronPython or CPython, with the pyacad library and the same globals every time
Clicks, chords, typed names and macros all send the same registered command. 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 PYCOUNT at the command line runs the same thing.
  • Macros and scripts can use it. A CUI macro such as ^C^C_PYCOUNT or a line in a .scr file 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:

CommandDoes
PYACADPrompts for a .py path or a bundle's command name, plus optional arguments, and runs it. Quotes keep a path with spaces together.
PYACADRELOADRescans every extension folder and rebuilds the ribbon, commands, shortcuts, hooks and dock panes.
PYACADCONSOLEA diagnostic dialog naming the IronPython version, the AutoCAD API version and how many extensions loaded. It works when nothing else does.
PYACADRUNUsed 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.

CadTools.extension/CadTools.tab/Blocks.panel/Count.pushbutton/script.py
"""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).

 IronPythonCPython
Engine idironpython (the default)cpython
Version3.4, shipped with pyACADWhatever 64-bit 3.7 to 3.13 install is found
How it runsIn-process, on AutoCAD's own .NET runtimeIn-process, through Python.NET 3.0
Compiled packages (numpy, pandas)NoYes
Needs Python installedNoYes
Use it whenAlmost alwaysYou 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.

Rule of thumb

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.

1 AutoCAD starts and reads the manifest pyACAD.bundle\PackageContents.xml one Components block per release, SeriesMin = SeriesMax 2 It loads the loader for its own year Contents\2026\PyAcad.dll net48 for 2021 to 2024, net8 for 2025 and 2026 3 The loader resolves the runtime %APPDATA%\pyACAD\2026\runtime PyAcad.Runtime.dll, IronPython, stdlib, pyacadlib 4 The runtime scans and builds extension roots, startup.py, dock panes, commands, ribbon, hooks, context rules, keyboard shortcuts
One bundle serves every installed release: AutoCAD 2024 loads Contents\2024, AutoCAD 2026 loads Contents\2026. The loader itself stays tiny and references only the AutoCAD API.
  1. The manifest. PackageContents.xml holds one Components block per AutoCAD release, each pinned to that release's series (R24.0 for 2021 through R25.1 for 2026) with platform AutoCAD*, so the verticals built on AutoCAD match too. Each entry is marked LoadOnAutoCADStartup, because the ribbon has to build before anyone can type a command.
  2. The loader. Contents\<year>\PyAcad.dll is 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.
  3. The runtime. The loader looks for its runtime folder in this order: the PYACAD_RUNTIME environment variable, a "runtime2026"-style key (named for the year) in config.json, %APPDATA%\pyACAD\<year>\runtime, then %PROGRAMDATA%\pyACAD\<year>\runtime, which counts only when no one but administrators can write to it.
  4. The build. The runtime reads config.json, scans the extension roots, and once AutoCAD's ribbon exists runs each extension's startup.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#

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

Keep your own tools out of pyACAD.extension

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#

  1. Create or edit a file inside your extension folder.
  2. Click Reload on the pyACAD panel, or type PYACADRELOAD.
  3. 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.

What needs what

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 *.dockpane instead.
  • No package installer. Vendor pure-Python dependencies into your extension's lib\ folder or a *.lib folder, or use CPython and its own site-packages.

Where to go next#