Scripts

The script environment

What the runtime does between the click and the first line of your code, every global it hands you, and every rule that follows from it.

A pyACAD script is not run the way a file is run from a terminal. There is no argument vector, no interpreter start-up and no process boundary. Your file is compiled and executed inside a scope the runtime prepares for it, on AutoCAD's own UI thread, usually inside an AutoCAD command, by an interpreter that has been alive since the first script of the session.

Almost every surprise in authoring comes from one of those facts. This page covers all of them.

What happens when you click#

A click does not run your script. It sends your bundle's AutoCAD command to the drawing, and the command runs it (see AutoCAD commands and AutoLISP). From there the path is short and the same every time.

Runtime What it means for you Click, chord, typed or (pyacad ...) Shift+Click sends the CFG twin and runs config.py. AutoCAD starts the command Document locked, undo group opened, pickfirst kept. Host version gate min_host_version / max_host_version, else a toast. Run gate One pyACAD script at a time, process-wide. before-command event Hooks for it run now, with your bundle key. Fresh scope, globals set __file__ __commandpath__ __args__ ... Search paths set, cache pruned Your bundle and lib modules are re-read. script.py executed from source Top to bottom. Never imported, always fresh. Teardown in a finally Progress closed, after-command raised, gate freed.
Every command run walks the same stages. An error anywhere after the scope is set is formatted, written to the output window and the log, and the teardown still happens. The scope is discarded at the end: nothing you define survives to the next run.

Where scripts run#

A bundle's script.py is not the only Python pyACAD runs. Each kind of run gets a slightly different set of globals and a different relationship with the drawing:

RunWhenIn a command?
A bundle's script.pyClick, chord, typed command, macro, PYACAD, (pyacad ...)Yes (see below)
A bundle's config.pyShift+Click, or its CFG commandYes
A *.smartbutton's selfinit runEvery ribbon build, before any clickNo
A *.dockpane's script.pyOnce, when its palette is built (boot and every Reload)No
hooks\<event>.pyWhen the AutoCAD event fires No
startup.pyBoot and every Reload, before the ribbon builds No
A script under pyacad runIn AutoCAD Core ConsoleYes, inside PYACADRUN

Two exceptions to the first row. A bundle whose command name was taken (see AutoCAD commands and AutoLISP) has no command, so its click runs the script directly under a document lock: locked, but with no undo group of its own. And (pyacad ...) runs the script from inside the LISP call rather than as a separate command.

The injected globals#

These are every name the runtime sets in a scope. Anything else you see is something you defined or something Python provides.

NameTypeWhat it holds
__file__str Full path of the file being executed. On a Shift+Click run this is the config.py, not script.py.
__name__str Always '__main__', on both engines, in every kind of run.
__commandpath__str The bundle folder: where icon.png, your data files and your bundle-local modules live. For a file run by PYACAD or pyacad run, the file's folder.
__title__str The resolved title (bundle.yaml, then __title__ in the script, then the folder name), flattened to one line. It stays the button's title on a Shift+Click run. For a file run by path, the file name without .py.
__args__sequence The arguments of a PYACAD line or a (pyacad ...) call, an empty list for a click, a chord or a typed command, and the words after -- under pyacad run. It is a .NET list or array, not a Python list; script.get_args() returns a real one.
__result__you set it Not set by the runtime but read back: set it to a JSON-friendly value and (pyacad ...) returns it to AutoLISP, or pyacad run writes it to results.json. Ignored on a click.
__selfinit__bool False on every command run, click and Shift+Click alike. True only on a *.smartbutton's extra ribbon-build run.
__button__proxy Only on that selfinit run: Title, Enabled, Tooltip and SetIcon(path) on the ribbon item.
__pane__proxy Only in a *.dockpane's script.py: Find(name), Content, Title, BundleKey and Visible over the palette.
__event__dict Only in a hook: name (the event, such as doc-saved) and command (the bundle key, for before-command and after-command).
__runner__bool True only under pyacad run (including the startup.py it starts). Check it before any prompt in a script that may run headless.
__model__, __models__, __run_dir__str, sequence, str Only under pyacad run: the open drawing, every drawing in the run, and the run folder. See pyacad.exe and headless runs.
__pyacad__host object The runtime host singleton, your door back into the C# side. Everywhere.

