Bundles

Anatomy of an extension

The four folder levels, how discovery walks them, and the ordering and naming rules that decide what your ribbon looks like.

An extension is a folder tree with meaningful suffixes. pyNavis reads that tree at startup and on every Reload, and builds the ribbon from it. Nothing else registers a command: there is no manifest, no index file, and no place to list your tools. If the folders are right the buttons exist, and if a button is missing the folders are wrong.

This page is the reference for that tree. It covers every level, the two configuration files that sit outside a bundle, the ordering rules, how a folder name becomes a title, and every reason discovery drops something.

The four levels#

Each level is a folder whose name ends in a fixed suffix. Matching is case-insensitive, so Survey.Tab and Survey.tab are the same thing, but the suffix itself is matched literally: nothing else counts.

SuffixLives underBecomesMay contain
*.extensionAn extension rootNothing visible; it is the unit of distribution extension.yaml, lib\, hooks\, startup.py, *.tab
*.tab*.extensionOne ribbon tab bundle.yaml (a layout: list only), *.panel
*.panel*.tabOne panel inside that tab bundle.yaml (a layout: list only), *.pushbutton, *.stack, *.pulldown, and nine more panel item suffixes (see below)
*.pushbutton*.panel, *.stack, *.pulldown One clickable button script.py (required), config.py, bundle.yaml, icons
*.stack*.panel2 or 3 small buttons in one column bundle.yaml (a layout: list only), *.pushbutton
*.pulldown*.panelOne large button that opens a menu bundle.yaml, icons, *.pushbutton
*.slideout*.panel Nothing of its own; its contents render below a panel break, in the flyout the host opens from the panel title bundle.yaml (a layout: list only), any of the other panel item suffixes, one level deep
Twelve panel item suffixes, not three

The table above covers the three shapes you will use constantly, but a panel understands nine more: *.nobutton, *.urlbutton, *.linkbutton, *.toggle, *.smartbutton, *.splitbutton, *.splitpushbutton, *.dockpane and *.slideout. Buttons, stacks and pulldowns covers all twelve. If you have come from pyRevit, note the exact spelling: it is *.nobutton, not *.nobundle, and *.toggle, not *.togglebutton.

A misspelled suffix is reported, not swallowed

A folder inside a panel whose name contains a dot is taken to be an attempted bundle, so a suffix outside the twelve is logged as '<dir>' is not a bundle kind pyNavis knows (check the folder suffix) - skipped. and counted into the warning toast pyNavis shows after every scan: N bundle problems found, quoting the first one. A folder with no dot in its name, such as assets or __pycache__, is an ordinary folder and says nothing.

A complete extension#

Everything below is either required, optional, or ignored. Nothing is boilerplate.

A realistic extension, annotated
D:\NavisTools\                       # an extension root, listed in config.json
  Shared.lib\                        # optional: a sibling of *.extension, not inside one
    acme_common\                     # shared python for every extension, every engine
      __init__.py
  Survey.extension\
    extension.yaml                   # optional: display name, default engine, tab order
    startup.py                       # optional: runs at boot and every Reload
    lib\                             # optional: modules importable from every button IN
      surveyutil\                    # this extension only - different from Shared.lib\ above
        __init__.py
        geometry.py
    hooks\                           # optional: event scripts, filename = event name
      doc-opened.py
    Survey.tab\                      # one ribbon tab
      bundle.yaml                    # optional: layout: Export, Review
      Export.panel\
        bundle.yaml                  # optional: layout: Points, Mesh, Format, More
        Points.pushbutton\           # large button, titled "Points"
          script.py                  # REQUIRED, or the whole bundle is dropped
          config.py                  # optional: Shift+Click runs this
          bundle.yaml                # optional: title, tooltip, engine, shortcut, keytip
          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
        Mesh.pushbutton\
          script.py
        Format.stack\                # 2 or 3 children; no icon of its own
          bundle.yaml                # optional: layout: CSV, IFC
          CSV.pushbutton\
            script.py
            icon.small.png
          IFC.pushbutton\
            script.py
            icon.small.png
        More.pulldown\               # a menu button; has its own yaml and icon
          bundle.yaml                # title, tooltip, keytip, layout
          icon.png
          Legacy.pushbutton\
            script.py
          Debug.pushbutton\
            script.py
        Extras.slideout\             # contents move below the panel break, into the flyout
          Settings.pushbutton\
            script.py
      Review.panel\
        Compare.pushbutton\          # no yaml: titled "Compare"
          script.py

