Scripts

The script environment

What the runtime does between the click and the first line of your code, and every rule that follows from it.

A pyNavis script is not run the way a file is run from a terminal. There is no argument vector, no interpreter start-up, and no process boundary. Your file is compiled and executed inside a scope that the runtime prepares for it, on the same thread that is drawing the Navisworks window, in an interpreter that has already been alive for as long as the session.

Almost every surprise in authoring comes from one of those facts. This page covers all of them.

What happens when you click#

The path from a ribbon click to your first statement is short and entirely deterministic.

Runtime What it means for you Click, Shift+Click or a chord Shift+Click swaps in config.py, nothing else. Run gate One script at a time, process-wide. Engine resolved bundle.yaml, then extension.yaml, then ironpython. Fresh scope, five globals set __file__ __commandpath__ __title__ Search paths set, cache pruned Your bundle and lib modules are dropped. script.py executed from source Top to bottom. Never imported, always fresh. Teardown in a finally Progress dialogs closed, gate released. The scope is discarded at the end: nothing you define survives to the next run.
Every run walks the same seven stages. An error anywhere from stage four onward is formatted and written to the output window; the teardown still happens.

The five injected globals#

The runtime sets exactly five names in your scope before executing your file. This is the complete list for a *.pushbutton and every kind built on one. Everything else you see is either something you defined or something Python itself provides.

A dockpane's script.py gets a sixth: __pane__

A *.dockpane's script.py runs once, when the panel's content is built, with these same five plus __pane__, a proxy over the panel itself (Find, Content, Title, BundleKey, Visible). See Buttons, stacks and pulldowns and pynavis.panes.

NameTypeWhat it holds
__file__str Full path of the file being executed. On a Shift+Click run this is the config.py path, not script.py.
__commandpath__str The bundle folder. This is where icon.png, your data files and your bundle-local modules live.
__title__str The resolved title: bundle.yaml first, then a __title__ in the script, then the folder name with its numeric prefix stripped. It stays the button's title on a Shift+Click run.
__selfinit__bool False on every ordinary run, click and Shift+Click alike. Only a *.smartbutton ever sees True, on its extra ribbon-build run, which is also the only run where __button__ exists, and a run with a smaller scope: __commandpath__ and __title__ do not exist there. See Buttons, stacks and pulldowns.
__pynavis__host object The runtime host singleton, your door back into the C# side.

You can see all five at once. This is one of the few scripts where print is the right call, because opening the output window is the entire point of it:

Probe.pushbutton\script.py
"""Prints the environment the runtime handed this script."""
print('__file__        %s' % __file__)
print('__commandpath__ %s' % __commandpath__)
print('__title__       %s' % __title__)
print('__selfinit__    %s' % __selfinit__)
print('__pynavis__     %s' % __pynavis__)
print('version         %s' % __pynavis__.Version)
print('dark theme      %s' % __pynavis__.IsDarkTheme)
print('__name__        %r' % __name__)

__pynavis__ is the same object pynavis.script.get_host() returns, so you rarely touch the global directly. Its members:

MemberDoes
.VersionRuntime assembly version string.
.IsDarkThemeTrue when the host UI renders dark. Theme your own dialogs with this.
.ScriptPathThe running command's script path.
.CommandPathThe running command's bundle folder.
.CommandTitleThe running command's resolved title.
.CommandBundleKeyThe running command's bundle key. It is what toggle state and pynavis.panes' default target are keyed on.
.WriteHtml(html)Appends an HTML fragment to the output window.
.HasOpenOutputTrue once this run has actually opened an output window, so a script can decide whether a summary is worth writing.
.SetOutputTitle(title)Renames the output window away from pyNavis - <button title>.
.SaveOutput(path)Writes the output window's transcript to a file.
.Progress(fraction, label)Shows or updates the output window progress bar, fraction 0 to 1.
.ElementLink(item, label)HTML for a link that selects a model item.
.GetToggleState() / .SetToggleState(on) The running bundle's session toggle flag. See toggle.
.GetEnvVar(name) / .SetEnvVar(name, value) A session-scoped, case-insensitive string store shared by every bundle. Setting None removes the entry.
.LogMessage(level, source, message) Writes one line to the pyNavis log. Level is one of debug, info, success, warning, error.
.LogSourceRead/write. The label log lines are tagged with while something that is not a command is running.
.Reload()Rescans extension folders and rebuilds the ribbon.