Which run gets which:

RunGlobals
Command (script.py or config.py) __file__ __commandpath__ __title__ __args__ __selfinit__ __runner__ __pyacad__
Smartbutton selfinit __file__ __selfinit__ __button__ __runner__ __pyacad__. No __commandpath__, __title__ or __args__.
Dock pane script __file__ __commandpath__ __title__ __pane__ __runner__ __pyacad__
Hook__file__ __event__ __runner__ __pyacad__
startup.py__file__ __runner__ __pyacad__
pyacad run __file__ __commandpath__ __title__ __args__ __selfinit__ __runner__ __pyacad__ __model__ __models__ __run_dir__

Every run also gets __name__. A missing name raises NameError like any other, which is why a file shared between a command and something else should test with '__commandpath__' in globals() rather than read it.

You can see them at once. This is one of the few scripts where print is the right call, because opening the output window is the point of it:

Probe.pushbutton\script.py
"""Prints the environment the runtime handed this script."""
print('__file__        %s' % __file__)
print('__commandpath__ %s' % __commandpath__)
print('__title__       %s' % __title__)
print('__args__        %r' % list(__args__))
print('__selfinit__    %s' % __selfinit__)
print('__runner__      %s' % __runner__)
print('version         %s' % __pyacad__.Version)
print('drawing         %s' % __pyacad__.ActiveDocumentPath)
print('dark theme      %s' % __pyacad__.IsDarkTheme)
print('__name__        %r' % __name__)

The __pyacad__ host#

__pyacad__ is the same object script.get_host() returns, so you rarely touch the global directly. Its members:

MemberDoes
.VersionThe pyACAD version, such as 0.1.0.
.ActiveDocumentPathThe active drawing's full path, 'untitled' when it was never saved, None with no drawing open.
.IsDarkThemeTrue when AutoCAD's UI is dark. Theme your own dialogs with this.
.IsRunnerTrue under pyacad run.
.ScriptPath, .CommandPath, .CommandTitle, .CommandBundleKeyThe current (or most recent) command's script, bundle folder, title and bundle key. Toggle state is keyed on the bundle key.
.WriteHtml(html)Appends an HTML fragment to the output window, opening it if needed.
.HasOpenOutputTrue once this run has opened an output window.
.SetOutputTitle(title)Renames the output window away from pyACAD - <title>.
.SaveOutput(path)Writes the output window's transcript to a standalone HTML file.
.Progress(fraction, label)Shows or updates the output window's progress bar, fraction 0 to 1.
.ElementLink(item, label)HTML for a link that selects an object in the drawing (a handle string, an ObjectId or a Handle).
.GetToggleState() / .SetToggleState(on)The running toggle bundle's session flag.
.GetEnvVar(name) / .SetEnvVar(name, value)A session-scoped, case-insensitive store shared by every script. Setting None removes the entry.
.LogMessage(level, source, message)One line in the pyACAD log.
.LogSourceThe label log lines carry while a hook, startup.py or a dock pane script runs.
.Reload()Rescans the extension folders and rebuilds the ribbon.

.SetCommandContext(...) and .SetCurrentOutput(...) also exist and belong to the runtime. Calling either from a script points the host at the wrong tool.

Prefer the Python wrappers in pyacad.script:

python
from pyacad import script

script.get_host()            # the __pyacad__ object, even outside a script scope
script.get_script_path()     # the running command's script.py
script.get_command_path()    # its bundle folder
script.get_title()           # its resolved title
script.get_args()            # __args__ as a Python list, [] when none
script.get_document_path()   # active drawing path, 'untitled', or None
script.get_host_version()    # '0.1.0'
script.is_dark_theme()       # bool
script.is_runner()           # bool, True under pyacad run
script.reload_pyacad()       # rescan and rebuild the ribbon, no restart
Outside a command they go stale, not blank

The host keeps the context of the most recent command. Hooks, startup.py, smartbutton selfinit runs and the console set none of their own, so script.get_title() there returns whatever command ran last, or None if none has. Trust these only inside a command.

