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.

folders
Section.pushbutton\
  script.py        # click: does the work, reads settings
  config.py        # Shift+Click: the options dialog, writes settings
  bundle.yaml
  icon.png

See 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.

FunctionReturnsDoes
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.
python
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.

config.py Shift+Click, writes save <tool>.json %APPDATA%\pyNavis\settings load script.py Click, reads Inside load: merge(defaults, stored) DEFAULTS this version of the tool padding_mm snap_degrees Stored file written by an older version padding_mm 200.0 snap_degrees 1.0 units "mm" Result what your script sees padding_mm 200.0 snap_degrees 1.0 units is gone Values come from the file. The set of keys comes from DEFAULTS, so a key you removed from the tool cannot come back, and a key you added arrives at its default.
The merge is the migration story. You change 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.

pynavislib\pynavis\settings.py
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.

You never need to check the result

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.

pynavislib\pynavis\settings.py
def merge(defaults, stored):
    result = dict(defaults)
    for key in defaults:
        if key in stored:
            result[key] = stored[key]
    return result

Work through the version change in the diagram above. Version 1 of a section tool shipped these defaults:

python
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:

%APPDATA%\pyNavis\settings\section_planes.json
{
  "padding_mm": 200.0,
  "snap_degrees": 1.0,
  "units": "mm"
}

Version 2 drops the units option, because everything is millimetres now:

python
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.

This is the migration mechanism

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:

folders
%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.

KeyTypeDefaultWhat it controls
extensionsarray 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.
pynavislibstringabsent 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.
cpythonstringabsent 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.allowBareKeysbooleanfalse 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.extraSlotsinteger above zero0 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.configDotbooleantrue Draws a dot on the icon of any bundle that ships a config.py.
ribbon.shortcutMarkerstring"●" 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 stringabsent 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.
There is a window for most of this

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.

Broken JSON is refused, not repaired

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.

%APPDATA%\pyNavis\config.json
{
  "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.

Do not reassign slots by hand

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.

%APPDATA%\pyNavis\config.json
{
  "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:

%APPDATA%\pyNavis\config.json
{
  "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.

Do
TOOL = 'smart_clash_grouper'
TOOL = 'section_planes'
TOOL = 'count_selection'
Don't
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:

pynavislib\pynavis\section.py
TOOL = 'section_planes'
DEFAULTS = {'padding_mm': 150.0, 'snap_degrees': 1.5}

which makes the calling script three lines long:

Section.pushbutton\script.py
"""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:

Export.pushbutton\export_opts.py
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.

Export.pushbutton\config.py
"""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'))
Export.pushbutton\script.py
"""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#

A crash mid-write can truncate the file

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:

python
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.

StateBelongs inExample
A preference the user sets oncepynavis.settings Tolerance, decimals, default output folder, whether to open the report.
Something about the open modelpynavis.memory A saved selection, the last clash test worked on, a per-model register.
Something about this run onlyA 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.

text
C:\Projects\Tower\structure.nwd   ->   structure-3f9a2c1d.json
D:\Archive\2019\structure.nwd     ->   structure-b7e40a52.json
(unsaved document)                ->   untitled-da39a3ee.json

Two 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.