.SetCommandContext(...) and .SetCurrentOutput(...) also exist and belong to the runtime. Calling either from a script points the whole host at the wrong tool; there is no reason to.

Prefer the Python wrappers. pynavis.script exposes the read-only members as functions, and pynavis.output wraps the HTML and progress members in something you would want to call twice.

python
from pynavis import script

script.get_host()          # the __pynavis__ object, even outside a script scope
script.get_script_path()   # str, the executing command's script.py
script.get_command_path()  # str, the bundle folder
script.get_title()         # str, the resolved title
script.get_host_version()  # str, runtime assembly version
script.is_dark_theme()     # bool
script.reload_pynavis()    # rescan and rebuild the ribbon, no restart
Outside a run they go stale, not blank

The host keeps the context of the most recent run. Call script.get_title() from a console or a unit test and you get whatever ran last, or None if nothing has. Only trust these inside a command.

__name__ is '__main__'#

A hosted scope has no __name__ of its own, so if __name__ == '__main__': would be false and the body of such a script would silently never run. Both engines therefore set the name explicitly before your first statement, and both have a test that proves it. The familiar guard works:

Count.pushbutton\script.py
"""Counts the selection."""
from pynavis import selection, toast


def main():
    items = selection.get_items()
    toast.success('%d selected' % len(items))


if __name__ == '__main__':      # true on IronPython and on CPython
    main()

That is the only name Python itself would have given you. Everything else in the scope is either one of the five injected globals or something you defined.

The guard is not a way to make a file importable

Because a bundle script always runs with __name__ == '__main__', the guard never excludes anything here. A module you want to import from elsewhere as well should be a separate file beside script.py, with script.py importing it and calling in; that module is imported normally and gets its own real module name. If you really need one file to behave both ways, guard on something you control, such as the presence of __commandpath__.

Module-level code is still the shorter and more common style, and every shipped bundle uses it. Defining helper functions is fine and encouraged; just make sure something calls them.

Do
"""Counts the selection."""
from pynavis import selection, toast

items = selection.get_items()
toast.success('%d selected' % len(items))
Don't
"""Counts the selection."""
from pynavis import selection, toast

def main():
    items = selection.get_items()
    toast.success('%d selected' % len(items))

# nothing calls main(), so the script does nothing
Names that are not API

__window__ appears in a stale comment in the runtime source and is set by nothing. Do not use it. On CPython you will also find __pynavis_source__ and __pynavis_file__ in the scope; they are internals of the engine's compile shim, are absent on IronPython, and must not be relied on.

Choosing an engine#

The engine is a per-bundle choice, resolved once when the bundle is parsed:

  1. The engine: key in the bundle's bundle.yaml.
  2. The engine: key in the extension's extension.yaml, which sets a default for every bundle in it.
  3. ironpython.
Analyse.pushbutton\bundle.yaml
title: Analyse quantities
engine: cpython
 ironpythoncpython
VersionIronPython 3.4Whatever is installed on the machine
Ships with pyNavisYes, including a 3.4-era stdlibNo, it locates an installed Python
HostingThe DLR, one engine per sessionpythonnet, one interpreter per process
CreatedAt bootLazily, on the first click of a CPython bundle
Per runA fresh scopeA fresh scope
Subclass .NET and COM typesYesNo
Compiled wheelsNoYes
Shut downWith the sessionNever, only with the process

The CPython DLL is looked for in three places, in order: the "cpython" key in %APPDATA%\pyNavis\config.json, then the PYNAVIS_CPYTHON environment variable, then the registry under SOFTWARE\Python\PythonCore, current user before local machine. Either of the first two may name a python3XX.dll directly or the folder that holds one. The registry scan accepts versions 3.7 to 3.13 and prefers the newest. If nothing is found, the first click on a CPython bundle raises a message naming both the config key and python.org.