__name__ is '__main__'#

A hosted scope has no __name__ of its own, so if __name__ == '__main__': would be false and the body of such a script would silently never run. Both engines therefore set the name before your first statement, and the familiar guard works.

It also means the guard never excludes anything here. A module you want to import from elsewhere belongs in a separate file beside script.py, which imports it. Module-level code is the shorter and more common style; every shipped bundle uses it.

Do
"""Counts the selected objects."""
from pyacad import sel, toast

handles = sel.current()
toast.info('%d selected' % len(handles))
Don't
"""Counts the selected objects."""
from pyacad import sel, toast

def main():
    toast.info('%d selected' % len(sel.current()))

# nothing calls main(), so nothing happens

sys.exit()#

Both engines treat sys.exit() as a script saying "stop here", not as a crash: no traceback, and the run counts as a success. sys.exit(0) is the same. sys.exit('message') or a non-zero code is a failure reported in your own words (Script exited with code 2. for a number), which (pyacad ...) and pyacad run see as a failed run.

python
import sys
from pyacad import sel, toast

handles = sel.current()
if not handles:
    toast.info('Nothing selected', 'Select the blocks first, then run this again.')
    sys.exit()

Choosing an engine#

The engine is chosen per bundle when the bundle is parsed:

  1. The engine: key in the bundle's bundle.yaml.
  2. The engine: key in the extension's extension.yaml, a default for every bundle, hook and startup.py in it.
  3. ironpython.
Quantities.pushbutton\bundle.yaml
title: Quantities
engine: cpython

Hooks and startup.py have no bundle.yaml, so they always use the extension's engine. Files run by path (PYACAD, (pyacad ...) with a path, pyacad run) always use IronPython.

 ironpythoncpython
VersionIronPython 3.4 (3.4.2)The installed CPython, 3.7 to 3.13, 64-bit
Ships with pyACADYes, with its standard library No, it finds an installed Python
HostingThe DLR, one engine per session Python.NET 3.0.5, one interpreter per process
CreatedOn first useOn the first run of a CPython script
Per runA fresh scopeA fresh scope
AutoCAD assembliesReferenced up front: acdbmgd, accoremgd, acmgd, AdWindows (those the host has loaded)Not referenced by the engine; call clr.AddReference('acdbmgd') before importing Autodesk.AutoCAD in your own code (the pyacad modules do this for you)
Compiled packages (numpy, pandas)NoYes
Shut downWith the sessionNever, only with the process

The CPython DLL is looked for in three places, in order: the "cpython" key in %APPDATA%\pyACAD\config.json, then the PYACAD_CPYTHON environment variable, then the registry under SOFTWARE\Python\PythonCore, current user before local machine. The first two may name a python3XX.dll or the folder that holds one. The registry scan takes the newest of 3.7 to 3.13 and skips 32-bit and ARM64 installs, which cannot load into 64-bit AutoCAD. If nothing is found, the first CPython run fails with No CPython found. Install Python 3 (python.org) or set the "cpython" key in %APPDATA%\pyACAD\config.json to a python3XX.dll or install dir.

Because the interpreter starts once and never stops, changing the "cpython" key takes effect at the next AutoCAD start, not on Reload. The Settings window says so.

A typo in engine: ships a broken button

The value is matched, case-insensitively, against exactly ironpython and cpython, and only when a script is about to run. engine: python3 gives a button that looks normal and fails on every click with Unknown engine 'python3'. in the output window and the log.

Module search paths#

Two sets of folders combine on every run. The per-run set is the bundle's own folder plus its extension's lib\ folder when there is one (for a hook, the hooks\ folder plus lib\; for startup.py, the extension folder plus lib\). The engine-wide set is the bundled standard library, which only IronPython sees, the folder holding the pyacad package, and every *.lib folder found under the extension roots.

