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.

SuffixLives inBecomesMay contain
*.extensionAn extension root Nothing visible; it is the unit you ship extension.yaml, lib\, hooks\, startup.py, *.tab
*.libAn extension root, beside the *.extension folders Nothing visible; shared Python for every extension Python modules and packages
*.tab*.extensionOne ribbon tab bundle.yaml (read for layout: only), *.panel
*.panel*.tabOne 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.

SuffixRequiredOptionalHolds
*.pushbuttonscript.py config.py, bundle.yaml, iconsnothing
*.stack2 or 3 usable children bundle.yaml (layout: only)*.pushbutton only
*.pulldown1 or more usable children bundle.yaml, icons*.pushbutton only
*.splitbutton1 or more usable children bundle.yaml, icons*.pushbutton only
*.splitpushbutton1 or more usable children bundle.yaml, icons*.pushbutton only
*.nobuttonscript.py config.py, bundle.yamlnothing
*.togglescript.py config.py, bundle.yaml, icons including icon.on.png and icon.off.pngnothing
*.smartbuttonscript.py config.py, bundle.yaml, iconsnothing
*.urlbuttonbundle.yaml with url: iconsnothing
*.linkbuttonbundle.yaml with plugin: iconsnothing
*.dockpanepane.xaml script.py, bundle.yaml, iconsnothing
*.slideoutnothing bundle.yaml (layout: only) any of the other eleven, one level deep
Only pushbuttons go inside a stack or a menu

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 misspelled suffix is reported, not swallowed

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.

A realistic extension, annotated
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.py

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

Short titles make generic command names

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.

What is scanned What is passed over 1 Extension roots config.json "extensions", then defaults A root that is missing or unreadable is skipped with a log line; the other roots still load. 2 Extensions and libraries *.extension *.lib Directly under the root only, never recursive. An extension one folder deeper is invisible. 3 Inside an extension extension.yaml lib\ hooks\ startup.py *.tab Every other file or folder is left alone. A hooks\ file with an unknown event name is logged. 4 Panels *.panel A .panel nested inside another .panel is never reached; panels do not nest. 5 Panel items, and slideout contents *.pushbutton *.stack ... *.slideout All twelve suffixes match here; a dotted folder name that matches none of them is reported. 6 Children of a stack, pulldown or split *.pushbutton Any other bundle-looking folder is reported and ignored. Nesting stops here.
Each stage looks for one pattern in one folder. Anything that does not match is not an error, it is simply not part of the walk.

Where roots come from#

An extension is only found if it sits directly inside a scanned root. The roots, in load order, are:

  1. every folder in the "extensions" array of %APPDATA%\pyACAD\config.json, in the order listed (the Settings window and pyacad.exe extensions add both edit this array);
  2. %APPDATA%\pyACAD\extensions, where the installer puts the shipped extension, if it exists and is not already listed;
  3. %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 and startup.py included, 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.

Do
D:\CadTools\
  Drafting.extension\
  Plotting.extension\
Don't
D:\CadTools\
  Team\
    Drafting.extension\    # never found
  Archive\
    Plotting.extension\    # never found

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

CadTools.extension\extension.yaml
name: CadTools
engine: ironpython
layout:
  - CadTools
  - Plotting
KeyEffectDefault
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.

CadTools.extension\CadTools.tab\Drafting.panel\Rename.pushbutton\script.py
"""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\.

Edits are picked up on the next run

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.

lib is only special at extension level

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.

folders
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"
Adding or removing a *.lib folder needs a restart

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.

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

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

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

A failure still lets the buttons load

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.

Drafting.panel\bundle.yaml
layout:
  - Count
  - Rename
  - Layers
  - More
  - Extras

The 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: Count and Count.pushbutton both match. Matching ignores case, and ignores a legacy NN_ 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.

The older way: a numeric prefix

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.

^\d{2,}_ is removed from the front, then every _ that survives becomes a space. Folder name Title Command Why 01_Count Count PYCOUNT Two digits: a prefix. 02_Purge_All Purge All PYPURGEALL Inner _ is a space. 1_Keep 1 Keep PY1KEEP One digit is not a prefix, so it stays. 010_Blocks_to_Excel Blocks to Excel PYBLOCKSTOEXCEL Three digits too. The command name follows the resolved title, so it never carries the prefix either.
The rule is deliberately two digits or more, so a tool genuinely named 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.

SymptomCauseLog lineToast
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 missingNo url:, or an empty one Urlbutton '<dir>' has no url: in bundle.yaml - skipped.No
A linkbutton missingNo 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 itNo
A tab missing Every panel in it was skipped None for the tabNo
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 folderNo
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
Read the counts in the log

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.

folders
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's layout: list gives the slideouts; there is only ever one break per panel.
  • The folder name is never shown. Its bundle.yaml is read for a layout: 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 .slideout inside a .slideout is 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 .slideout folder 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.

folders
%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 tab

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

text
pyACAD.tab/Layers.panel/Layer_Snapshot.pushbutton
pyACAD.tab/AI_(beta).panel/Setup.stack/AI_Settings.pushbutton
CadTools.tab/Drafting.panel/Extras.slideout/Settings.pushbutton

That key is what identifies a bundle wherever pyACAD remembers something about it:

  • user shortcut overrides under shortcuts.bindings in config.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.

Renumbering is free

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.

The key does not include the extension's name

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 a script.py.
  • Every .stack holds 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).