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.
| Suffix | Lives under | Becomes | May contain |
|---|---|---|---|
*.extension | An extension root | Nothing visible; it is the unit of distribution | extension.yaml, lib\, hooks\,
startup.py, *.tab |
*.tab | *.extension | One ribbon tab | *.panel |
*.panel | *.tab | One 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 | *.panel | 2 or 3 small buttons in one column | *.pushbutton only |
*.pulldown | *.panel | One 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 |
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 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.
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.pyThat 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.
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.
D:\NavisTools\ Survey.extension\ Drawings.extension\
D:\NavisTools\
Team\
Survey.extension\ # never found
Archive\
Drawings.extension\ # never foundIf 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.
name: Survey Tools
engine: ironpython| Key | Effect | Default |
|---|---|---|
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.
"""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:
- its own bundle folder, so a module beside
script.pyshadows the extension'slib\; - the extension's
lib\folder, if it exists.
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.
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\.
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.
Survey.extension\
startup.py
extension.yaml"""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.
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.
startup.py failed for 'Survey Tools':
Traceback (most recent call last):
...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.
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"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.
01_Memorize.pushbutton 02_Recall.pushbutton ... 09_Purge.pushbutton 10_Export.pushbutton
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.
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.
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.
| Symptom | Cause | Log 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. |
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.
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
.slideoutinside a.slideoutis 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.slideoutfolder 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.
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.
pyNavis.tab/Memory.panel/01_Memorize.pushbutton
pyNavis.tab/Memory.panel/03_Set.stack/01_Add.pushbuttonThat 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.
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
.pushbuttoncontains ascript.py. - Every
.stackcontains 2 or 3 of them. - Every
NN_prefix on a panel is padded to the same width. - You clicked Reload.