Searched first IronPython engine defaults <runtime>\lib bundled stdlib pyacadlib every *.lib folder <bundle folder> <extension>\lib Your folders come after the stdlib: a module named os.py stays harmless. CPython pyacadlib every *.lib folder <bundle folder> <extension>\lib the interpreter's own sys.path CPython stdlib, then site-packages (the bundled <runtime>\lib is never added) Your folders come first: a module named json.py in a bundle shadows the real one. Both engines drop your bundle and lib modules from the cache before every run.
The two engines order the same folders differently. The consequence is CPython-only, and it is a shadowing hazard.
Do not name a module after a stdlib module on CPython

Your bundle folder and lib\ are searched ahead of the interpreter's own sys.path, so a json.py, types.py or select.py beside your script is imported instead of the real one, by your code and by anything else that imports it during the run. Prefix your modules, or put them in a package folder with a name you own.

Both engines put the search paths, sys.stdout and sys.stderr back after every run, so one run never leaks its folders or its output into the next. If the pyacad package cannot be found at all, the runtime logs it at boot and import pyacad fails for every script.

*.lib folders#

A folder named Something.lib beside your *.extension folders, directly under an extension root, is a library extension: the folder itself joins the engine-wide search path, so Something.lib\acme_geometry.py is importable as acme_geometry from every extension, every hook and both engines.

folders
D:\CadTools\                        an extension root
  Acme.lib\
    acme_geometry.py                import acme_geometry, from anywhere
    acme_standards\__init__.py      import acme_standards
  Acme.extension\
    lib\
      acme_local.py                 only this extension's bundles, hooks and startup.py
    Acme.tab\...

The engine-wide paths are fixed when an engine is created, so adding or removing a *.lib folder needs an AutoCAD restart; Reload toasts Library extensions changed to say so. Editing a file already inside one needs a Reload.

The module cache and the edit loop#

Before every run that has per-run search paths, the engine deletes from sys.modules every module whose file sits under the bundle folder or the extension's lib\ folder. script.py itself is never cached: it is read and executed from source on every run, never imported. Together that gives an edit loop with no ceremony in the common case.

You edited … Just run it again script.py config.py modules in the bundle modules in <ext>\lib Dropped from the cache before every run. Reload bundle.yaml, new bundles icons, new hooks, pane.xaml files inside a *.lib the pyacad library Rescan, plus a cache drop for pyacadlib and *.lib. Restart AutoCAD a *.lib added or removed the "cpython" key the "pyacadlib" key the pyACAD runtime Fixed when the engine or the process starts. The standard library is never invalidated on either engine, and never needs to be.
The first column is where you will spend your day. Script and bundle-module edits need no Reload.
Cached modules keep their state

The flip side of the cache: a module in pyacadlib or a *.lib folder is imported once and kept until Reload, so a module-level variable there survives from one run to the next. That is handy for a cache and a trap for anything that should start fresh. For deliberate session state, use script.get_envvar() and script.set_envvar(). Each engine has its own cache: IronPython and CPython never share a module.

Importing#

import pyacad gives you one public name, pyacad.__version__. There are no re-exports and no automatic submodule imports, so you always name what you want. Every shipped bundle uses the same style: from pyacad import with the modules in alphabetical order, at module level, right after the docstring.

Do
"""Exports block attributes to Excel."""
from pyacad import blocks, forms, toast, xl
Don't
"""Exports block attributes to Excel."""
import pyacad
pyacad.blocks.references()   # AttributeError

What an import needs depends on the module:

ModulesNeeds at import timeConsequence
doc, sel, ents, layers, blocks, xrefs, layouts, plot, audit, lisp, units, settings, output, xl Nothing beyond the standard library They import the AutoCAD API (or the runtime) inside the functions that need it, so they import anywhere, including a unit test with no AutoCAD. Their pure functions work there too; an API function raises ImportError when called outside AutoCAD.
script, toast, forms, banner The PyAcad.Runtime assembly Always present wherever a pyACAD script runs.

Outside AutoCAD the API error reads pyacad: could not load the AutoCAD .NET API (acdbmgd, acmgd, accoremgd). This module only works inside an AutoCAD session running pyACAD. Underscored modules (_api, _xlsx, _repl and the rest) are implementation and change without notice.

Document context#

AutoCAD lets code change a drawing only while that drawing is locked. A pyACAD command gets the lock and an undo group from AutoCAD before your first line, so inside a command you just open a transaction:

python
from pyacad import doc

with doc.transaction() as tr:        # commits on success, aborts if the block raises
    db = doc.database()
    ...

Several runs are not commands: hooks, startup.py, a dock pane's script.py and every event handler it wires up, a smartbutton's selfinit run, the interactive console, and any modeless window you leave open after your script returns. Code there that writes to the drawing must take the lock itself, and gets no undo group of its own:

Layer_Panel.dockpane\script.py
from pyacad import doc, layers

def on_freeze(sender, args):
    with doc.lock():                 # a palette click is not a command
        layers.freeze(__pane__.Find('Filter').Text)

__pane__.Find('Freeze').Click += on_freeze

Reading does not need the lock. With no drawing open, doc.active() (and everything built on it) raises RuntimeError('No drawing is open.'); a ribbon click in that state never reaches your script, it toasts Open a drawing first. instead.

For command-line prompts, pyacad.sel wraps the Editor: sel.pick_one(), sel.pick_many() and sel.pick_point() return handles and points, and None or [] on Esc. Handles are hex strings and points are (x, y, z) tuples throughout the library.

One script at a time#

A long script pumps the Windows message loop so AutoCAD can repaint, and pumping dispatches queued input, including another click on a ribbon button. Rather than run two scripts over one drawing, the runtime holds a process-wide gate. A second run while one is going is refused with a toast, <title> is still running, Wait for it to finish, then try again. (pyacad ...) gets nil in that case, and hooks skip their run (it is logged) rather than share the engine, except before-command and after-command, which fire around the very run that holds the gate.

Pumping happens only when you report progress, through output.progress() or a forms.progress() window. A loop that never reports never repaints, and Windows marks AutoCAD "Not Responding" after a few seconds.

python
from pyacad import forms, layers

names = layers.names()
with forms.progress('Checking layers') as p:
    for i, name in enumerate(names):
        p.check()                                   # raises forms.Cancelled on Cancel
        p.update(float(i) / len(names), 'Layer %d of %d' % (i + 1, len(names)))
        ...

Output and errors#

The output window is created lazily, on the first write, and is titled pyACAD - <title>. It is an HTML window when the WebView2 runtime is installed and a plain text window otherwise. A script that writes nothing shows no window at all, which is exactly what you want from a tool that just did its job.

Do
from pyacad import toast

toast.success('Exported 412 blocks', path)
Don't
print('Exported 412 blocks')
# opens a whole window to say one line

Use pyacad.toast for one-line statuses, the output window for real reports, and say nothing on a cancel. See Talking to the user.

An uncaught exception is formatted by the engine that raised it and written to the output window in the error colour and to %APPDATA%\pyACAD\logs\pyACAD.log. The traceback is also kept for the session, which is what the Ask AI panel's Send last error to AI uses. You do not need a top-level try to see a traceback; add one only when you can turn the failure into a better sentence.

Whatever happens, the runtime closes any progress window your script left open, so a crashed script cannot leave AutoCAD behind a modal dialog. Your own try/finally (or the with block) is the first line of defence; this is the backstop.

Threading#

Do not start threads that touch the drawing

Your script runs on AutoCAD's UI thread, and the AutoCAD .NET API is single-threaded. A worker thread calling into a database, a document or the editor corrupts state or crashes AutoCAD, sometimes at once and sometimes on a later unrelated operation. CPython adds a second limit: one interpreter, one lock, one run at a time process-wide.

If a job is slow, the answer is progress reporting and a cancel check, not concurrency. Work that really belongs off the main thread belongs in another process: write the input to disk, run something external, read the result back. For a batch over many drawings, that process is pyacad run.

Encoding#

  • Save scripts as UTF-8. IronPython reads script.py as Python 3 does, UTF-8 unless a coding line says otherwise; the shipped scripts start with # -*- coding: utf-8 -*-.
  • On CPython, and for bundle.yaml and extension.yaml on both engines, pyACAD honours a byte-order mark, then tries strict UTF-8, and only falls back to the Windows ANSI code page when the bytes are not valid UTF-8, so a title saved by an old editor keeps its accents.
  • Output written with print reaches the output window as UTF-8 on both engines.