That tree produces one tab named Survey with two panels. The Export panel holds four items in the order its layout: list gives: a large button, a large button, a two-row stack and a pulldown, plus one more button hidden in the flyout the panel title opens. The Review panel holds one button and needs no list.

How discovery walks the disk#

Discovery is a fixed six-stage walk. Every stage globs for one pattern and ignores everything else it finds, which is why a misplaced folder produces no error: it was never looked at.

What is scanned What is passed over 1 Extension roots config.json → "extensions" Every listed root is scanned, and the default %APPDATA%\pyNavis\extensions is added. 2 Extensions *.extension Directly under the root only, never recursive. An extension one folder deeper is invisible. 3 Tabs *.tab extension.yaml and lib\ are read here; every other file or folder is left alone. 4 Panels *.panel A .panel nested inside another .panel is never reached; panels do not nest. 5 Panel items *.pushbutton *.stack ... *.dockpane All twelve item suffixes match here; a dotted folder name that matches none of them is logged as a typo. 6 Children of a stack or pulldown *.pushbutton A .stack inside a .stack, or a .pulldown inside a .pulldown, is logged and dropped. Nesting stops here.
Each stage globs one pattern in one folder. Anything that does not match that pattern 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 are the strings in the "extensions" array of %APPDATA%\pyNavis\config.json, plus two defaults added when they are not already in the array: %APPDATA%\pyNavis\extensions, which is where the install puts the shipped extension, and %PROGRAMDATA%\pyNavis\extensions if that folder exists and only administrators can write to it, which is how extensions are deployed to a whole machine. Any user can create folders under %PROGRAMDATA%, so a machine-wide root others could write to is skipped with a log line: its hooks and startup.py would otherwise run for everyone on the PC. All roots are scanned and their results concatenated, so a development extension and an installed one happily coexist.

Do
D:\NavisTools\
  Survey.extension\
  Drawings.extension\
Don't
D:\NavisTools\
  Team\
    Survey.extension\      # never found
  Archive\
    Drawings.extension\    # never found

If you want to group extensions in subfolders, add each subfolder as its own root instead: pynavis extensions add D:\NavisTools\Team.

extension.yaml#

Optional, and it understands exactly three keys. Anything else in the file is ignored.

Survey.extension\extension.yaml
name: Survey Tools
engine: ironpython
layout:
  - Survey
KeyEffectDefault
name The extension's display name. It is not shown on the ribbon; it is one half of every tab id this extension creates. The folder name with .extension removed
engine The default engine for every bundle in the extension. A bundle's own engine: still wins. ironpython
layout The order of the extension's tabs, as a block list of *.tab folder names. 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 layout: list only matters once an extension has more than one tab.

The lib folder (one extension)#

A folder named lib directly inside the .extension folder is added to the module search path of every button in that extension. It is the place for shared code: helpers, vendored pure-Python packages, anything more than one tool imports.

Survey.extension\Survey.tab\Export.panel\Points.pushbutton\script.py
"""Exports survey points."""
from surveyutil import points            # resolved from Survey.extension\lib
from pynavis import selection, toast

toast.info('%d point(s)' % len(points.of_items(selection.get_items())))

Each button gets two search paths, in this order:

  1. its own bundle folder, so a module beside script.py shadows the extension's lib\;
  2. the extension's lib\ folder, if it exists.
lib is only special at extension level

A lib folder inside a .tab, a .panel, a .stack or a bundle is an ordinary folder and is never added to the search path. There is exactly one lib per extension and it lives beside extension.yaml.

*.lib extensions (every extension, both engines)#

