Scripts
Settings and per-tool state
How a tool remembers what the user chose, using one small module and one JSON file per tool.
Most tools eventually grow an option. A tolerance, an output folder, a checkbox that
someone wants remembered. pyNavis has one convention for this, and it is deliberately
small: a bundle ships a config.py that writes the options, a
script.py that reads them back, and pynavis.settings in between.
There is no settings framework, no schema, no registry. A tool's settings are a Python dict that round-trips through a JSON file.
The convention#
A bundle folder holds two scripts. Clicking the button runs script.py;
Shift-clicking it runs config.py, in the same engine with the same globals.
That split is the whole design: options are edited in one place, consumed in another, and the
user never has to hunt for a preferences dialog because it is always on the same button.
Section.pushbutton\
script.py # click: does the work, reads settings
config.py # Shift+Click: the options dialog, writes settings
bundle.yaml
icon.pngSee Click actions for the full behaviour of the three
modifiers, including what happens when a bundle has no config.py.
The four functions#
pynavis.settings is pure stdlib. It imports no Navisworks API, so it works on
either engine, in a unit test, and outside Navisworks entirely.
| Function | Returns | Does |
|---|---|---|
path_for(tool) | str |
The settings file path for a tool key:
%APPDATA%\pyNavis\settings\<tool>.json. The file need not exist. |
merge(defaults, stored) | dict |
Defaults overlaid with stored values. Keys that are not in defaults are
dropped. |
load(tool, defaults) | dict |
The tool's saved settings merged over defaults. Never raises. |
save(tool, values) | None |
Writes the dict as indented, key-sorted JSON, creating the folder if needed. |
from pynavis import settings
TOOL = 'section_planes'
DEFAULTS = {'padding_mm': 150.0, 'snap_degrees': 1.5}
values = settings.load(TOOL, DEFAULTS) # always a complete dict
values['padding_mm'] = 200.0
settings.save(TOOL, values)The read and write cycle#
Two scripts, one file, and one merge step that quietly does the version management for you.
DEFAULTS and old files
converge on the new shape by themselves, without a version number or an upgrade step.Why load never raises#
load catches everything. A missing file, an empty file, a file full of
half-written JSON because the machine lost power mid-save, a file someone edited by hand into
a list instead of an object: all of them return a fresh copy of your defaults.
def load(tool, defaults):
try:
with open(path_for(tool), 'r') as f:
stored = json.load(f)
except Exception:
return dict(defaults)
if not isinstance(stored, dict):
return dict(defaults)
return merge(defaults, stored)A blanket except is normally a smell. Here it is the point. The alternative
is a tool that fails to open because a preferences file is bad, which is the worst possible
trade: the user loses the feature and has no obvious way to fix it. Falling back to
defaults means the tool always runs, and the next save repairs the file.
load always returns a complete dict containing exactly the keys in your
DEFAULTS. Indexing it with values['padding_mm'] is safe;
values.get('padding_mm', 150.0) is redundant, and duplicating the default in two
places is how they drift apart.
Why merge drops unknown keys#
merge starts from defaults and copies over only the keys that
already exist there. Anything else in the stored file is ignored and, on the next
save, gone from disk.
def merge(defaults, stored):
result = dict(defaults)
for key in defaults:
if key in stored:
result[key] = stored[key]
return resultWork through the version change in the diagram above. Version 1 of a section tool shipped these defaults:
DEFAULTS = {'padding_mm': 150.0, 'snap_degrees': 1.5, 'units': 'mm'}A user changed the padding and the snap, so their file on disk reads:
{
"padding_mm": 200.0,
"snap_degrees": 1.0,
"units": "mm"
}Version 2 drops the units option, because everything is millimetres now:
DEFAULTS = {'padding_mm': 150.0, 'snap_degrees': 1.5}The next load returns {'padding_mm': 200.0, 'snap_degrees': 1.0}.
The user's two real choices survived. The dead key did not, and no code anywhere had to know
it once existed. Add a key later and it simply arrives at its default for everyone.
There is no version field and no upgrade hook because there does not need to be. Renaming
a key is the one case that needs thought: the old name is dropped and the new one starts at
its default, so the user loses that single value. If that matters, read the old key once with
a temporary DEFAULTS that still contains it, write the new one, and remove the
compatibility line a release later.
Where the files live#
One file per tool, named after the tool key, under the per-user pyNavis folder:
%APPDATA%\pyNavis\
config.json # pyNavis itself: extension roots, engine paths, shortcuts, panes
settings\
section_planes.json # one tool
smart_clash_grouper.json
count_selection.json
memory\ # per-document selection registers
logs\The format is what json.dump(values, f, indent=2, sort_keys=True) produces:
indented and key-sorted, so it reads cleanly and diffs cleanly if anyone puts it in version
control. Hand-editing is fine, and a hand-edit that breaks the syntax costs the user their
settings for that tool and nothing else.
What pyNavis itself keeps in config.json#
config.json belongs to the runtime, not to any one tool. This is every key it
reads. Anything else in the file is ignored and, importantly, preserved: each writer
read-modify-writes the whole tree through a temp file and a swap, so a key one editor does not
know about survives every save another one makes.
| Key | Type | Default | What it controls |
|---|---|---|---|
extensions | array of strings | [] |
The folders scanned for *.extension and *.lib folders.
%APPDATA%\pyNavis\extensions, the root the install writes into, and
%PROGRAMDATA%\pyNavis\extensions when that folder exists are appended
automatically if they are not already listed. Blank entries are dropped. |
pynavislib | string | absent | The folder holding the pynavis package. Absent, or set to a folder that
does not exist, falls back to <runtime>\pynavislib; if that is
missing too the runtime logs that import pynavis will fail. |
cpython | string | absent | A python3XX.dll or a Python install folder for the CPython engine.
Absent means auto-detect. Read once, when the interpreter is created, so a change
needs a Navisworks restart. |
theme | "dark" or "light" |
absent | Forces the theme of pyNavis' own windows, toasts and icon variants. Absent, or any other string, means follow the Navisworks ribbon. |
shortcuts.allowBareKeys | boolean | false |
Permits chords with neither Ctrl nor Alt. Read only as a real JSON boolean; the string
"true" is ignored. See Shortcuts and
keytips. |
shortcuts.bindings |
object: bundle key to chord string, or null | {} |
Per-tool chord overrides. A string replaces the author default; null
disables the tool's chord entirely. Written by the Shortcuts
editor. |
panes.assignments |
object: dockpane bundle key to a 1-based slot number | {} |
Which panel slot each *.dockpane owns. Rewritten on every boot and
Reload. |
panes.extraSlots | integer above zero | 0 |
How many slots the generated satellite DLL adds beyond the five that ship in the
loader. The satellite sits beside the loader in the bundle's
Contents\<year> folder and is registered in
PackageContents.xml. Written by the Panel slots
button. |
ribbon.configDot | boolean | true |
Draws a dot on the icon of any bundle that ships a config.py. |
ribbon.shortcutMarker | string | "●" |
Appended to the caption of any button a chord resolves to. "" turns it
off. |
layout.<dialog>.<part> | number above zero | absent | Remembered pane widths inside pyNavis' own dialogs, in whole pixels. Machine-written when a dialog that supports it closes. Nothing a bundle reads. |
runtime<year>, for example runtime2026 |
string | absent | The folder the runtime assemblies load from, one key per Navisworks release so two
installed versions never share a build. Read by the loader with a regex before any
real parser exists. It is the second of four candidates the loader tries, after the
PYNAVIS_RUNTIME environment variable and before
%APPDATA%\pyNavis\<year>\runtime and
%PROGRAMDATA%\pyNavis\<year>\runtime. A normal install leaves it
absent and relies on the third; tools/deploy-dev.ps1 writes it to point
at a repository build. Never delete it by hand when it is there. |
Settings on the pyNavis panel edits extensions,
theme, pynavislib, cpython,
shortcuts.allowBareKeys and the two ribbon hints, and tells you
whether your change needs a Reload or a Navisworks restart. It deliberately leaves
shortcuts.bindings, panes and layout alone, because
those belong to the Shortcuts editor, the pane registry and the dialogs that wrote them.
Reading config.json at boot never throws: a file that will not parse logs
Failed to load config '<path>' - using defaults. and pyNavis carries on with
nothing configured, which usually looks like every extension disappearing at once.
Writing is stricter. A save that finds an unparsable file refuses rather than starting
a fresh one, because a blank start would silently drop your extension roots, your pane claims
and the runtime<year> key the loader boots from. Fix the file, or delete
it.
The two machine-written sections#
The Shortcuts editor maintains shortcuts.bindings
(Shortcuts and keytips), and the runtime maintains
panes: which *.dockpane bundle owns which panel slot, plus how many
extra slots the generated satellite DLL adds beyond the five that ship in the loader.
{
"panes": {
"assignments": {
"Survey.tab/Field.panel/Levels.dockpane": 2,
"pyNavis.tab/Tools.panel/Navigator.dockpane": 1
},
"extraSlots": 3
}
}The keys under assignments are the same extension-relative bundle paths that
shortcut overrides use; the values are 1-based slot numbers. The section is rewritten on
every boot and Reload, and a claim is kept even after its extension disappears, so a
reinstalled extension gets the same slot back, and with it the docked position and size
Navisworks remembered for that slot.
Navisworks stores a panel's position, size and open state against the slot, not against your bundle, which is why a bundle keeps its slot for life. Editing a number here does not move a panel; it points your bundle at a slot whose saved layout belongs to a different one. Delete a claim only to forget it deliberately, and expect that bundle to be treated as brand new on the next scan.
Ribbon hints#
Two hints are drawn on every button that earns them, and both are yours to change. They are read once per ribbon build, so an edit here lands on the next Reload rather than needing a restart.
{
"ribbon": {
"configDot": true,
"shortcutMarker": "ā"
}
}configDot draws a filled dot in the corner of the icon of any bundle that
ships a config.py, so a tool with a Shift+Click action says so on the ribbon
instead of hiding it. shortcutMarker is appended to the caption of any button a
keyboard chord resolves to; the tooltip still names the chord itself.
Both default on. The marker is the string that gets drawn, so setting it to
"" turns it off, and any character works in its place. A marker that begins
with a newline takes its own caption line instead of sitting beside the name:
{
"ribbon": {
"configDot": false,
"shortcutMarker": "\nā"
}
}Choosing a tool key#
The key is a filename, so it should be a short, stable, snake_case string that
identifies the tool rather than describing it. One key per tool.
TOOL = 'smart_clash_grouper' TOOL = 'section_planes' TOOL = 'count_selection'
TOOL = 'Smart Clash Grouper' # spaces in a filename TOOL = __title__ # renaming the button loses settings TOOL = 'settings' # says nothing about which tool
Do not derive the key from __title__ or the folder name. Both are things you
will want to change for presentation reasons, and changing either would silently strand every
user's saved settings in a file nobody reads any more.
Two bundles may deliberately share a key when they are two faces of one tool, for example a run button and a report button that must agree on the same tolerance. That is the exception, not the norm.
The TOOL and DEFAULTS constants#
Both scripts need the same key and the same defaults, and defining them twice is how they end up different. Where the logic already lives in a shared module, put the constants there too. The shipped section tool does exactly this:
TOOL = 'section_planes'
DEFAULTS = {'padding_mm': 150.0, 'snap_degrees': 1.5}which makes the calling script three lines long:
"""Fits section planes around the current selection."""
from pynavis import section, selection, settings, toast
values = settings.load(section.TOOL, section.DEFAULTS)
result = section.fit_to_selection(selection.get_items(), values)
toast.show(result.level, result.message, result.detail)For a tool of your own, a module in your extension's lib\ folder does the same
job, and edits to it are picked up on the next run with no Reload. If the tool is genuinely
one file, a module beside script.py is enough:
TOOL = 'quantity_export'
DEFAULTS = {
'include_hidden': False,
'decimals': 2,
'output_path': '',
}A complete tool#
Three settings, an options dialog on Shift+Click, and a main script that reads them. This is the whole pattern in one bundle.
"""Options for Quantity export."""
from pynavis import forms, settings, toast
import export_opts
values = settings.load(export_opts.TOOL, export_opts.DEFAULTS)
answer = forms.ask_string(
'Decimal places for quantities:',
default=str(values['decimals']),
title='Quantity export',
)
if answer is not None: # None means cancelled: change nothing
try:
decimals = int(answer)
except ValueError:
toast.error('That is not a whole number')
else:
values['decimals'] = max(0, min(6, decimals))
values['include_hidden'] = forms.confirm(
'Include hidden items in the export?', title='Quantity export')
chosen = forms.save_file(default_name='quantities.csv')
if chosen:
values['output_path'] = chosen
try:
settings.save(export_opts.TOOL, values)
except (IOError, OSError) as exc:
toast.error('Could not save settings', str(exc))
else:
toast.success('Saved', '%d decimals, hidden items %s'
% (values['decimals'],
'included' if values['include_hidden'] else 'excluded'))"""Exports quantities for the current selection."""
from pynavis import selection, settings, toast
import export_opts
values = settings.load(export_opts.TOOL, export_opts.DEFAULTS)
items = selection.get_items()
if not items:
toast.info('Nothing selected', 'Pick something in the model and run this again.')
elif not values['output_path']:
toast.warning('No output file set', 'Shift+Click this button to choose one.')
else:
rows = export_opts.build_rows(items, values['include_hidden'], values['decimals'])
export_opts.write_csv(values['output_path'], rows)
toast.success('Exported %d row(s)' % len(rows), values['output_path'])Notice what is absent: no existence check on the settings file, no try around
load, no defaults repeated at the point of use, and no message on cancel.
save is not atomic#
settings.save opens the target file and writes into it directly. It is not
the temp-file-then-replace dance that pynavis.memory.save uses, because settings
are small, rewritten rarely, and cheap to lose. The consequence is real but mild: a process
that dies during the write leaves a half-written file, which the next load
treats as corrupt and replaces with the defaults.
save is also the one function here that can raise. A read-only
profile, a locked file or a full disk surfaces as IOError or
OSError. In a config.py that is usually worth catching, because
losing the settings is less bad than a traceback in the user's face:
try:
settings.save(TOOL, values)
except (IOError, OSError) as exc:
toast.error('Could not save settings', str(exc))
else:
toast.success('Saved')Per user, not per document#
Settings live under %APPDATA%. They belong to the person, follow them between
models, and are shared by every document they open. That is right for a preference such as
decimal places, and wrong for anything that describes a particular model.
| State | Belongs in | Example |
|---|---|---|
| A preference the user sets once | pynavis.settings |
Tolerance, decimals, default output folder, whether to open the report. |
| Something about the open model | pynavis.memory |
A saved selection, the last clash test worked on, a per-model register. |
| Something about this run only | A local variable | Anything you would otherwise be tempted to cache between runs. The scope is discarded anyway, so do not try. |
For the per-document case, pynavis.memory solves the naming problem you would
otherwise have to solve yourself. Its file names are built from the lowercased document path:
a sanitised stem of up to 40 characters, plus an 8-character SHA1 of the full path.
C:\Projects\Tower\structure.nwd -> structure-3f9a2c1d.json
D:\Archive\2019\structure.nwd -> structure-b7e40a52.json
(unsaved document) -> untitled-da39a3ee.jsonTwo files called structure.nwd in different folders never collide, the same
document reached through a differently cased path lands on the same file because everything is
derived from the lowercased form, and every unsaved document shares one
untitled register. If you build your own per-document store, copy that scheme
rather than inventing another one.