Bundles
Hooks
Scripts that run themselves when something happens in AutoCAD, plus the startup.py every extension may carry.
Every tool covered so far runs because someone clicked it or typed its command. A hook
runs because a drawing opened, the current layer changed, or AutoCAD is shutting down. It
lives in a hooks folder at the root of an extension, is named after the event it
answers, and needs no bundle.yaml, icon or ribbon presence.
MyTools.extension\
extension.yaml
startup.py # runs at boot and on every Reload
hooks\
doc-opened.py # runs every time a drawing is opened from disk
layer-changed.py # runs when the current layer changes
MyTools.tab\The file name, minus .py, must be one of the twenty event names below
(case does not matter). Any other .py file in hooks is skipped at
load time with a log line that lists the valid names:
Hook 'D:\CAD\MyTools.extension\hooks\doc-open.py': unknown event 'doc-open' - skipped. Valid names: doc-created, doc-opened, ...The twenty events#
Every event has one AutoCAD (or pyACAD) source, listed here exactly as the runtime wires it. "Every open drawing" means pyACAD subscribes to each drawing as it arrives, so an event in a drawing that is not in front still fires.
Drawings#
| File name | Fires when | Source |
|---|---|---|
doc-created.py |
A new, never-saved drawing is created (NEW, QNEW, the
+ on the drawing tabs). |
DocumentCreated for a drawing that is not named |
doc-opened.py |
A drawing is opened from a file. | DocumentCreated for a named drawing |
doc-activated.py |
A drawing becomes the active one: switching drawing tabs, and after a drawing is created or opened. | DocumentActivated |
doc-closing.py |
A drawing is about to close. | DocumentToBeDestroyed |
doc-saved.py |
A save of any open drawing completes (SAVE, QSAVE,
SAVEAS). |
The drawing database's SaveComplete |
layout-switched.py |
The Model tab or a layout tab is switched to. | The drawing's LayoutSwitched |
The drawing that is open when pyACAD boots is already there before hooks are wired, so it raises none of these. Only drawings created, opened or switched to afterwards do.
Commands#
| File name | Fires when | Source |
|---|---|---|
command-will-start.py |
Any AutoCAD command is about to start, in any open drawing. | The drawing's CommandWillStart |
command-ended.py | Any command finished normally. | CommandEnded |
command-cancelled.py | Any command was cancelled. | CommandCancelled |
command-failed.py | Any command failed. | CommandFailed |
before-command.py |
A pyACAD script is about to run: a click, a chord, a typed PY command,
a Shift+Click config run, PYACAD or (pyacad ...) from LISP. |
pyACAD itself |
after-command.py |
That pyACAD script has finished, whether it succeeded or raised. | pyACAD itself |
The four command-* events are AutoCAD's and cover every command, native ones
(LINE, ZOOM, QSAVE) and pyACAD's own PY
commands alike. They do not say which command it was. before-command and
after-command are pyACAD's, wrap only pyACAD script runs, and do say which tool:
see What a hook receives. A run refused before it starts
(another script still running, or the host version gate) raises neither.
Selection, objects and layers#
| File name | Fires when | Source |
|---|---|---|
selection-changed.py |
The pickfirst selection (the grips you see) changes. Debounced. | The drawing's ImpliedSelectionChanged |
object-appended.py |
An object is added to any open drawing's database. | The database's ObjectAppended |
object-modified.py |
An object in any open drawing's database is modified. Debounced. | The database's ObjectModified |
object-erased.py |
An object is erased, or un-erased (by UNDO, say): AutoCAD raises the
same event for both and pyACAD does not tell them apart. |
The database's ObjectErased |
layer-changed.py |
The current layer changes, that is the CLAYER system variable. |
SystemVariableChanged for CLAYER |
It fires when a different layer is made current, and for nothing else. Creating, renaming,
deleting, freezing, locking or recolouring a layer does not fire it (those edits surface, if
anywhere, as object-modified or object-appended on the layer table
record). CLAYER changes are routed here instead of to
system-variable-changed, so a system-variable-changed hook never sees
them.
The application#
| File name | Fires when | Source |
|---|---|---|
app-init.py |
pyACAD has finished starting (once AutoCAD's ribbon is ready), and again after every Reload, once the Reload command has finished. | pyACAD itself |
app-quit.py | AutoCAD begins to quit. | Application.BeginQuit |
system-variable-changed.py |
Any system variable other than CLAYER changes value. Debounced. |
Application.SystemVariableChanged |
AutoCAD changes system variables constantly while you work (the last point, the current
command, view state), so this hook is debounced and fires once things settle. The event
carries no variable name, so a hook that cares about one variable has to read it and compare
with what it saw last time (keep that in pyacad.script.set_envvar).
What a hook receives#
A hook is an ordinary script run with four globals:
| Global | Value |
|---|---|
__event__ |
A dict with two keys: 'name', the event name
('doc-saved'), and 'command', the bundle key of the pyACAD tool
for before-command and after-command
('pyACAD.tab/Output.panel/Batch_Plot.pushbutton'), else
None. |
__file__ | The hook script's own path. |
__pyacad__ | The runtime host object, as in any script. |
__runner__ |
True only when the hook fires inside a pyacad run
session. |
That is all. __event__ never names the drawing, the object, the command or the
variable involved: a hook finds out what it needs from the drawing itself. A run of a
.py file through PYACAD has no bundle, so its
'command' is None.
A hook is not a command. It has no __commandpath__, __title__ or
__args__, and pyacad.script calls that ask "which command am I?"
(get_config, get_bundle_file, store_data,
get_toggle_state) answer for whichever tool ran last. Store a hook's
own options with pyacad.settings under a key you choose. The one exception is
pyacad.script.get_logger, whose lines are labelled
hook <extension>:<event>.
Examples#
"""Says which layer is now current."""
from pyacad import doc, toast
toast.info('Current layer: %s' % doc.sysvar('CLAYER'))"""Logs every pyACAD tool that finishes, for a usage tally."""
from pyacad import script
script.get_logger().info('finished %s' % __event__['command'])A hook runs from an AutoCAD event handler, outside any command, so the drawing is
not locked for it. Reading is fine; anything that writes to the drawing goes
inside doc.lock():
"""Gives the drawing in front a NOTES layer when it has none."""
from pyacad import doc, layers
if not layers.exists('NOTES'):
with doc.lock():
layers.create('NOTES', color=2)That example also shows why to choose events carefully: it edits every drawing you switch to, which marks each one as changed. A hook that only reports is always the safer default.
Both fire from DocumentCreated, and nothing in __event__ says which
drawing that was. Code that works on "the active drawing" should hang off
doc-activated, which fires once the new drawing is in front.
Debouncing#
Three events can fire many times for one user action, so they are debounced:
selection-changedobject-modifiedsystem-variable-changed
Each occurrence restarts a 250 ms timer, and the hooks run once, 250 ms after the last
occurrence of the burst. A window-select that changes the pickfirst set a hundred times runs
selection-changed.py once. Every other event runs its hooks immediately, once
per occurrence.
They run once per object. Pasting a thousand entities, exploding a block or erasing a
window full of objects runs an object-appended.py or object-erased.py
hook a thousand times, synchronously, on AutoCAD's UI thread. They are also not limited to
entities: dictionaries, layer records and other non-graphical objects count.
Object events cost nothing unless asked for#
AutoCAD raises the three object-* events for every object, thousands of times
for one regen or block insert. pyACAD only passes them on when at least one loaded extension
ships an object-appended.py, object-modified.py or
object-erased.py hook; otherwise they are dropped at the first line of the
handler. This is decided at boot and on every Reload, so adding or removing such a hook needs
a Reload to take effect, like any other hook change.
When hooks are skipped#
- While a pyACAD script runs. Long scripts pump the message loop, which
can deliver AutoCAD events mid-run. Running a hook's Python on top of the script's would
share one engine, so every hook except
before-commandandafter-commandis skipped and the log saysHook MyTools:selection-changed skipped: 'Batch Plot' is running.Skipped events are not replayed later. The one exception isapp-initafter a Reload, which is held until the Reload command finishes and then delivered. - While another hook runs. Only one hook runs at a time, and an event a
hook causes directly (it set the current layer, say) starts no hooks. A debounced event
a hook caused (it modified an object or changed the selection) is marked as hook-caused
when its timer fires, and hooks skip it. So an
object-modifiedhook that edits an object does not loop. Ribboncontext:greying still sees every event. - After three failures. See below.
- With no one at the screen. In AutoCAD Core Console (where
pyacad runworks) and in an AutoCAD started through COM automation, the interactive start-up stages are skipped, and hooks are among them, so no hook is wired. For the COM case,"automatedBoot": "full"inconfig.jsonbrings them back.
Three failures and it is disabled#
A hook that raises is logged with its traceback. Three failures in a row (a success resets the count) disable that hook for the rest of the session, with a warning toast:
Hook MyTools:doc-saved disabled after 3 failures
Fix the script, then Reload pyACAD to re-enable it.The hook's identity is <extension name>:<event>, where the
extension name is name: from extension.yaml or the folder name
without .extension. Reload clears every count and re-enables
every hook.
The time budget#
Hooks run synchronously on AutoCAD's UI thread: while one runs, AutoCAD is frozen. A hook
that takes longer than 500 ms gets a line in the log,
Hook MyTools:doc-saved took 1240ms (soft budget 500ms) - keep hooks fast., but is
not stopped. Keep hooks to a few quick reads, a toast or a log line. Put anything slow behind
a button instead.
Where a hook's output goes#
print()goes to%APPDATA%\pyACAD\logs\pyACAD.log, one line each, prefixed with[hook MyTools:doc-saved]. A hook never opens the output window.pyacad.toastworks and is the right way to tell the user something.- Dialogs work, but a modal dialog from an event handler interrupts whatever the user was doing. Avoid them.
Engine and imports#
A hook runs on its extension's engine, engine: in extension.yaml
(IronPython when absent). It can import from its own hooks folder, from the
extension's lib folder, from pyacad and from any *.lib
folder beside the extensions. When several extensions ship a hook for the same event, they run
one after another in extension load order.
Hooks are found when the extension is loaded, so adding, renaming or removing a hook file needs a Reload. Editing an existing one does not: the file is read on every run.
startup.py#
An extension may carry one startup.py at its root. It runs with no event and
no click:
- At boot, once AutoCAD's ribbon is ready, and on every Reload. Write it so running twice in a session is harmless.
- Before the ribbon is built (and before hooks are configured and commands registered), for every extension, so anything it sets up is in place before the first button exists.
- In
pyacad run, once before the first script of the run, with__runner__set toTrue.
It receives __file__, __pyacad__ and __runner__,
runs on the extension's engine, and can import from the extension folder and its
lib folder. Like a hook it has no command context and no document lock, and
there may be no drawing open at all. Its print() output goes to the log under
[startup MyTools].
"""Shares the office standards folder with every MyTools script this session."""
import os
from pyacad import script
script.set_envvar('mytools.standards', os.path.join(os.path.dirname(__file__), 'standards'))If startup.py raises, the traceback goes to the log and a warning toast says
MyTools startup.py failed, See the log; the extension's buttons still
loaded. One broken startup script never costs an extension its ribbon.