A different thing with a similar name: a folder ending in *.lib sitting directly under an extension root, a sibling of your *.extension folders rather than something inside one. Its contents join the global module search path shared by every extension and both engines, not just the buttons in one extension.

folders
D:\NavisTools\                       # an extension root
  Shared.lib\                        # a library-only extension, not a *.extension
    acme_common\
      __init__.py
      units.py
  Survey.extension\
    ...
  Drawings.extension\
    ...                              # both can "import acme_common"

Search path order puts every *.lib folder ahead of the bundle folder and the per-extension lib\, right after pynavislib: pynavislib, then every *.lib folder found (in sorted order), then the bundle folder, then that extension's own lib\.

Adding or removing a *.lib folder needs a restart

The set of *.lib folders is fixed into the engine configuration once, at boot. Reload rescans it and, if the set changed, toasts Library extensions changed - Added or removed *.lib folders apply after a Navisworks restart. and leaves the old set in place. Edits to files already inside an existing *.lib folder are a different matter: they need a Reload, not a restart. A Reload drops the cached modules under every known *.lib folder, and under pynavislib, so the next run picks up your change. Only a module in your own bundle folder or your extension's lib\ is evicted before every run.

startup.py#

A file named exactly startup.py directly inside a .extension folder runs once at boot and again at the start of every Reload, before that extension's ribbon is built.

folders
Survey.extension\
  startup.py
  extension.yaml
Survey.extension\startup.py
"""Confirms the shared geometry helper resolved before anyone clicks a button."""
from pynavis import toast

try:
    import surveyutil
except ImportError:
    toast.warning('surveyutil not found', 'Check Survey.extension\\lib\\surveyutil.')

startup.py runs on the extension's default engine with the globals a hook gets, minus __event__: __file__, __pynavis__ and __runner__, which is True only when pynavis run starts it. It gets the same search paths: the extension folder itself, then its lib\ if it has one, plus the global *.lib folders every script gets. Output is routed to the pyNavis log rather than a window, because there is no button behind it to title one after, and every line is labelled with what is actually running ([startup Survey Tools]) rather than with the last command clicked, which on a Reload would be Reload itself.

A failure still lets the buttons load

An exception in startup.py is logged and toasted, but discovery carries on and that extension's ribbon still builds. A broken startup.py costs you whatever it was supposed to set up, not the whole extension.

%APPDATA%\pyNavis\logs\pyNavis.log
startup.py failed for 'Survey Tools':
Traceback (most recent call last):
  ...
Neither startup.py nor a hook can address a specific toggle

pynavis.script.get_toggle_state/set_toggle_state act on "whichever bundle is currently running", which only means something inside that bundle's own script.py or config.py. Neither startup.py nor a hook runs as any particular bundle, so there is no supported way to flip a specific *.toggle's state from either of them. If a toggle needs to remember its setting across a restart, store the flag with pynavis.settings and have the toggle's own script.py read it on the very first click of the session.

Hooks covers the companion mechanism: event scripts under hooks\ that run later, in response to something happening, rather than once at boot or Reload.

Ordering panels and buttons#

At every level, order comes from a layout: list in the container's own configuration file: extension.yaml for the tabs of an extension, and a bundle.yaml inside a .tab, a .panel, a .stack, a .pulldown, a .splitbutton, a .splitpushbutton or a .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 themselves, and the order you created the folders in is irrelevant.

Selection.panel\bundle.yaml
layout:
  - Remember
  - Recall
  - Set
  - Step
  - More
folders
Selection.panel\
  bundle.yaml
  Remember.pushbutton\         # first, as the list says
  Recall.pushbutton\
  Set.stack\
  Step.stack\
  More.pulldown\               # last, as the list says

The rules are short:

  • 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 kind suffix: Recall and Recall.pushbutton both match. Matching ignores case, and it ignores a legacy NN_ prefix on either side.
  • An entry that names no folder in that container is logged, counted into the extension's problem list on the same channel as a mistyped suffix, and skipped: The layout in 'D:\NavisTools\...\Selection.panel' names 'Recal', but no such folder is there - ignored.

