Bundles

The bundle.yaml reference

Every key the runtime reads from bundle.yaml and extension.yaml, which kinds read it, what it falls back to, and the exact rules of the very small parser that reads the file.

A bundle is a folder. bundle.yaml is the optional file inside it that names the button, describes it, picks its command name and engine, binds its keys and gates it. Nothing in it is required for a script bundle: one with no bundle.yaml still produces a working button and command, because every value has a fallback. Two kinds do need it: a *.urlbutton needs url: and a *.linkbutton needs plugin:.

folders
Count.pushbutton\
  script.py       # required: no script.py, no button
  bundle.yaml     # optional: metadata only
  icon.png        # optional

The file is read at discovery: at AutoCAD startup and on every Reload. Change it and click Reload on the pyACAD panel (or type PYACADRELOAD); there is no need to restart AutoCAD. The same reader handles extension.yaml and the bundle.yaml inside tabs, panels and containers.

It is not YAML#

This is the single most useful thing to know about the file. pyACAD does not use a YAML library. It uses a small reader that walks the file one line at a time and produces a flat dictionary of strings. The name and the .yaml extension are a convention inherited from pyRevit, not a promise of YAML semantics.

The whole algorithm, in order:

  1. Split the text on \n. Trim a trailing \r, then trim whitespace from both ends of the line.
  2. Skip the line if it is now empty, or if it starts with #.
  3. If the last key line had nothing after its colon and this line starts with - (dash, space), the rest of the line is one item of that key's block list. One matching layer of quotes is removed from the item and it is appended. Nothing else on the line is looked at.
  4. Otherwise find the first :. If there is none, or it is the first character, skip the line.
  5. The key is everything before that colon, trimmed and lowercased.
  6. The value is everything after that colon, trimmed.
  7. If the value is two characters or longer and starts and ends with the same quote character, " or ', remove exactly one character from each end.
  8. Store it. A key seen twice keeps the last value. An empty value opens a block list for the - lines that follow; the next key line closes it.

That is all of it. There is no escape processing, no type coercion and no schema. The block list is the one shape of structure the reader knows, and layout: is the only key that reads one. A file that does not exist yields no keys; a file that exists but cannot be read is logged (Could not read '<path>' - its title, tooltip, context and shortcut are ignored.) and also yields none, so the bundle falls back on its defaults.

The line in the file What the parser stores context : doc # gate context doc # gate A trailing comment is part of the value. tooltip : Scale is 1:50 tooltip Scale is 1:50 Only the first colon splits; later ones are text. engine : 'cpython' engine cpython Indentation is trimmed away; one quote layer is removed. # engine: cpython Nothing. A line starting with # is skipped whole. Highlighted left to right: the key, lowercased; the first colon, which is the only separator; and the value, trimmed. Everything the parser does happens on one line, in that order.
The parser has almost no concept of structure. It sees lines, a first colon, and two strings; the block list under an empty value is the one exception.

Inputs that surprise people#

Most of these follow from the rules above rather than being special cases, but they are worth reading once so you recognise them when a value comes out wrong.

LineKeyValueWhy
context: doc # gatecontext doc # gate Trailing comments are not a thing. Here it breaks the rule (# is not allowed in one), so the button is never greyed; on min_host_version it makes the value "not a year"; on title it shows on the ribbon.
    title: Counttitle Count The line is trimmed first, so indentation means nothing.
TITLE: CounttitleCount Keys are lowercased. Values keep their case.
tooltip: Scale is 1:50tooltip Scale is 1:50 The split is on the first colon, so colons in prose are safe.
title: "Count"titleCount One matching layer of quotes is removed.
title: ""Count""title "Count" Exactly one layer, and there is no escape syntax.
tooltip: "A" or "B"tooltip A" or "B The test is only that the first and last characters match.
title: "Count'title "Count' Mismatched quotes are left alone.
title Countnonenone No colon, so the line is dropped silently.
: Countnonenone A colon at the very start means no key.
title: A then title: Btitle BThe last one wins.
title:titleempty The key exists with an empty value, which is not the same as being absent; it also opens a block list.
An empty value still counts