Because the interpreter is initialised once and never shut down, changing the key takes effect on the next Navisworks start, not on Reload. The Settings window says so when you edit it.

A typo in engine: ships a broken button

The engine id is matched case-insensitively against exactly two strings, ironpython and cpython. Anything else raises NotSupportedException: Unknown engine '<id>'. at click time, not at load time. So engine: python3 produces a button that appears on the ribbon, looks completely normal, and fails the moment a user presses it. The message lands in the output window and in the log.

Only pushbuttons honour engine:. Setting it on a pulldown does nothing; the key belongs on each pushbutton inside it, or on the extension.

Module search paths#

Two sets of paths combine on every run. The per-bundle set is the bundle's own folder plus its extension's lib\ folder when that folder exists. The engine-global set is the bundled stdlib, which only IronPython ever sees, the directory holding the pynavis package, and every *.lib folder found under the extension roots. The diagram below leaves the *.lib folders out for room; they sit immediately after pynavislib on both engines, in the order they were discovered, so they are always ahead of a bundle's own folder and of an extension's lib\. Anatomy of an extension covers what they are.

Searched first IronPython engine defaults <runtime>\lib bundled stdlib pynavislib <bundle folder> <extension>\lib Your folders come after the stdlib: a module named os.py stays harmless. CPython pynavislib <bundle folder> <extension>\lib the interpreter's own sys.path CPython stdlib, then site-packages (the bundled <runtime>\lib is never added) Your folders come first: a module named os.py in a bundle shadows the real one. Both engines prune your bundle and lib modules from the cache before every run.
The two engines order the same ingredients differently. The consequence is CPython-only, and it is a shadowing hazard.
Do not name a module after a stdlib module on CPython

Because your bundle folder and your lib\ folder are searched ahead of the interpreter's own sys.path, a file called json.py, types.py or select.py next to your script will be imported instead of the real one, by your code and by anything else that imports it during the run. Prefix your modules, or put them in a package folder with a name you own.

CPython restores sys.path, sys.stdout and sys.stderr in a finally, so a run cannot leak its paths or its output redirection into the next one. If pynavislib cannot be found on disk at all, the runtime logs an error at boot and import pynavis fails for every bundle.

The module cache and the edit loop#

Before every run that has bundle search paths, the engine walks sys.modules and deletes every module whose __file__ sits under your bundle folder or your extension's lib\ folder. Paths are compared lowercased with a trailing backslash appended, so C:\a does not match C:\ab.

script.py itself is never in that cache: it is read and executed from source on every run, never imported. Together those two facts give you an edit loop with no ceremony in the common case.

You edited … Just click again script.py config.py modules in the bundle modules in <ext>\lib Dropped from the cache before every run. Click Reload bundle.yaml folder names, new bundles icons, shortcuts the pynavis library Rescan, plus a targeted cache drop for pynavislib. Restart Navisworks the PyNavis .NET runtime the "cpython" config key the "pynavislib" path Loaded once per process and never released. The stdlib is never invalidated on either engine, and never needs to be.
The first column is where you will spend your day. Reaching for Reload out of habit costs a second, so people do; it is genuinely unnecessary for script and module edits.

Splitting a large tool across files therefore costs you nothing in turnaround:

folders
MyTools.extension\
  lib\
    acme_geometry.py        # edits picked up on the next run
  MyTools.tab\
    Model.panel\
      Analyse.pushbutton\
        script.py           # re-read from source on every run
        rules.py            # edits picked up on the next run

Importing#

import pynavis gives you exactly one public name, pynavis.__version__. There are no re-exports, no __all__ and no automatic submodule import, so the package itself is nearly free to import and you always name what you want.

Every shipped bundle uses the same style: from pynavis import with the modules in alphabetical order, at module level, immediately after the docstring.

Do
"""Groups the active clash test."""
from pynavis import clash, forms, toast
Don't
"""Groups the active clash test."""
import pynavis
pynavis.clash.walk_tests()  # AttributeError