Inserting a tool in position three is therefore one new line in the list. Nothing gets renamed, and nothing else moves.

Items keep list order across types

A panel does not group buttons, stacks and pulldowns. All of them are placed by the same list, so a stack can sit between two large buttons simply by listing it there. Buttons, stacks and pulldowns shows this on a real panel.

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_Remember.pushbutton, which sorted the folder and was stripped from the title. That still works, and an extension written that way loads unchanged: without a layout: list the folders sort by name, prefix included, and the prefix never reaches the title or the bundle key. New extensions should use the list. The shipped extension no longer uses prefixes at all.

From folder name to title#

When a bundle has no title: in its bundle.yaml and no __title__ in its script, the title is derived from the folder name by two rules applied in order: strip a leading run of two or more digits followed by an underscore, which is the legacy ordering prefix older extensions still carry, then turn every remaining underscore into a space.

^\d{2,}_ is removed from the front, _ that survives becomes a space. Folder name Title Why 01_Remember Remember Two digits: a legacy ordering prefix. 02_Recall_Now Recall Now The inner underscore becomes a space. 1_Keep 1 Keep One digit is not a prefix. Nothing is stripped, so the digit stays in the label. 010_Purge Purge Three digits work the same way. Tabs and panels get their titles from exactly the same two rules.
The rule is deliberately two digits or more, so a tool genuinely named 1_Keep keeps its name. If you want a single digit in the label, set title: and stop guessing. 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.

Titles are only derived when nothing better is available. The full precedence, most specific first, is bundle.yaml, then the script's __title__ or docstring, then the folder name. bundle.yaml 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. Most drops announce themselves twice: once in %APPDATA%\pyNavis\logs\pyNavis.log, and once in a warning toast after the scan that counts them and quotes the first. This table maps what you see to what went wrong, with the line to search for in the log.

SymptomCauseLog line
One button missing The .pushbutton folder has no script.py '<dir>' has no script.py - skipped.
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 pyNavis knows (check the folder suffix) - skipped.
A dock panel's ribbon toggle missing The .dockpane folder has no pane.xaml Dockpane '<dir>' has no pane.xaml - skipped.
A whole stack missing The .stack 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.
A pulldown missing No child .pushbutton had a script.py Pulldown '<dir>' has no usable pushbuttons - skipped.
A panel missing Every item in it was skipped, so the panel has zero items None for the panel itself; the item skips above explain it.
A tab missing Every panel in it was skipped, so the tab has zero panels None for the tab itself.
The second copy of a tab missing Another extension already produced a tab with the same id Ribbon tab '<id>' already exists - skipped (duplicate extension?).
A stack or pulldown child missing It is not a *.pushbutton; those two containers hold nothing else '<dir>' is ignored: a stack holds only *.pushbutton folders.
A slideout's contents missing The .slideout sits inside another .slideout Slideout '<dir>' sits inside another slideout - skipped.
Nothing at all from an extension folder A misspelled .extension, .tab or .panel suffix, or the extension is not directly inside a scanned root None. Those three levels are globbed by pattern, so the walk never looked at the folder and has nothing to report.
Read the count in the log

Every successful build logs Ribbon built: N tab(s), M button(s). If M 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. It is a marker: whatever panel items it contains are emitted after a panel break, so the host hides them until the user clicks the panel title (the little Title with a chevron along the bottom of the panel), exactly like the native Tags panel. It is where the tools that matter least go, without their costing any ribbon width.

