Extending PyVista#
PyVista exposes a pandas/xarray-style accessor mechanism so third-party packages can attach custom filter methods to dataset classes without monkey-patching or subclassing. This is the recommended way to plug domain-specific operations (mesh repair, tetrahedralization, format conversion, remote IO) into the fluent filter API.
Why accessors#
PyVista inherits the problem every extensible scientific library in
Python eventually faces: users want to add methods to a core type
without forking it. pandas solved this first with
registered accessors
and xarray adopted the same pattern, growing an ecosystem of
downstream plugins such as
rioxarray and
pint-xarray
that hook in via ds.rio.reproject(...) and ds.pint.quantify()
without ever subclassing xarray’s core objects.
PyVista’s accessor mechanism follows the same contract. A plugin registers an accessor class against a target dataset type. When the plugin is imported, the accessor becomes available on every instance of that type (and its subclasses) under a single namespace.
The advantages over subclassing and monkey-patching:
Namespaced:
mesh.meshfix.repair(...)cannot collide with PyVista built-ins or with a second plugin’s methods.Lazy: the accessor class is instantiated on first access per instance, so unused plugins cost nothing.
Composable: accessor methods that return PyVista datasets chain naturally with core filters.
Per-instance caching: subsequent accesses on the same dataset return the same accessor object, which can hold per-mesh computed state without leaking across instances.
Writing an accessor#
An accessor class accepts the dataset instance as its single
__init__ argument and exposes any methods that should be callable
under the namespace.
# pymeshfix/_pyvista_plugin.py
import pyvista as pv
from pymeshfix import MeshFix
@pv.register_dataset_accessor("meshfix", pv.PolyData)
class MeshFixAccessor:
"""Accessor exposing pymeshfix on PolyData."""
def __init__(self, mesh):
self._mesh = mesh
def repair(
self,
*,
verbose=False,
joincomp=False,
remove_smallest_components=True
):
fix = MeshFix(self._mesh)
fix.repair(
verbose=verbose,
joincomp=joincomp,
remove_smallest_components=remove_smallest_components,
)
return fix.mesh
def has_holes(self):
return MeshFix(self._mesh).has_holes()
Once the plugin is imported, every PolyData instance
exposes the meshfix namespace:
import pyvista as pv
import pymeshfix # noqa: F401 — registers the .meshfix accessor
mesh = pv.PolyData("broken.ply")
result = mesh.clean().meshfix.repair(verbose=True).decimate(0.5)
The accessor registered against PolyData is visible
only on PolyData and its subclasses. To expose a method across
every dataset type, register against DataSet. To
cover MultiBlock as well, register against
DataObject.
Registration paths#
PyVista supports two ways to register an accessor. Both use the same
@register_dataset_accessor decorator. The difference is only
when the decorator runs.
Import-time. The plugin’s module runs the decorator at import
time, so any user who does import plugin gets the accessor
attached. This is the simplest pattern and is ideal for scripts,
notebooks, and tests:
# script.py
import pyvista as pv
import pymeshfix # noqa: F401 — registers the ``.meshfix`` accessor
pv.PolyData("broken.ply").meshfix.repair()
Entry points. The plugin declares a pyvista.accessors entry
point in its pyproject.toml pointing at its accessor module.
import pyvista reads the entry-point metadata but does not import
any plugin module. The plugin module imports on demand: on the first
dataset.<name> access whose normal attribute lookup misses,
PyVista resolves the pending entry, imports the module, lets its
top-level decorator attach the accessor, and completes the lookup.
Users never need an explicit import plugin:
# pymeshfix/pyproject.toml
[project.entry-points."pyvista.accessors"]
meshfix = "pymeshfix._accessor"
# script.py
import pyvista as pv
pv.PolyData(
"broken.ply"
).meshfix.repair() # works with no explicit import
Both paths populate the same registry. A plugin that declares an
entry point AND imports its accessor module from its __init__ is
safe (the second import is a no-op against sys.modules).
The lazy resolution means installing an accessor plugin does not
affect import pyvista performance or stability. A broken plugin
only surfaces when a user actually accesses its namespace: they get
a UserWarning pointing at the specific plugin and an
AttributeError on the call, and no other code is affected.
pv.registered_accessors() is the one call that explicitly forces
discovery of every pending plugin so the returned list reflects the
full picture.
For production plugins on PyPI, prefer the entry-point path: users
get zero-config discovery without any startup cost on import
pyvista. For in-script or in-notebook experimentation, the
decorator alone is simpler.
The entry-point key is the accessor namespace (meshfix above),
and the value must be a module path (no :ClassName suffix). A
plugin that registers multiple accessors from one module should
declare one entry-point line per namespace, all pointing at the same
module. Put the accessor in a small module like _accessor.py
that runs the decorator at import time and does nothing else; heavy
compute dependencies should be lazy-imported inside the accessor
methods, not at the module top.
Chaining and return types#
Accessor methods can return three kinds of things:
A PyVista dataset (the common case). Chaining continues seamlessly into core filters and into other plugins’ accessors.
A different PyVista type (for example, a filter that converts
PolyDatatoUnstructuredGrid). Chaining continues on the new type; any accessors registered against that type become available.A non-dataset value (for example, a
bool). Chaining terminates. Documented as a query method, not a filter.
For consistency with PyVista’s core filters, which are overwhelmingly functional, plugins should prefer returning a new dataset rather than mutating the input in place. The caller then decides whether to assign the result back.
Collision policy#
Two collision cases are handled differently:
Accessor-vs-accessor (two plugins register the same name on the same target): emits
UserWarningand replaces the previous accessor. Matches pandas’ behavior so a collision does not hard-break user scripts.Accessor shadowing a built-in attribute (a filter method, a property, or any other non-accessor attribute on the target or one of its ancestors): raises
ValueErrorunlessoverride=Trueis passed. This prevents accidental replacement of core PyVista methods.
@pv.register_dataset_accessor("clip", pv.PolyData)
class MyClipAccessor: ...
# ValueError: Cannot register accessor 'clip' on PolyData:
# shadows built-in attribute on DataSetFilters
# (inherited by PolyData). Pass override=True to force.
Typing and autocomplete#
Because accessors are attached at import time via a decorator, static
type checkers do not see the new attribute on the target class.
PyVista exports a DataSetAccessor structural
protocol so plugin authors can have type checkers verify their own
accessor class conforms to the expected shape:
import pyvista as pv
class MeshFixAccessor:
def __init__(self, mesh: pv.PolyData) -> None:
self._mesh = mesh
def repair(self) -> pv.PolyData: ...
# mypy / pyright verify that MeshFixAccessor's __init__ signature
# is compatible with the DataSetAccessor protocol.
_accessor_cls: type[pv.DataSetAccessor] = MeshFixAccessor
To give downstream users autocomplete on mesh.meshfix.repair(...),
plugin packages should ship a small .pyi stub that declares the
attribute on the target class. For example, pymeshfix/__init__.pyi:
from pyvista import PolyData
from pymeshfix._pyvista_plugin import MeshFixAccessor
# Surface the attribute that the import-time decorator installs at
# runtime. Type checkers do not execute decorators, so the stub is
# the only signal they see.
PolyData.meshfix: MeshFixAccessor # type: ignore[attr-defined]
This is the same pattern used by rioxarray and pint-xarray on
the xarray side, where the plugin ships stubs that teach editors and
type checkers about the attribute.
Deregistration#
For tests and interactive sessions, an accessor can be removed with
unregister_dataset_accessor(). Any built-in attribute
that was shadowed via override=True is restored.
pv.unregister_dataset_accessor("meshfix", pv.PolyData)
To inspect the current registry, call
registered_accessors(), which returns a tuple of
AccessorRegistration records describing each active
registration.
Cache semantics#
The first access of dataset.<name> constructs the accessor and
caches the result in the dataset instance’s __dict__. All
subsequent accesses return the same accessor object, which allows the
accessor to hold per-mesh computed state without re-running any setup
work.
If the underlying dataset is mutated in a way the accessor needs to
observe, evict the cache with del dataset.<name>; the next access
will rebuild the accessor. Avoid mutating the dataset from inside
accessor methods unless you explicitly document and test that
behavior — functional methods that return a new dataset are easier to
reason about.
subclassing (advanced)#
For use cases that genuinely require a custom class (for example,
adding persistent state that travels with the dataset through
filters), direct subclassing of PolyData or
UnstructuredGrid is still supported. See
Extending PyVista Example for the
subclassing pattern.
For everything else, prefer accessors. They compose more cleanly, cost nothing when unused, and give plugins a clear boundary the PyVista core can rely on.
Plotter components#
Datasets get accessors; the plotter gets components. The mechanism mirrors the dataset accessor pattern line for line on the registration API and adds explicit lifecycle hooks so plugin code that wires up VTK observers, sockets, or background threads has somewhere to clean up when the plotter shuts down.
A component class accepts the plotter as its single __init__
argument and exposes any methods that should be callable under the
namespace plotter.<name>.<method>(...). Two optional dunder hooks
participate in the plotter lifecycle:
__plotter_close__(self) -> None: called when the plotter closes. Use this to release VTK observers, close sockets, stop background threads, or undo any side effects the component is responsible for.__plotter_deep_clean__(self) -> None: called frompyvista.Plotter.deep_clean(). Optional; if absent, deep clean falls through to the close path on the next plotter shutdown.
Both hooks fire only on components that were actually constructed (touched at least once). Untouched components contribute nothing, since they hold no observers or references that need releasing.
# pyvista_tui/_pyvista_plugin.py
import pyvista as pv
@pv.register_plotter_component("tui")
class TuiComponent:
def __init__(self, plotter):
self._plotter = plotter
self._socket = None
def serve(self, port=8765):
self._socket = _open_socket(port)
...
def __plotter_close__(self):
if self._socket is not None:
self._socket.close()
The same decorator works for first-party and third-party components.
Registration uses register_plotter_component(),
introspection uses registered_plotter_components(),
and removal uses unregister_plotter_component(). Plugin
authors can declare a pyvista.plotter_components entry point in
their pyproject.toml for zero-config discovery. The plugin module
itself is imported only when a user first accesses
plotter.<plugin_name>, so installing the plugin costs nothing for
plotters that never use it.
See Plotter Components for the full registration API.