What an import costs depends entirely on which module it is. Three tiers:

ModulesNeeds at import timeConsequence
settings, memory, clashgroup, viewpoints, section, output, sets, props, clashtest, export, viewstate, xl Nothing beyond the stdlib They import the API lazily inside the functions that need it, so they load anywhere, including in a unit test with no Navisworks.
forms, toast, script, panes The PyNavis.Runtime assembly Always present wherever a pyNavis script runs.
app, doc, selection, clash A live Navisworks session They import pynavis._api at module load, which raises ImportError('pynavis: could not load the Navisworks .NET API ...') outside one.

That last tier is why a tool whose dialog you want to open outside Navisworks uses a guarded import. This is the pattern the shipped clash grouper uses so its options dialog still works when there is no model:

python
from pynavis import forms, settings, toast

try:
    from pynavis import clash
except ImportError:
    clash = None

if clash is None:
    toast.error('No Navisworks session', 'Open a model and run this again.')
else:
    ...
Underscored modules are private

_api, _com, _query, _xlsx and every other underscore-prefixed module are implementation. They change without notice. If you find yourself importing one, the thing you want probably belongs in the public surface, so say so.

One script at a time#

A long script pumps the Windows message loop so the window can repaint, and pumping dispatches queued input, including another click on a ribbon button. Rather than run two scripts over one document, the runtime holds a process-wide gate. A second click while one script is running is refused with a toast reading “<title> is still running” and “Wait for it to finish, then try again.”

This is not something you can opt out of, and it is worth designing around: a job that takes thirty seconds should say so. Use the output window's progress bar and check for cancellation in your loop.

For a modal progress dialog with its own cancel button, use pynavis.forms.progress, a context manager over the runtime's progress window: update(fraction, label) reports and returns False once the user has cancelled, and check() raises forms.Cancelled instead. The shipped Sets From Excel importer does exactly this:

python
from pynavis import forms

with forms.progress('Importing sets', 'Starting') as p:
    for i, item in enumerate(items):
        p.check()                        # raises forms.Cancelled on cancel
        p.update(float(i) / len(items), 'Set %d' % (i + 1))
The raw runtime type is still reachable

A script that needs the C# scope itself (the Smart Clash Grouper drives its whole write phase with one) imports PyNavis.Runtime.Forms.ProgressScope and calls Begin/Report/Dispose directly; forms.progress is the same window with the ceremony folded away.

Output and errors#

The output window is created lazily, on the first write, and is titled pyNavis - <button title>. A script that writes nothing shows no window at all, which is exactly the behaviour you want from a tool that just did its job.

This is why print is the wrong tool for status

A one-line print('Done') opens a whole window to hold six characters. Use pynavis.toast for statuses and reserve the output window for real reports, tables and links. On a user cancel, say nothing at all.

Do
from pynavis import toast

toast.success('Exported 412 items', path)
Don't
print('Exported 412 items')
# opens a window to say one line

An uncaught exception is formatted by the engine that raised it, IronPython through ExceptionOperations.FormatException and CPython through PythonException.Format(), then written to the output window in the error colour and copied to %APPDATA%\pyNavis\logs\pyNavis.log. You do not need a top-level try to see a traceback; add one only when you can turn the failure into a better message.

Whatever happens, ProgressScope.CloseAll() runs in the runtime's finally. A crashed script cannot leave a modal progress dialog on screen and lock the user out of Navisworks. Your own try/finally is still the first line of defence; this is the backstop.

Threading#

Do not start threads that touch the model

Your script runs on the Navisworks UI thread and the Navisworks API is not thread-safe. A worker thread calling into the document will corrupt state or crash the process, sometimes immediately, sometimes on a later unrelated operation. On CPython there is a second constraint: a single interpreter, a single lock, and one run at a time process-wide.

If a job is slow, the answer is progress reporting and a cancel check, not concurrency. Work that genuinely belongs off the main thread belongs in a separate process instead: write the input to disk, run something external, read the result back.