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.

folders
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:

%APPDATA%\pyACAD\logs\pyACAD.log
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 nameFires whenSource
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 nameFires whenSource
command-will-start.py Any AutoCAD command is about to start, in any open drawing. The drawing's CommandWillStart
command-ended.pyAny command finished normally. CommandEnded
command-cancelled.pyAny command was cancelled. CommandCancelled
command-failed.pyAny 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 nameFires whenSource
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
layer-changed is CLAYER only

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 nameFires whenSource
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.pyAutoCAD begins to quit. Application.BeginQuit
system-variable-changed.py Any system variable other than CLAYER changes value. Debounced. Application.SystemVariableChanged
system-variable-changed does not say which variable

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:

GlobalValue
__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.

No command context

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#

hooks\layer-changed.py
"""Says which layer is now current."""
from pyacad import doc, toast

toast.info('Current layer: %s' % doc.sysvar('CLAYER'))
hooks\after-command.py
"""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():

hooks\doc-activated.py
"""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.

doc-opened and doc-created do not name the new drawing

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-changed
  • object-modified
  • system-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.

object-appended and object-erased are not debounced

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-command and after-command is skipped and the log says Hook MyTools:selection-changed skipped: 'Batch Plot' is running. Skipped events are not replayed later. The one exception is app-init after 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-modified hook that edits an object does not loop. Ribbon context: greying still sees every event.
  • After three failures. See below.
  • With no one at the screen. In AutoCAD Core Console (where pyacad run works) 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" in config.json brings 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:

text
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.toast works 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 to True.

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

MyTools.extension\startup.py
"""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.