Bundles
Anatomy of an extension
The folder levels, how discovery walks them, the files that sit outside a bundle, and the ordering and naming rules that decide what your ribbon looks like.
An extension is a folder tree with meaningful suffixes. pyACAD reads that tree at startup and on every Reload, and builds the ribbon, the AutoCAD commands, the keyboard shortcuts, the hooks and the dock panes from it. There is no manifest and no index file. If the folders are right the tools exist, and if a tool is missing the folders are wrong.
This page is the reference for that tree: every level, the files that live outside a bundle
(extension.yaml, lib\, *.lib, hooks\,
startup.py), the ordering rules, how a folder name becomes a title, how identity
is derived, and every reason discovery drops something.
The levels#
Each level is a folder whose name ends in a fixed suffix. Matching ignores case, so
Drafting.Panel and Drafting.panel are the same thing, but the suffix
itself is matched literally.
| Suffix | Lives in | Becomes | May contain |
|---|---|---|---|
*.extension | An extension root | Nothing visible; it is the unit you ship | extension.yaml, lib\, hooks\,
startup.py, *.tab |
*.lib | An extension root, beside the *.extension folders |
Nothing visible; shared Python for every extension | Python modules and packages |
*.tab | *.extension | One ribbon tab | bundle.yaml (read for layout: only), *.panel |
*.panel | *.tab | One panel on that tab | bundle.yaml (read for layout: only), bundles |
| bundles | *.panel (and some inside other bundles) |
Buttons, menus, palettes | Depends on the kind: see the next table |
Tab and panel captions always come from the folder name: a title: key in a
tab's or panel's bundle.yaml is not read. Name the folder what the ribbon should
say (an underscore becomes a space).
The twelve bundle kinds#
A panel understands twelve suffixes. Buttons, stacks and pulldowns covers what each one does; this table is about what each one is made of.
| Suffix | Required | Optional | Holds |
|---|---|---|---|
*.pushbutton | script.py |
config.py, bundle.yaml, icons | nothing |
*.stack | 2 or 3 usable children | bundle.yaml (layout: only) | *.pushbutton only |
*.pulldown | 1 or more usable children | bundle.yaml, icons | *.pushbutton only |
*.splitbutton | 1 or more usable children | bundle.yaml, icons | *.pushbutton only |
*.splitpushbutton | 1 or more usable children | bundle.yaml, icons | *.pushbutton only |
*.nobutton | script.py |
config.py, bundle.yaml | nothing |
*.toggle | script.py |
config.py, bundle.yaml, icons including
icon.on.png and icon.off.png | nothing |
*.smartbutton | script.py |
config.py, bundle.yaml, icons | nothing |
*.urlbutton | bundle.yaml with url: |
icons | nothing |
*.linkbutton | bundle.yaml with plugin: |
icons | nothing |
*.dockpane | pane.xaml |
script.py, bundle.yaml, icons | nothing |
*.slideout | nothing | bundle.yaml (layout: only) |
any of the other eleven, one level deep |
A stack, a pulldown and the two split buttons collect *.pushbutton folders and
nothing else. A *.toggle, *.smartbutton, *.nobutton or
nested container inside one is reported as '<dir>' is ignored: a stack holds only
*.pushbutton folders. (or pulldown, splitbutton,
splitpushbutton). If you have come from pyRevit, note the exact spellings too:
*.nobutton, not *.nobundle, and *.toggle, not
*.togglebutton.
A folder inside a panel whose name contains a dot (not as its first character) is taken
to be an attempted bundle, so a suffix outside the twelve is logged as '<dir>' is
not a bundle kind pyACAD knows (check the folder suffix) - skipped. and counted into
the warning toast shown after the scan: N bundle problems found, quoting the first.
A folder with no dot in its name, such as assets or __pycache__, is
an ordinary folder and says nothing; neither does .git.
A complete extension#
Everything below is either required, optional, or ignored. Nothing is boilerplate.
D:\CadTools\ # an extension root, listed in config.json
Shared.lib\ # optional: a sibling of *.extension, not inside one
acme_common\ # importable from every extension, both engines
__init__.py
CadTools.extension\
extension.yaml # optional: name, default engine, tab order
startup.py # optional: runs at boot and every Reload
lib\ # optional: importable by this extension's scripts only
cadutil\
__init__.py
naming.py
hooks\ # optional: one file per event, file name = event name
doc-opened.py
layer-changed.py
CadTools.tab\ # one ribbon tab, captioned "CadTools"
bundle.yaml # optional: layout of the panels
Drafting.panel\
bundle.yaml # optional: layout: Count, Rename, Layers, More, Extras
Count.pushbutton\ # large button and the command PYCOUNT
script.py # REQUIRED, or the bundle is dropped
config.py # optional: Shift+Click, and the command PYCOUNTCFG
bundle.yaml # optional: title, tooltip, command, shortcut, ...
icon.png # 96x96, light theme, large slot
icon.dark.png # 96x96, dark theme
icon.small.png # 32x32, stacks and menu rows
icon.small.dark.png # 32x32, dark theme
Rename.pushbutton\
script.py
Layers.stack\ # 2 or 3 children; no icon or title of its own
bundle.yaml # optional: layout: Freeze, Thaw
Freeze.pushbutton\
script.py
Thaw.pushbutton\
script.py
More.pulldown\ # a menu button; has its own yaml and icons
bundle.yaml # title, tooltip, keytip, layout
icon.png
Purge.pushbutton\
script.py
Audit.pushbutton\
script.py
Extras.slideout\ # contents move below the panel break, into the flyout
Help.urlbutton\
bundle.yaml # url: https://...
Review.panel\
Compare.pushbutton\ # no yaml: titled "Compare", command PYCOMPARE
script.pyThat tree produces one tab, CadTools, with two panels. Drafting holds
four items on its face in the order its layout: list gives (two large buttons, a
two-row stack and a pulldown), plus a link in the flyout under the panel title.
Review holds one button and needs no list. The tree also registers eight AutoCAD
commands: PYCOUNT, PYCOUNTCFG, PYRENAME,
PYFREEZE, PYTHAW, PYPURGE, PYAUDIT and
PYCOMPARE. The urlbutton is not a command.
PYPURGE and PYAUDIT are exactly the names another extension's
Purge and Audit buttons would derive too. The first bundle in load order
keeps the name and the second gets no command, with a toast saying so. In anything you
share, give generic tools a distinctive command: in bundle.yaml.
How discovery walks the disk#
Discovery is a fixed walk. Every stage looks for one pattern in one folder and ignores everything else it finds there, which is why a misplaced folder usually produces no error: it was never looked at.
Where roots come from#
An extension is only found if it sits directly inside a scanned root. The roots, in load order, are:
- every folder in the
"extensions"array of%APPDATA%\pyACAD\config.json, in the order listed (the Settings window andpyacad.exe extensions addboth edit this array); %APPDATA%\pyACAD\extensions, where the installer puts the shipped extension, if it exists and is not already listed;%PROGRAMDATA%\pyACAD\extensions, for extensions deployed to the whole machine, if it exists, is not already listed, and only administrators can write to it. Anything found there runs for every user on the PC, hooks andstartup.pyincluded, so a machine-wide folder that ordinary users could write to is skipped with a log line. Listing it in"extensions"loads it regardless.
Inside each root, *.extension folders load in name order, ignoring case. Load
order matters in two places: which bundle keeps a contested command name, and where a tab
shared by several extensions sits.
D:\CadTools\ Drafting.extension\ Plotting.extension\
D:\CadTools\
Team\
Drafting.extension\ # never found
Archive\
Plotting.extension\ # never foundTo group extensions in subfolders, register each subfolder as its own root instead.
extension.yaml#
Optional, and it understands exactly three keys. Anything else in the file is ignored.
name: CadTools
engine: ironpython
layout:
- CadTools
- Plotting| Key | Effect | Default |
|---|---|---|
name |
The extension's identity. It is not shown on the ribbon; it is half of every tab id the
extension creates, the label on its hooks and startup.py in the log, and
the key used to spot the same extension loaded from two roots. |
The folder name with .extension removed |
engine |
The default engine for every bundle, hook and startup.py in the
extension. A bundle's own engine: still wins. |
ironpython |
layout |
The order of the extension's tabs, as a block list naming *.tab folders.
See Ordering panels and buttons. |
Tab folders in name order |
Setting engine: cpython here is the clean way to write a whole extension
against CPython without repeating the key in every bundle.yaml. The extension's
tab order lives here; the order of the panels on a tab lives in that tab's own
bundle.yaml.
The lib folder (one extension)#
A folder named lib directly inside the .extension folder is added
to the module search path of every bundle, hook and startup.py in that extension.
It is the place for code more than one tool imports: helpers, vendored pure-Python packages.
"""Renames the drawing's layers to the office standard."""
from cadutil import naming # resolved from CadTools.extension\lib
from pyacad import layers, toast
renamed = 0
for name in layers.names():
wanted = naming.standard(name)
if wanted != name and not layers.exists(wanted):
layers.rename(name, wanted)
renamed += 1
toast.success('%d layer(s) renamed' % renamed)A bundle's module search path is, in order: the Python standard library and the
pyacad library, every *.lib folder, the bundle's own folder, then
the extension's lib\. A module beside script.py therefore shadows
one of the same name in lib\.
Before every run, modules loaded from the bundle folder or the extension's
lib\ are dropped from the engine's module cache, so an edit to a helper in
lib\ takes effect the next time any button runs, with no Reload.
A lib folder inside a .tab, a .panel or a bundle is an
ordinary folder and is never added to the search path. There is exactly one per extension and
it lives beside extension.yaml.
*.lib folders (every extension, both engines)#
A different thing with a similar name: a folder ending in .lib sitting
directly in an extension root, beside your *.extension folders rather than inside
one. The folder itself joins the global module search path shared by
every extension and both engines, so the modules and
packages directly inside it import by name.
D:\CadTools\ # an extension root
Shared.lib\ # a library folder, not an extension
acme_common\
__init__.py
units.py
Drafting.extension\
...
Plotting.extension\
... # both can "import acme_common"The set of *.lib folders is fixed into the engine configuration once, at boot.
A Reload rescans it and, if the set changed, toasts Library extensions changed
(Added or removed *.lib folders apply after an AutoCAD restart.) and keeps the old
set. Edits to files already inside an existing *.lib folder need only a
Reload: it drops the cached modules under every known *.lib folder and under the
pyacad library, so the next run imports your change.
hooks\#
A hooks folder directly inside the .extension folder holds event
scripts. Each file is named for the event it answers, in kebab case, and runs by itself when
that event fires, with no click.
CadTools.extension\
hooks\
doc-opened.py # a drawing was opened
layer-changed.py # a layer was added, renamed, changed or removed
command-ended.py # any AutoCAD command finishedThere are 20 event names: doc-created, doc-opened,
doc-activated, doc-closing, doc-saved,
command-will-start, command-ended,
command-cancelled, command-failed, before-command,
after-command, selection-changed, object-appended,
object-modified, object-erased, layer-changed,
layout-switched, app-init, app-quit and
system-variable-changed. A .py file in hooks\ with any
other name is logged (Hook '<file>': unknown event '<name>' -
skipped., with the valid names listed) and does not run. Hooks run on the extension's
default engine with the extension's hooks\ and lib\ folders on the
search path. Hooks covers what each event means, debouncing, and
the rule that disables a hook after repeated failures.
startup.py#
A file named exactly startup.py directly inside a .extension
folder runs once at boot and again on every Reload, before the ribbon is built.
"""Checks the shared helpers resolve before anyone clicks a button."""
from pyacad import toast
try:
import cadutil
except ImportError:
toast.warning('cadutil not found', 'Check CadTools.extension\\lib\\cadutil.')startup.py runs on the extension's default engine with three globals:
__file__, __pyacad__, and __runner__, which is
True only when pyacad run starts it.
Its search path is the extension folder itself, then its lib\, plus the global
*.lib folders. Printed output goes to the pyACAD log, labelled
[startup <extension name>], not to a window: there is no button behind it
to title a window after.
An exception in startup.py is logged and toasted (<name> startup.py
failed), but the extension's ribbon still builds. A broken startup.py costs
whatever it was meant to set up, not the extension. Every extension's
startup.py runs, in load order, before any ribbon tab is built.
Ordering panels and buttons#
At every level, order comes from a layout: list in the container's own file:
extension.yaml for the tabs of an extension, and the bundle.yaml
inside a .tab, .panel, .stack, .pulldown,
.splitbutton, .splitpushbutton or .slideout for the
folders inside it. The list names the child folders in the order you want them. There is no
order: key on the children, and the order you created the folders in is
irrelevant.
layout:
- Count
- Rename
- Layers
- More
- ExtrasThe rules:
- Listed children come first, in list order.
- Unlisted children follow, in case-insensitive name order, so a folder you forgot to list is never hidden. It just lands after the ones you did list.
- An entry may name the folder with or without its suffix:
CountandCount.pushbuttonboth match. Matching ignores case, and ignores a legacyNN_prefix on either side. - An entry that names no folder in that container is reported in the after-scan toast and
the log, and skipped:
The layout in '<dir>' names 'Cout', but no such folder is there - ignored. - An entry listed twice counts once, at its first position.
Inserting a tool at position three is one new line in the list. Nothing gets renamed and nothing else moves. A panel does not group by kind: buttons, stacks, pulldowns and the rest are all placed by the same list, so a stack sits between two large buttons simply by being listed there.
Before layout: existed, folders were ordered by name and carried a prefix of
two or more digits and an underscore, as in 01_Count.pushbutton, which sorted the
folder and was stripped from the title. That still works: without a layout: list
the folders sort by name, prefix included, and the prefix never reaches the title, the derived
command name or the bundle key. New extensions should use the list. The shipped extension uses
no prefixes, and every container in it lists every child.
From folder name to title#
When a bundle has no title: in its bundle.yaml and its script has
no __title__, the title is derived from the folder name: strip the suffix, strip a
leading run of two or more digits followed by an underscore, then turn every
remaining underscore into a space. Tabs and panels always take their caption this way.
1_Keep keeps its name. A new extension has no reason to carry a prefix at all:
order it with layout: and name the folder what the button should say.The full title precedence for a script bundle, most specific first, is
title: in bundle.yaml, then a top-level
__title__ = '...' in script.py, then the folder name. Urlbuttons,
linkbuttons, dockpanes and the pulldown family have no script to scan, so they go straight
from title: to the folder name. The bundle.yaml
reference covers every key.
When a bundle does not appear#
Discovery never throws. It drops what it cannot use and carries on, which keeps one broken
folder from taking down the whole ribbon. Every drop is written to
%APPDATA%\pyACAD\logs\pyACAD.log. Some are also counted into the
N bundle problems found toast after the scan; the rest are in the log only, which is
the first place to look when a tool is missing and nothing said why.
| Symptom | Cause | Log line | Toast |
|---|---|---|---|
| One button missing | The folder has no script.py |
'<dir>' has no script.py - skipped. | Yes |
| One button missing, suffix looks fine to you | The suffix is not one of the twelve, for example .pushbuttons |
'<dir>' is not a bundle kind pyACAD knows (check the folder suffix) -
skipped. | Yes |
| A child missing from a stack or menu | It is not a *.pushbutton |
'<dir>' is ignored: a stack holds only *.pushbutton folders. |
Yes |
A layout: entry has no effect |
It names a folder that is not there | The layout in '<dir>' names '<entry>', but no such folder is there -
ignored. | Yes |
| A whole stack missing | It ended up with fewer than 2 or more than 3 usable buttons, often because one child
lost its script.py |
Stack '<dir>' holds N button(s); a stack needs 2 or 3 - skipped. |
No |
| A pulldown or split button missing | No child *.pushbutton had a script.py |
Pulldown '<dir>' has no usable pushbuttons - skipped. | No |
| A urlbutton missing | No url:, or an empty one |
Urlbutton '<dir>' has no url: in bundle.yaml - skipped. | No |
| A linkbutton missing | No plugin:, or an empty one |
Linkbutton '<dir>' has no plugin: in bundle.yaml - skipped. | No |
| A dock pane's toggle missing | The folder has no pane.xaml |
Dockpane '<dir>' has no pane.xaml - skipped. | No |
| A slideout's contents missing | The .slideout sits inside another .slideout |
Slideout '<dir>' sits inside another slideout - skipped. | No |
| A panel missing | Every item in it was skipped | None for the panel; the item lines above explain it | No |
| A tab missing | Every panel in it was skipped | None for the tab | No |
| Nothing at all from an extension | A misspelled .extension, .tab or .panel, or the
extension is not directly inside a scanned root |
None: the walk never looked at the folder | No |
| A second copy of an extension missing | An extension with the same name already loaded from an earlier root |
Extension '<name>' at '<dir>' is already loaded from an earlier
root - skipped. | No |
Every build logs Ribbon built: N tab(s), M button(s). and
Commands registered: N (...) with the names. If either is lower than you expect,
something above is the reason. Troubleshooting walks
through the rest.
Slideouts#
A *.slideout folder is not a bundle and draws nothing itself. Whatever panel
items it contains are placed after a panel break, so AutoCAD keeps them in the flyout that
opens from the panel title (the caption with a small arrow along the bottom of the panel),
like the native panels that expand. It is where tools that matter least go, without costing
ribbon width.
Drafting.panel\
bundle.yaml # layout: Count, Rename
Count.pushbutton\ # always visible
Rename.pushbutton\ # always visible
Extras.slideout\ # everything in here is in the flyout
bundle.yaml # layout: Settings, Help
Settings.pushbutton\
script.py
Help.urlbutton\
bundle.yaml- A slideout may hold any of the other eleven kinds: stacks, pulldowns, toggles, dock panes and the rest.
- A panel may have more than one
.slideout. Their contents join one flyout, in the order the panel'slayout:list gives the slideouts; there is only ever one break per panel. - The folder name is never shown. Its
bundle.yamlis read for alayout:list and nothing else. Where the slideout sits in the panel's own list does not change anything visible, because its contents always render after the break. - One level only. A
.slideoutinside a.slideoutis logged and its contents are dropped. - A panel whose items are all in a slideout still renders, with an empty face and a full flyout.
- Bundles in a slideout are ordinary in every other respect. They are commands, they take
shortcuts, keytips and
context:, and their bundle key keeps the.slideoutfolder in the path.
Names and identity#
Three identities are derived from the tree, and all three matter once you ship an extension to other people.
Tab id and merging#
Every ribbon tab needs a unique id. pyACAD builds one from the extension name and the tab
folder name, replacing every character that is not a letter or digit with an underscore:
PYACAD_TAB_ followed by <extension name>_<tab folder>
sanitised. CadTools.extension\CadTools.tab becomes
PYACAD_TAB_CadTools_CadTools.
Tabs, and panels inside them, also join by caption across extensions: a
pyACAD.tab in your extension adds to the pyACAD tab instead of drawing a second
one, and a Layers.panel inside it adds your buttons to the end of the shipped
Layers panel. Matching ignores case. A shared tab sits where the first extension in load
order put it and keeps that extension's id; inside anything it shares, the shipped
pyACAD extension's panels and items come first and other extensions follow in load
order.
%APPDATA%\pyACAD\extensions\
pyACAD.extension\ # the installer's; every update replaces it
My pyACAD.extension\ # yours; updates never touch it
pyACAD.tab\
Layers.panel\
Layer_Report.pushbutton\ # lands at the end of the pyACAD Layers panel
Team.panel\
Publish.pushbutton\ # a new panel on the pyACAD tabThis is why nothing of yours belongs inside pyACAD.extension: the installer
replaces that folder on every update. It backs up what it finds there and moves bundles and
panels you added into My pyACAD.extension first, but a file added inside one of
pyACAD's own bundles, or an edit to one of its files, can only go to the backup.
Command name#
Every pushbutton-shaped bundle (pushbutton, nobutton, toggle, smartbutton, and the
pushbuttons inside stacks, pulldowns and split buttons) is an AutoCAD command. The name is
command: from bundle.yaml, or PY plus the resolved
title's letters and digits uppercased, capped at 64 characters; PYTOOL when the
title has none. A bundle with config.py also gets the same name plus
CFG. Names are handed out in load order and the first claim wins; the names
PYACAD, PYACADRELOAD, PYACADCONSOLE,
PYACADRUN and PYACADSETTINGS are reserved. A loser, or a name AutoCAD
already answers to, gets no command, a toast says which, and its button runs the script
directly. Commands, macros and AutoLISP has the details.
Bundle key#
Each bundle also carries a stable key: its path relative to the .extension
folder, with forward slashes, keeping every suffix, with any legacy NN_ prefix
dropped from every segment.
pyACAD.tab/Layers.panel/Layer_Snapshot.pushbutton
pyACAD.tab/AI_(beta).panel/Setup.stack/AI_Settings.pushbutton
CadTools.tab/Drafting.panel/Extras.slideout/Settings.pushbuttonThat key is what identifies a bundle wherever pyACAD remembers something about it:
- user shortcut overrides under
shortcuts.bindingsinconfig.json(Shortcuts); - a toggle's on/off state for the session;
- a dock pane's palette GUID, which is how AutoCAD remembers where the palette was docked and how big it was.
Renaming a folder, moving it to another panel, or moving it into or out of a slideout therefore makes a new key: a user's shortcut override no longer applies, and a dock pane forgets its saved position.
pyACAD.tab/02_Layers.panel/01_Layer_Snapshot.pushbutton and
pyACAD.tab/Layers.panel/Layer_Snapshot.pushbutton are the same key, and a key
written into config.json with prefixes is normalised the same way on load, so
adding, changing or removing prefixes never orphans an override. Renaming the folder itself,
Layer_Snapshot to Layer_States, is still a new key.
Two extensions can produce the identical key, for example a
Tools.tab/Main.panel/Viewer.dockpane in each. A shortcut override would then
apply to the first matching bundle only, and the two palettes would share one GUID and one
saved position. Name your tab, panel or bundle folders distinctively enough that the relative
path is yours alone.
What to check when you add a bundle#
- The extension folder sits directly in a registered root.
- Every suffix is spelled correctly and appears once per folder name.
- Every
.pushbutton(and nobutton, toggle, smartbutton) contains ascript.py. - Every
.stackholds 2 or 3 of them, and nothing but pushbuttons sits in a stack or a menu. - Every entry in a
layout:list names a folder that exists. - The title does not derive a command name some other tool already has.
- You clicked Reload (or typed
PYACADRELOAD).