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.
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:
| Run | When | In a command? |
|---|---|---|
A bundle's script.py | Click, chord, typed command, macro,
PYACAD, (pyacad ...) | Yes (see below) |
A bundle's config.py | Shift+Click, or its CFG
command | Yes |
A *.smartbutton's selfinit run | Every ribbon build, before any click | No |
A *.dockpane's script.py | Once, when its palette is built (boot and every Reload) | No |
hooks\<event>.py | When the AutoCAD event fires | No |
startup.py | Boot and every Reload, before the ribbon builds | No |
A script under pyacad run | In AutoCAD Core Console | Yes, 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.
| Name | Type | What 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:
| Run | Globals |
|---|---|
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:
"""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:
| Member | Does |
|---|---|
.Version | The pyACAD version, such as 0.1.0. |
.ActiveDocumentPath | The active drawing's full path,
'untitled' when it was never saved, None with no drawing
open. |
.IsDarkTheme | True when AutoCAD's UI is dark. Theme your own dialogs with this. |
.IsRunner | True under pyacad run. |
.ScriptPath, .CommandPath, .CommandTitle,
.CommandBundleKey | The 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. |
.HasOpenOutput | True 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. |
.LogSource | The 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:
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 restartThe 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.
"""Counts the selected objects.""" from pyacad import sel, toast handles = sel.current() toast.info('%d selected' % len(handles))
"""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.
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:
- The
engine:key in the bundle'sbundle.yaml. - The
engine:key in the extension'sextension.yaml, a default for every bundle, hook andstartup.pyin it. ironpython.
title: Quantities
engine: cpythonHooks 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.
ironpython | cpython | |
|---|---|---|
| Version | IronPython 3.4 (3.4.2) | The installed CPython, 3.7 to 3.13, 64-bit |
| Ships with pyACAD | Yes, with its standard library | No, it finds an installed Python |
| Hosting | The DLR, one engine per session | Python.NET 3.0.5, one interpreter per process |
| Created | On first use | On the first run of a CPython script |
| Per run | A fresh scope | A fresh scope |
| AutoCAD assemblies | Referenced 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) | No | Yes |
| Shut down | With the session | Never, 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.
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.
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.
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.
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.
"""Exports block attributes to Excel.""" from pyacad import blocks, forms, toast, xl
"""Exports block attributes to Excel.""" import pyacad pyacad.blocks.references() # AttributeError
What an import needs depends on the module:
| Modules | Needs at import time | Consequence |
|---|---|---|
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:
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:
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_freezeReading 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.
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.
from pyacad import toast toast.success('Exported 412 blocks', path)
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#
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.pyas Python 3 does, UTF-8 unless a coding line says otherwise; the shipped scripts start with# -*- coding: utf-8 -*-. - On CPython, and for
bundle.yamlandextension.yamlon 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
printreaches the output window as UTF-8 on both engines.