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 *.panel
*.panel*.tabOne panel inside that tab *.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 *.pushbutton only
*.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 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 folders were skipped, 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 and default engine
    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
      Export.panel\                  # "Export" sorts before "Review"
        01_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
        02_Mesh.pushbutton\
          script.py
        03_Format.stack\             # 2 or 3 children; no yaml, no icon of its own
          01_CSV.pushbutton\
            script.py
            icon.small.png
          02_IFC.pushbutton\
            script.py
            icon.small.png
        04_More.pulldown\            # a menu button; has its own yaml and icon
          bundle.yaml                # title, tooltip, keytip
          icon.png
          01_Legacy.pushbutton\
            script.py
          02_Debug.pushbutton\
            script.py
        99_More.slideout\            # contents move below the panel break, into the flyout
          Settings.pushbutton\
            script.py
      Review.panel\
        Compare.pushbutton\          # no NN_ prefix, no yaml: titled "Compare"
          script.py

That tree produces one tab named Survey with two panels. The Export panel holds four items in folder order: 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.

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, which is how extensions are deployed to a whole machine. 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 two keys. Anything else in the file is ignored.

Survey.extension\extension.yaml
name: Survey Tools
engine: ironpython
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

Setting engine: cpython here is the clean way to write a whole extension against CPython without repeating the key in every 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 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\01_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 same __file__ and __pynavis__ globals a hook gets, and 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, folders are sorted by name using a case-insensitive ordinal comparison. That sort is the only thing that decides order: there is no order: key, and the order you created the folders in is irrelevant.

Because folder names double as titles, you rarely want alphabetical order. Prefix the folder with two or more digits and an underscore. The prefix sorts the folder and is then stripped from the title.

folders
Memory.panel\
  01_Memorize.pushbutton\      # shows as "Memorize"
  02_Recall.pushbutton\        # shows as "Recall"
  03_Set.stack\
  04_Step.stack\
  05_Memory.pulldown\          # shows as "Memory"
10 sorts before 2

The sort is lexicographic, not numeric. 10_Export comes before 2_Import, and once you have ten tools the panel silently reshuffles. Always zero-pad every prefix on a panel to the same width. This is the single most common authoring mistake in pyNavis.

Do
01_Memorize.pushbutton
02_Recall.pushbutton
...
09_Purge.pushbutton
10_Export.pushbutton
Don't
1_Memorize.pushbutton
2_Recall.pushbutton
...
9_Purge.pushbutton
10_Export.pushbutton    # jumps to the front

A three-digit scheme works too and is worth using on a panel you expect to grow: 010_, 020_, 030_ leaves room to insert without renaming anything.

Items keep folder order across types

A panel does not group buttons, stacks and pulldowns. All three sort together by folder name, so a stack can sit between two large buttons simply by numbering it that way. Buttons, stacks and pulldowns shows this on a real panel.

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, 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_Memorize Memorize Two digits: the prefix is an order hint. 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.

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\
  01_Points.pushbutton\       # always visible
  02_Mesh.pushbutton\         # always visible
  99_More.slideout\           # everything below here is in the flyout
    01_Settings.pushbutton\
      script.py
    02_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 in folder order into the single flyout; there is only ever one break per panel.
  • The folder name is never shown. Use the NN_ prefix to place it, and name it for your own benefit.
  • 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, prefix 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, and do not ship two extensions that both contain a tab called Tools.tab under the same extension name. If a colleague installs a second copy of your extension in a different root, the duplicate tab is dropped and logged rather than drawn twice.

Bundle key#

Each button also carries a stable key: its path relative to the .extension folder, with forward slashes, keeping every suffix and every NN_ prefix.

text
pyNavis.tab/Memory.panel/01_Memorize.pushbutton
pyNavis.tab/Memory.panel/03_Set.stack/01_Add.pushbutton

That key is what config.json uses to rebind or disable a tool's keyboard shortcut, so renaming a folder or changing its NN_ prefix silently orphans a user's override. Shortcuts covers the binding file.

Renumbering has a cost

Reordering a panel by changing prefixes rewrites the bundle keys of everything you touched. Users lose their custom shortcuts for those tools. If you plan to reorder often, leave gaps in the numbering instead.

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 NN_ prefix on a panel is padded to the same width.
  • You clicked Reload.