folders
Export.panel\
  bundle.yaml                 # layout: Points, Mesh
  Points.pushbutton\          # always visible
  Mesh.pushbutton\            # always visible
  Extras.slideout\            # everything below here is in the flyout
    bundle.yaml               # layout: Settings, Docs
    Settings.pushbutton\
      script.py
    Docs.urlbutton\
      bundle.yaml
  • A slideout may hold any of the other panel item kinds, not just pushbuttons: stacks, pulldowns, toggles, dockpanes and the rest all work.
  • A panel may have more than one .slideout. Their contents are concatenated into the single flyout in the order the panel's layout: list gives them; there is only ever one break per panel.
  • The folder name is never shown, so name it for your own benefit. Its own bundle.yaml holds a layout: list for the items inside it and nothing else; where the slideout itself sits in the panel's list does not matter, because its contents always render after the break.
  • One level only. A .slideout inside a .slideout is logged (Slideout '<dir>' sits inside another slideout - skipped.) and its contents are dropped.
  • A panel whose items are all in a slideout still renders: the panel exists, the visible area is empty, and everything is in the flyout.
  • Buttons in a slideout are ordinary buttons in every other respect. They take shortcuts, keytips, context: gating and bundle keys exactly like the visible ones, and their bundle key keeps the .slideout folder in the path.

Names and identity#

Two identities are derived from the tree, and both matter when you ship an extension to other people.

Tab id#

Every ribbon tab needs a unique id. pyNavis builds one from the extension name and the tab folder name, replacing every character that is not a letter or digit with an underscore.

Building PYNAVIS_TAB_ extension.yaml name Survey Tools falls back to the folder name tab folder Field Work.tab suffix removed, a legacy prefix is kept joined with _ Survey Tools_Field Work sanitised Survey_Tools_Field_Work every non-alphanumeric becomes _ final id PYNAVIS_TAB_Survey_Tools_Field_Work Two extensions that resolve to the same name and the same tab name produce the same id; the second is skipped.
Sanitising means punctuation collapses. Survey-Tools and Survey Tools produce identical ids, so pick names that differ in more than punctuation.

The practical rule: give your extension a name nobody else will use. If a colleague installs a second copy of your extension in a different root, the second copy is not loaded at all and the log says so, rather than every button being drawn twice.

Adding to someone else's tab or panel#

Tabs, and panels inside them, join by name across extensions: a pyNavis.tab in your extension adds to the pyNavis tab instead of drawing a second one, and a Clash.panel inside it adds your buttons to the end of the Clash panel. The matching ignores case. A shared tab stays where the first extension in load order puts it, the shipped pyNavis extension's panels and buttons come first in anything it shares, and other extensions follow in load order. The tab keeps the first contributor's id.

text
%APPDATA%\pyNavis\extensions\
  pyNavis.extension\                  # the installer's; every update replaces it
  My pyNavis.extension\               # yours; updates never touch it
    pyNavis.tab\
      Clash.panel\
        Tag Clashes.pushbutton\       # lands at the end of the pyNavis Clash panel
      Team.panel\
        Publish.pushbutton\           # a new panel on the pyNavis tab

This is why nothing of yours belongs inside pyNavis.extension: the installer replaces that folder on every update. It backs up what it finds there and moves the buttons and panels you added into My pyNavis.extension first, but a file added inside one of pyNavis's own buttons, or an edit to one of its files, can only go to the backup.

Bundle key#

Each button also carries a stable key: its path relative to the .extension folder, with forward slashes, keeping every suffix. A legacy NN_ prefix is dropped from every segment.

text
pyNavis.tab/Selection.panel/Remember.pushbutton
pyNavis.tab/Selection.panel/Set.stack/Add.pushbutton

That key is what config.json uses to rebind or disable a tool's keyboard shortcut and to record which slot a *.dockpane claimed, so renaming a folder, meaning the name after any prefix, silently orphans a user's override. Shortcuts covers the binding file.

Renumbering is free

pyNavis.tab/02_Selection.panel/01_Remember.pushbutton and pyNavis.tab/Selection.panel/Remember.pushbutton are the same key, and a key written into config.json with prefixes is normalised on load. A user's shortcut overrides and dock-pane slot claims survive an author adding, changing or removing prefixes. Renaming the folder name itself, Remember to Memorize, is still a new key.

What to check when you add a bundle#

  • The extension folder sits directly in a root from config.json.
  • Every suffix is spelled correctly and appears exactly once per folder name.
  • Every .pushbutton contains a script.py.
  • Every .stack contains 2 or 3 of them.
  • Every entry in a layout: list names a folder that exists, spelled the way the folder is.
  • You clicked Reload.