Best practices#
There are a number of good and bad practices that may not be immediately obvious when developing a plugin. This page covers some known practices that could affect the ability to install or use your plugin effectively.
Don’t include PySide2
or PyQt5
in your plugin’s dependencies.#
This is important!
Napari supports both PyQt and PySide backends for Qt. It is up to the
end-user to choose which one they want. If they installed napari with pip install napari[all]
, then the [all]
extra will (currently) install PyQt5
for them from pypi. If they installed via conda install napari
, then they’ll
have PyQt5
, but via anaconda cloud instead of pypi. Lastly, they may have
installed napari with PySide2.
Here’s what can go wrong if you also declare one of these backends in the
install_requires
section of your plugin metadata:
If they installed via
conda install napari
and then they install your plugin viapip
(or via the builtin plugin installer, which currently usespip
), then there will be a binary incompatibility between their condapyqt
installation, and the new pip “PyQt5
” installation. This will very likely lead to a broken environment, forcing the user to re-create their entire environment and re-install napari. This is an unfortunate consequence of package naming decisions, and it’s not something napari can fix.Alternatively, they may end up with both PyQt and PySide in their environment, and while that’s not always guaranteed to break things, it can lead to unexpected and difficult to debug problems.
Don’t import from PyQt5 or PySide2 in your plugin: use
qtpy
.If you use
from PyQt5 import QtCore
(or similar) in your plugin, but the end-user has chosen to usePySide2
for their Qt backend — or vice versa — then your plugin will fail to import. Instead usefrom qtpy import QtCore
.qtpy
is a Qt compatibility layer that will import from whatever backend is installed in the environment.
Try not to depend on packages that require C compilation if these packages do not offer wheels#
Tip
This requires some awareness of how your dependencies are built and distributed…
Some python packages write a portion of their code in lower level languages like
C or C++ and compile that code into “C Extensions” that can be called by python
at runtime. This can greatly improve performance, but it means that the
package must be compiled for each platform (i.e. Windows, Mac, Linux) that the
package wants to support. Some packages do this compilation step ahead of time,
by distributing “wheels” on
PyPI… or by providing pre-compiled packages via conda
.
Other packages simply distribute the source code (as an “sdist”) and expect the
end-user to compile it on their own computer. Compiling C code requires
software that is not always installed on every computer. (If you’ve ever tried
to python -m pip install
a package and had it fail with a big wall of red text saying
something about gcc
, then you’ve run into a package that doesn’t distribute
wheels, and you didn’t have the software required to compile it).
As a plugin developer, if you depend on a package that uses C extensions but doesn’t distribute a pre-compiled wheel, then it’s very likely that your users will run into difficulties installing your plugin:
What is a “wheel”?
Briefly, a wheel is a built distribution, containing code that is pre-compiled for a specific operating system.
For more detail, see What Are Python Wheels and Why Should You Care?
How do I know if my dependency offers a wheel
There are many ways, but a sure-fire way to know is to go to the respective package on PyPI, and click on the “Download Files” link. If the package offers wheels, you’ll see one or more files ending in
.whl
. For example, napari offers a wheel. If a package doesn’t offer a wheel, it may still be ok if it’s just a pure python package that doesn’t have any C extensions…How do I know if one of my dependencies uses C Extensions?
There’s no one right way, but more often than not, if a package uses C extensions, the
setup()
function in theirsetup.py
file will use theext_modules
argument. (for example, see here in pytorch)
What about conda?
conda also distributes & installs pre-compiled packages, though they aren’t wheels. While this is definitely a fine way to install binary dependencies in a reliable way, the built-in napari plugin installer doesn’t currently work with conda. If your dependency is only available on conda, but does not offer wheels,you may guide your users in using conda to install your package or one of your dependencies. Just know that it may not work with the built-in plugin installer.
Don’t import heavy dependencies at the top of your module#
Note
This point will be less relevant when we move to the second generation manifest-based plugin declaration, but it’s still a good idea to delay importing your plugin-specific dependencies and modules until after your hookspec has been called. This helps napari stay quick and responsive at startup.
Consider the following example plugin:
[options.entry_points]
napari.plugin =
plugin-name = mypackage.napari_plugin
In this example, my_heavy_dependency_like_tensorflow
will be imported
immediately when napari is launched, and we search the entry_point
mypackage.napari_plugin
for decorated hook specifications.
# mypackage/napari_plugin.py
from napari_plugin_engine import napari_hook_specification
from qtpy.QtWidgets import QWidget
from my_heavy_dependency_like_tensorflow import something_amazing
class MyWidget(QWidget):
def do_something_amazing(self):
return something_amazing()
@napari_hook_specification
def napari_experimental_provide_dock_widget():
return MyWidget
This can deterioate the end-user experience, and make napari feel slugish. Best practice is to delay heavy imports until right before they are used. The following slight modification will help napari load much faster:
# mypackage/napari_plugin.py
from napari_plugin_engine import napari_hook_specification
from qtpy.QtWidgets import QWidget
class MyWidget(QWidget):
def do_something_amazing(self):
# import has been moved here, will happen only after the user
# has opened and used this widget.
from my_heavy_dependency_like_tensorflow import something_amazing
return something_amazing()
(again, the second gen napari plugin engine will help improve this situation, but it’s still a good idea!)
Don’t require napari
if not necessary#
It’s good practice to not depend on napari
if not strictly necessary.
If you only use napari
for type annotations, we recommend that you use strings
instead of importing the types. This is called a
Forward reference.
For example, you can see in the
widget contribution guide that napari type annotations
are strings and not imported.
If you’d like to maintain IDE type support and autocompletion, you can
still do so by hiding the napari imports inside of a typing.TYPE_CHECKING
clause:
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import napari
@magicgui
def my_func(data: 'napari.types.ImageData') -> 'napari.types.ImageData':
...
This will not require napari at runtime, but if it is installed in your development environment, you will still get all the type inference.
Don’t leave resources open#
It’s always good practice to clean up resources like open file handles and databases. As a napari plugin it’s particularly important to do this (and especially for Windows users). If someone tries to use the built-in plugin manager to uninstall your plugin, open file handles and resources may cause the process to fail or even leave your plugin in an “installed-but-unuseable” state.
Don’t do this:
# my_plugin/module.py
import json
data_file = open("some_data_in_my_plugin.json")
data = json.load(data_file)
Instead, make sure to close your resource after grabbing the data (ideally by using a context manager, but manually otherwise):
with open("some_data_in_my_plugin.json") as data_file:
data = json.load(data_file)
Write extensive tests for your plugin!#
Programmer and author Bruce Eckel famously wrote:
“If it’s not tested, it’s broken”
It’s true. High test coverage is one way to show your users that you are dedicated to the stability of your plugin. Aim for 100%!
Of course, simply having 100% coverage doesn’t mean your code is bug-free, so make sure that you test all of the various ways that your code might be called.
See Tips for testing napari plugins.
How to check test coverage?#
The napari plugin template is already set up to report test coverage, but you can test locally as well, using pytest-cov
python -m pip install pytest-cov
Run your tests with
pytest --cov=<your_package> --cov-report=html
Open the resulting report in your browser:
open htmlcov/index.html
The report will show line-by-line what is being tested, and what is being missed. Continue writing tests until everything is covered! If you have lines that you know never need to be tested (like debugging code) you can exempt specific lines from coverage with the comment
# pragma: no cover
In the napari plugin template, coverage tests from github actions will be uploaded to codecov.io
Set style for additional windows in your plugin#
In napari plugins we strongly advise additional widgets be docked in the main napari viewer,
but sometimes a separate window is required.
The best practice is to use QDialog
based windows with parent set to widget
already docked in the viewer.
from qtpy.QtWidgets import QDialog, QWidget, QSpinBox, QPushButton, QGridLayout, QLabel
class MyInputDialog(QDialog):
def __init__(self, parent: QWidget):
super().__init__(parent)
self.setWindowTitle("My Input Dialog")
self.number = QSpinBox()
self.ok_btn = QPushButton("OK")
self.cancel_btn = QPushButton("Cancel")
layout = QGridLayout()
layout.addWidget(QLabel("Number:"), 0, 0)
layout.addWidget(self.number, 0, 1)
layout.addWidget(self.ok_btn, 1, 0)
layout.addWidget(self.cancel_btn, 1, 1)
self.setLayout(layout)
self.ok_btn.clicked.connect(self.accept)
self.cancel_btn.clicked.connect(self.reject)
class MyWidget(QWidget):
def __init__(self, viewer: "napari.Viewer"):
super().__init__()
self.viewer = viewer
self.open_dialog = QPushButton("Open dialog")
self.open_dialog.clicked.connect(self.open_dialog_clicked)
def open_dialog_clicked(self):
# setting parent to self allows the dialog to inherit its
# style from the viewer by pass self as argument
dialog = MyInputDialog(self)
dialog.exec_()
if dialog.result() == QDialog.Accepted:
print(dialog.number.value())
If there is a particular reason that you need to use a separate window that
inherits from QWidget
, not QDialog
, then you could use the get_current_stylesheet
and get_stylesheet
functions from the
napari.qt
module.
Here is a magicgui
example (but could be easily generalised to native qt
based widgets):
from magicgui import magicgui
from napari.qt import get_current_stylesheet
from napari.settings import get_settings
def sample_add(a: int, b: int) -> int:
return a + b
@magicgui
def sample_add(a: int, b: int) -> int:
return a + b
def change_style():
sample_add.native.setStyleSheet(get_current_stylesheet())
get_settings().appearance.events.theme.connect(change_style)
change_style()
Do not package your tests as a top-level package#
If you are using the napari plugin template, your tests are already packaged in the correct way. No further action required!
# project structure suggested by the napari plugin template
src/
my_package/
_tests/
test_my_module.py
__init__.py
my_module.py
pyproject.toml
README.md
However, if your project structure is already following a different scheme, the testing logic might live outside your package, as a top-level directory:
# alternative structure, no src/ directory, testing logic outside the package
my_package/
__init__.py
my_module.py
tests/
conftest.py
test_my_module.py
pyproject.toml
README.md
Under these circumstances, your build backend (usually setuptools
) might include tests
as a
separate package that will be installed next to my_package
!
Most of the time, this is not wanted; e.g. do you want to do import tests
? Probably not!
Additionally, this unwanted behavior might cause installation issues with other projects.
Ideally, you could change your project structure to follow the recommended skeleton followed in the napari plugin template. Howevever, if that’s unfeasible, you can fix this in the project metadata files.
You need to explicitly exclude the top-level tests
directory from the packaged contents:
# pyproject.toml
...
[options.packages.find]
exclude =
tests
tests.*
# setup.py
...
setup(
...
packages=find_packages(exclude=("tests", "tests.*")),
...
)
Note this also applies to other top-level directories, like test
, _tests
, testing
, etc.
You can find more information in the
package discovery documentation for setuptools
.
License issues when including code from 3rd parties#
Plugins will often depend on 3rd party packages beyond napari
itself.
These dependencies are usually included in the project metadata in pyproject.toml
.
However, sometimes developers might include code from 3rd parties directly in their project.
Sometimes it will be just a little snippet, maybe slightly modified to suit the project needs.
Some other times, a whole project will be included entirely (vendoring).
This constitutes an act of source code redistribution, which is usually covered by many licensing schemes. Most of the time, this means you need to explicitly include the vendored project license in the source. This is the case for Apache, BSD and MIT-style licenses. Do note that some projects might NOT allow redistribution without explicit approval. Others will prevent it entirely… Be mindful and check the requirements before distributing your package!
Note
If you are vendoring other projects, please add an acknowledgement in your README. The license details in your project metadata should also include this information!