title: with nothing after it gives you a button with an empty caption, not one titled from the folder name, and an empty title derives the command name PYTOOL. Fallbacks apply only when a key is missing. To get the default back, delete the line.

What the file cannot express#

Because the reader only produces flat string pairs, none of the following work, and none of them raise an error: they parse into keys nothing reads.

  • Nesting. An indented block is just more top-level keys.
  • Lists, except one shape. A key with nothing after its colon followed by - item lines is a block list, and layout: is the only key that reads one. A - item line anywhere else has no colon and is dropped.
  • Multi-line values. There is no | or >. A tooltip is one line. (A docstring tooltip can be several; see below.)
  • Types. Every value is a string. Numbers are parsed by the keys that want them; there are no booleans or nulls.
Do
# Count objects by layer
title: Count\nObjects
tooltip: Counts the selected objects, or all of model space.
command: PYCOUNTOBJ
shortcut: Ctrl+Shift+C
context: doc
Don't
title: Count Objects  # by layer
tooltip: >
  Counts the selected objects,
  or all of model space.
context: doc  # needs a drawing

In the second file the title keeps its comment, tooltip is literally >, the two prose lines vanish for having no colon, and the context rule fails to parse, so the button is never greyed. Nothing warns you except the context rule, in the log.

Which file reads which keys#

Every folder in the tree may carry a bundle.yaml, but each kind reads a different subset. Any other key is ignored without a word, with one exception noted below.

WhereKeys read
*.extension\extension.yaml name, engine, layout
*.tab, *.panel, *.stack, *.slideout layout only
*.pulldown, *.splitbutton, *.splitpushbutton title, tooltip, keytip, layout
*.pushbutton, *.nobutton, *.smartbutton title, tooltip, command, engine, shortcut, keytip, context, min_host_version, max_host_version
*.toggle All of the pushbutton keys, plus title_on and tooltip_on
*.urlbutton url (required), title, tooltip, keytip
*.linkbutton plugin (required), title, tooltip, keytip
*.dockpane title, tooltip, engine, keytip, shortcut (read but not bound in the current build)

The exception: title_on or tooltip_on on anything but a toggle is logged (title_on and tooltip_on are for *.toggle bundles only - ignored.). Keys some other tools use, such as author, help_url, highlight, icon or min_revit_version, are not read by pyACAD at all.

The keys a pushbutton reads#

These apply to *.pushbutton, *.nobutton, *.toggle and *.smartbutton, wherever they sit: on a panel, in a stack, in a menu, or in a slideout.

KeyFalls back toWhat it does
title __title__ in the script, then the folder name with any legacy NN_ prefix removed and underscores turned into spaces The caption. A literal \n breaks it onto a second line on a large button. The command name is derived from it unless command is set.
tooltip The script's module docstring, then nothing The hover tooltip. The ribbon appends the resolved shortcut in brackets, then a line naming what to type: Counts objects. (Ctrl+Shift+C) and Command: PYCOUNT (alias CNT).
command PY plus the title's letters and digits, uppercased The AutoCAD command name. See below.
aliasnone Short extra names to type, like BX. Users can replace or drop them. See below.
engine The extension's engine, then ironpython ironpython or cpython, matched ignoring case.
shortcutnone The author's default keyboard chord, for example Ctrl+Shift+C. Needs Ctrl or Alt unless the user allows bare keys. Users can rebind or disable it. See Shortcuts and keytips.
keytipgenerated from the title The letters shown for Alt navigation of the ribbon, uppercased.
contextnone: always enabled Greys the button unless a drawing condition holds. See below.
min_host_versionnone: no floor Oldest AutoCAD release the tool supports, as a year. See below.
max_host_versionnone: no ceiling Newest AutoCAD release the tool supports, as a year. See below.
title_on, tooltip_on none: one caption and tooltip for both states *.toggle only: the caption and tooltip while the toggle is on. See Buttons.
A bad engine id fails at click time, not at load time

Discovery does not check the engine name. engine: python3 builds a normal-looking button; running it writes Unknown engine 'python3'. to the output window and nothing runs. If a tool does nothing but open a window with that message, check this line first.

