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.
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 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.
| Name | Type | What 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:
"""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:
| Member | Does |
|---|---|
.Version | Runtime assembly version string. |
.IsDarkTheme | True when the host UI renders dark. Theme your own dialogs with this. |
.ScriptPath | The running command's script path. |
.CommandPath | The running command's bundle folder. |
.CommandTitle | The running command's resolved title. |
.CommandBundleKey | The 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. |
.HasOpenOutput | True 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. |
.LogSource | Read/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.
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 restartThe 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:
"""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.
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.
"""Counts the selection.""" from pynavis import selection, toast items = selection.get_items() toast.success('%d selected' % len(items))
"""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
__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:
- The
engine:key in the bundle'sbundle.yaml. - The
engine:key in the extension'sextension.yaml, which sets a default for every bundle in it. ironpython.
title: Analyse quantities
engine: cpythonironpython | cpython | |
|---|---|---|
| Version | IronPython 3.4 | Whatever is installed on the machine |
| Ships with pyNavis | Yes, including a 3.4-era stdlib | No, it locates an installed Python |
| Hosting | The DLR, one engine per session | pythonnet, one interpreter per process |
| Created | At boot | Lazily, on the first click of a CPython bundle |
| Per run | A fresh scope | A fresh scope |
| Subclass .NET and COM types | Yes | No |
| Compiled wheels | No | Yes |
| Shut down | With the session | Never, 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.
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.
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.
Splitting a large tool across files therefore costs you nothing in turnaround:
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 runImporting#
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.
"""Groups the active clash test.""" from pynavis import clash, forms, toast
"""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:
| Modules | Needs at import time | Consequence |
|---|---|---|
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:
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:
..._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:
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))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.
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.
from pynavis import toast toast.success('Exported 412 items', path)
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#
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.