command: naming the AutoCAD command#

Every bundle that reads the pushbutton keys is registered as an AutoCAD command at boot and on every Reload. Without command:, the name is PY followed by every letter and digit of the resolved title, uppercased, capped at 64 characters:

Resolved titleCommand
Blocks to ExcelPYBLOCKSTOEXCEL
Find and Replace AttributesPYFINDANDREPLACEATTRIBUTES
Export\nLayers (a two-line caption)PYEXPORTLAYERS
2D Takeoff (beta)PY2DTAKEOFFBETA
empty, or no letters or digitsPYTOOL

With command:, the value is uppercased, anything but letters, digits, _ and - is dropped, and it is used as is. No PY is added, so command: blkx registers BLKX. A bundle with a config.py also gets the same name with CFG appended, which runs the config script.

Blocks_to_Excel.pushbutton\bundle.yaml
title: Blocks to Excel
command: PYB2X

Names are handed out in load order and the first claim wins. A bundle loses its command when the name is already taken by an earlier bundle, by one of the reserved loader names (PYACAD, PYACADRELOAD, PYACADCONSOLE, PYACADRUN, PYACADSETTINGS), or by anything AutoCAD already answers to (a native command, a LISP-defined one, another add-in's). Each loss is logged and toasted (One command was not registered, with the reason), and the losing bundle's button still works by running its script directly. Pick a command: that is distinctive enough not to collide with the tools of the people you share with.

alias: short names to type#

People who draw all day type two or three letters, not PYBLOCKSTOEXCEL. alias: gives a tool short extra names. Each one is registered as a second command that runs the same tool, so it works typed, in a macro, in a script file and from (pyacad "BX"). Several aliases go on one line, split by commas or spaces; each is uppercased and cleaned the same way as command:.

Blocks_to_Excel.pushbutton\bundle.yaml
title: Blocks to Excel
alias: BX, B2X

An alias never changes what a keystroke already does:

  • One that the user's acad.pgp defines, or that AutoCAD already answers to (a native command, a LISP C: function, another add-in), is left out. The existing meaning keeps working and the full command name still runs the tool.
  • One that a drawing's LISP defines later (an acaddoc.lsp that loads after pyACAD) hands the run to that LISP command.
  • Aliases are named after every full command name, so an alias never takes another tool's command.

A bundle's alias that is left out for one of these reasons only goes to the log, because nothing is lost. Users choose their own aliases in config.json under aliases, which replaces the bundle's list; an alias of their own that cannot be registered is toasted.

context: enabling and disabling a button#

Without a context: key a button is always enabled. With one, the runtime parses a small boolean expression once at discovery and re-evaluates it whenever it sees an AutoCAD event (a drawing opening or switching, a command ending, the selection changing, a layout switch, and so on), greying or enabling the button to match.

Eight condition names, answered from one snapshot of the active drawing:

ConditionTrue when
docA drawing is open and active.
selectionThe pickfirst selection is not empty.
modelspaceThe Model tab is current (TILEMODE is 1).
paperspaceA layout tab is current, including while working inside a viewport on it.
xrefsThe drawing has at least one attached or overlaid xref.
blocksThe drawing has at least one ordinary block definition (not a layout, anonymous, xref or xref-dependent block). A definition is enough; it need not be inserted.
layoutsThe drawing has at least one paper space layout.
multi-docMore than one drawing is open.

Combine names with & (and), | (or) and ! (not). ! binds tightest, then &, then |. There are no parentheses: ( is not part of the vocabulary. The word forms and, or and not, and the doubled && and ||, are not accepted either. Names ignore case.

bundle.yaml
context: doc
context: selection & modelspace
context: xrefs | blocks
context: doc & !multi-doc

A name that is not one of the eight is legal syntax but always false, and discovery logs it:

%APPDATA%\pyACAD\logs\pyACAD.log
Bundle 'D:\CadTools\...\Count.pushbutton': unknown context condition 'selction'.

A rule that does not parse at all, such as a stray operator or a trailing comment, is logged too, and the button falls back to always enabled rather than never enabled:

%APPDATA%\pyACAD\logs\pyACAD.log
Bundle 'D:\CadTools\...\Count.pushbutton': bad context rule - Unexpected '&' in context
rule '& selection'. Button stays always enabled.
Greying is a hint, not a lock

context: greys a drawn button and nothing else. The command can still be typed, run from a macro or from (pyacad ...), and a keyboard chord still fires it. A script that needs a selection must still check for one. On a *.nobutton, which is never drawn, the key has no effect at all.

min_host_version and max_host_version: gating on the AutoCAD release#

Two keys, each a bare year such as 2023. Either can be used alone.

Batch_Publish.pushbutton\bundle.yaml
title: Batch Publish
min_host_version: 2023
max_host_version: 2026

An out-of-range bundle still renders exactly like any other: it is not greyed and not hidden. The check happens when it runs, before the script starts, whichever way it was started (click, chord, typed command, config.py, PYACAD or (pyacad ...)), and shows a warning toast instead of running it:

text
Requires AutoCAD 2023 or newer (this is 2022).
Requires AutoCAD 2024 or older (this is 2026).

A value that is not a whole number is logged and ignored, leaving that side of the range open: 2024 works; 2024.1, R24.3 and 2024 # note do not.

%APPDATA%\pyACAD\logs\pyACAD.log
Bundle 'D:\CadTools\...\Batch_Publish.pushbutton': min_host_version 'R24.3' is not a year - ignored.

The host year comes from AutoCAD's own version number. If it cannot be determined, the gate is off and every bundle runs, because refusing on a host you cannot identify is the worse failure. A smartbutton's build-time run and a dock pane's script.py are not version checked.

The keys a pulldown reads#

A *.pulldown, *.splitbutton or *.splitpushbutton may carry its own bundle.yaml describing the menu button itself. It reads four keys.

KeyFalls back toWhat it does
titleThe folder nameThe caption; \n breaks it.
tooltipnoneThe hover tooltip.
keytipgenerated from the titleThe Alt-navigation letters.
layoutname orderThe order of the menu rows.

command, shortcut, engine and context are not read there, because the menu button runs nothing itself; put them on the pushbuttons inside it.

The keys a urlbutton reads#

KeyFalls back toWhat it does
urlrequired The address opened in the default browser, trimmed. Missing or blank: the bundle is skipped with Urlbutton '<dir>' has no url: in bundle.yaml - skipped.
titleThe folder nameThe caption.
tooltipThe url itselfThe hover tooltip.
keytipgenerated from the titleThe Alt-navigation letters.

The keys a linkbutton reads#

KeyFalls back toWhat it does
pluginrequired The AutoCAD command the button sends, for example LAYWALK or -LAYER. Uppercased and cut down to letters, digits, _ and -; sent as _.<NAME>. Missing or blank: skipped with Linkbutton '<dir>' has no plugin: in bundle.yaml - skipped.
titleThe folder nameThe caption.
tooltipnoneThe hover tooltip.
keytipgenerated from the titleThe Alt-navigation letters.

The keys a dockpane reads#

KeyFalls back toWhat it does
titleThe folder name The ribbon caption and the palette's title.
tooltipnoneThe ribbon toggle's tooltip.
engineThe extension's engine, then ironpythonThe engine that runs the pane's script.py.
keytipgenerated from the titleThe Alt-navigation letters.
shortcutnone Read, but not bound to anything in the current build.

A dock pane does not read tooltip from a docstring, has no command, and takes no context or host version keys. Buttons covers the palette itself.

The keys an extension reads#

extension.yaml, directly inside the .extension folder, reads three keys.

CadTools.extension\extension.yaml
name: CadTools
engine: ironpython
layout:
  - CadTools
  - Plotting
KeyFalls back toWhat it does
nameThe folder name without .extension The extension's identity: part of its tab ids, the label on its hooks and startup.py in the log, and the key that spots a second copy loaded from another root (the second is skipped). Not shown on the ribbon.
engineironpython The default engine for every bundle, hook and startup.py in the extension.
layoutname orderThe order of the extension's tabs.

layout: ordering the folders inside a container#

layout: is a block list: the key with nothing after its colon, then one - name line per child folder, in the order you want.

Drafting.panel\bundle.yaml
layout:
  - Count
  - Rename
  - Layers.stack
  - More
  • Listed children come first, in list order; unlisted ones follow in case-insensitive name order, so nothing is ever hidden by being left off.
  • An entry matches a folder with or without its suffix (Layers or Layers.stack), ignoring case and any legacy NN_ prefix on either side.
  • An entry with no matching folder is reported in the after-scan toast and the log, and skipped. A repeated entry counts once.
  • Comment lines (#) inside the list are skipped and do not end it; the next key: line does.

It is read from extension.yaml (tabs) and from the bundle.yaml of a tab (panels), panel (items), stack (rows), pulldown or split button (menu rows) and slideout (flyout items). Anatomy covers ordering in context.

Folders that read only a layout list#

The bundle.yaml inside a *.tab, *.panel, *.stack or *.slideout is opened for layout: and nothing else. In particular there is no title: for a tab or a panel: their captions are always the folder name, with underscores turned into spaces. To rename a panel on the ribbon, rename its folder (and its entry in the tab's layout:).

How title and tooltip actually resolve#

KindTitle, first that existsTooltip, first that exists
Pushbutton, nobutton, toggle, smartbutton title:, __title__ in script.py, folder name tooltip:, module docstring of script.py, none
Pulldown, splitbutton, splitpushbutton title:, folder nametooltip:, none
Urlbuttontitle:, folder nametooltip:, the url
Linkbutton, dockpanetitle:, folder nametooltip:, none
Tab, panelfolder namenone

"Folder name" always means: suffix removed, a leading run of two or more digits and an underscore removed, remaining underscores turned into spaces. A toggle that is on shows title_on and tooltip_on instead, when set.

Breaking a long caption onto two lines#

A long caption stretches its panel wide. Write a literal backslash-n in the title to break it: title: Export\nLayers. Spaces around the \n are absorbed, so Export \n Layers is the same. The break applies only where there is a second line to use, the caption of a large button (pushbutton, toggle, smartbutton, pulldown, split button, url and link buttons, dock pane toggle). Everywhere else (stack and menu rows, logs, toasts, the output window title, keytips, __title__ and the derived command name) sees the flat form Export Layers.

How the script is scanned#

For the pushbutton family, pyACAD reads script.py as text at discovery, without running it, to find two things. Both are found with simple pattern matching, so they have to be written plainly.

__title__#

A line that starts, in the first column, with __title__ = followed by a single- or double-quoted string on that same line. The string between the quotes is the title. Indented assignments, triple-quoted strings, string concatenation and anything computed are not seen.

script.py
__title__ = 'Count Objects'      # seen: the title is "Count Objects"

The docstring#

The first statement of the file, after any blank and comment lines, must open with """ or '''. The text up to the matching closing quotes, trimmed, is the tooltip, and it may run over several lines. A docstring with a prefix such as r""", or one that comes after an import, is not seen.

script.py
# -*- coding: utf-8 -*-
"""Counts the selected objects, or every object in model space.

Pass a layer pattern from AutoLISP to count only those layers."""
from pyacad import sel

The scan never fails a bundle: a script that cannot be read or scanned simply contributes no title and no tooltip.

A complete example#

Isolate_Layer.toggle\bundle.yaml
# Freeze everything but the current layer; click again to restore.
title: Isolate\nLayer
title_on: Restore\nLayers
tooltip: Freezes every layer except the current one, remembering what was on.
tooltip_on: Puts back the layer states saved when you isolated.
command: PYISOLATECURRENT
engine: ironpython
shortcut: Ctrl+Alt+I
keytip: IL
context: doc & modelspace
min_host_version: 2022

That toggle registers PYISOLATECURRENT, greys itself unless a drawing is open with the Model tab current, refuses to run on AutoCAD 2021, binds Ctrl+Alt+I (which users can rebind), answers to Alt, PY, IL on the ribbon, and swaps caption and tooltip while on.