Repository: napari/napari

Issue:
Consider adding an explicit dependency on `pip`
## 🐛 Bug

<!-- A clear and concise description of what the bug is. -->

The plugin manager depends on `pip` being present in the same installation/environment as `sys.executable`. This is usually the case since:

* Most users install `napari` with `pip` anyway
* Conda users won't have changed the default settings, which always include `pip` next to every `python` install ([see feedstock issue for longer explanation](https://github.com/conda-forge/napari-feedstock/issues/43)).

That said, there are scenarios where `pip` might not be present; e.g. installing with a hypothetical tool that can take packages from PyPI without depending on `pip`. I don't know (and I haven't checked), but maybe `poetry` or `flit` fall in this category.

Besides that, I think it's good practice to be explicit about the requirements and we should not assume that `pip` is going to be present just because most of the time it is.

## To Reproduce

With `conda`, you can reach this situation like this:

1. `CONDA_ADD_PIP_AS_PYTHON_DEPENDENCY=false conda create -n napari-no-pip napari`
2. `conda activate napari-no-pip`
3. `napari`
4. Install anything with the plugin manager, you'll see `No module named pip`.

<!-- If you have a code sample, error messages, stack traces, please provide it here as well -->

## Expected behavior

<!-- A clear and concise description of what you expected to happen. -->

`pip` is always installed as a `napari` dependency.

Alternatively, if we package the plugin manager as a plugin itself, this can be dealt with the hypothetical `napari-plugin-manager` package metadata. (I would welcome this change).

## Environment

 - Please copy and paste the information at napari info option in help menubar here:

```
$ napari --info
napari: 0.4.17
Platform: macOS-11.2.3-arm64-arm-64bit
System: MacOS 11.2.3
Python: 3.10.6 | packaged by conda-forge | (main, Aug 22 2022, 20:41:22) [Clang 13.0.1 ]
Qt: 5.15.6
PyQt5: 5.15.7
NumPy: 1.23.4
SciPy: 1.9.3
Dask: 2022.10.2
VisPy: 0.11.0
magicgui: 0.6.0
superqt: 0.4.0
in-n-out: 0.1.6
app-model: 0.1.1
npe2: 0.6.1

OpenGL:
  - GL version:  2.1 Metal - 71.0.7
  - MAX_TEXTURE_SIZE: 16384

Screens:
  - screen 1: resolution 1440x900, scale 2.0
  - screen 2: resolution 2560x1440, scale 1.0

Plugins:
  - napari: 0.4.17 (77 contributions)
  - napari-console: 0.0.6 (0 contributions)
  - napari-svg: 0.1.6 (2 contributions)
```

 - Any other relevant information:

## Additional context

<!-- Add any other context about the problem here. -->

<details>

<summary>pip-less environment contents for step 1 above:</summary>

```
  alabaster          conda-forge/noarch::alabaster-0.7.12-py_0 None
  aom                conda-forge/osx-arm64::aom-3.5.0-h7ea286d_0 None
  app-model          conda-forge/noarch::app-model-0.1.1-pyhd8ed1ab_0 None
  appdirs            conda-forge/noarch::appdirs-1.4.4-pyh9f0ad1d_0 None
  appnope            conda-forge/noarch::appnope-0.1.3-pyhd8ed1ab_0 None
  asciitree          conda-forge/noarch::asciitree-0.3.3-py_2 None
  asttokens          conda-forge/noarch::asttokens-2.1.0-pyhd8ed1ab_0 None
  attrs              conda-forge/noarch::attrs-22.1.0-pyh71513ae_1 None
  babel              conda-forge/noarch::babel-2.11.0-pyhd8ed1ab_0 None
  backcall           conda-forge/noarch::backcall-0.2.0-pyh9f0ad1d_0 None
  backports          conda-forge/noarch::backports-1.0-py_2 None
  backports.functoo~ conda-forge/noarch::backports.functools_lru_cache-1.6.4-pyhd8ed1ab_0 None
  blosc              conda-forge/osx-arm64::blosc-1.21.1-hd414afc_3 None
  bokeh              conda-forge/noarch::bokeh-2.4.3-pyhd8ed1ab_3 None
  brotli             conda-forge/osx-arm64::brotli-1.0.9-h1a8c8d9_8 None
  brotli-bin         conda-forge/osx-arm64::brotli-bin-1.0.9-h1a8c8d9_8 None
  brotlipy           conda-forge/osx-arm64::brotlipy-0.7.0-py310h8e9501a_1005 None
  brunsli            conda-forge/osx-arm64::brunsli-0.1-h9f76cd9_0 None
  bzip2              conda-forge/osx-arm64::bzip2-1.0.8-h3422bc3_4 None
  c-ares             conda-forge/osx-arm64::c-ares-1.18.1-h3422bc3_0 None
  c-blosc2           conda-forge/osx-arm64::c-blosc2-2.4.3-h303ed30_0 None
  ca-certificates    conda-forge/osx-arm64::ca-certificates-2022.9.24-h4653dfc_0 None
  cachey             conda-forge/noarch::cachey-0.2.1-pyh9f0ad1d_0 None
  certifi            conda-forge/noarch::certifi-2022.9.24-pyhd8ed1ab_0 None
  cffi               conda-forge/osx-arm64::cffi-1.15.1-py310h2399d43_2 None
  cfitsio            conda-forge/osx-arm64::cfitsio-4.1.0-hd4f5c17_0 None
  charls             conda-forge/osx-arm64::charls-2.3.4-hbdafb3b_0 None
  charset-normalizer conda-forge/noarch::charset-normalizer-2.1.1-pyhd8ed1ab_0 None
  click              conda-forge/noarch::click-8.1.3-unix_pyhd8ed1ab_2 None
  cloudpickle        conda-forge/noarch::cloudpickle-2.2.0-pyhd8ed1ab_0 None
  colorama           conda-forge/noarch::colorama-0.4.6-pyhd8ed1ab_0 None
  commonmark         conda-forge/noarch::commonmark-0.9.1-py_0 None
  cryptography       conda-forge/osx-arm64::cryptography-38.0.3-py310h4fe9c50_0 None
  cytoolz            conda-forge/osx-arm64::cytoolz-0.12.0-py310h8e9501a_1 None
  dask               conda-forge/noarch::dask-2022.10.2-pyhd8ed1ab_0 None
  dask-core          conda-forge/noarch::dask-core-2022.10.2-pyhd8ed1ab_0 None
  dataclasses        conda-forge/noarch::dataclasses-0.8-pyhc8e2a94_3 None
  dav1d              conda-forge/osx-arm64::dav1d-1.0.0-he4db4b2_1 None
  debugpy            conda-forge/osx-arm64::debugpy-1.6.3-py310h0f1eb42_1 None
  decorator          conda-forge/noarch::decorator-5.1.1-pyhd8ed1ab_0 None
  distributed        conda-forge/noarch::distributed-2022.10.2-pyhd8ed1ab_0 None
  docstring_parser   conda-forge/noarch::docstring_parser-0.13-pyhd8ed1ab_0 None
  docutils           conda-forge/osx-arm64::docutils-0.17.1-py310hbe9552e_3 None
  entrypoints        conda-forge/noarch::entrypoints-0.4-pyhd8ed1ab_0 None
  executing          conda-forge/noarch::executing-1.2.0-pyhd8ed1ab_0 None
  expat              conda-forge/osx-arm64::expat-2.5.0-hb7217d7_0 None
  fasteners          conda-forge/noarch::fasteners-0.17.3-pyhd8ed1ab_0 None
  font-ttf-dejavu-s~ conda-forge/noarch::font-ttf-dejavu-sans-mono-2.37-hab24e00_0 None
  font-ttf-inconsol~ conda-forge/noarch::font-ttf-inconsolata-3.000-h77eed37_0 None
  font-ttf-source-c~ conda-forge/noarch::font-ttf-source-code-pro-2.038-h77eed37_0 None
  font-ttf-ubuntu    conda-forge/noarch::font-ttf-ubuntu-0.83-hab24e00_0 None
  fontconfig         conda-forge/osx-arm64::fontconfig-2.14.1-h82840c6_0 None
  fonts-conda-ecosy~ conda-forge/noarch::fonts-conda-ecosystem-1-0 None
  fonts-conda-forge  conda-forge/noarch::fonts-conda-forge-1-0 None
  freetype           conda-forge/osx-arm64::freetype-2.12.1-hd633e50_0 None
  freetype-py        conda-forge/noarch::freetype-py-2.3.0-pyhd8ed1ab_0 None
  fsspec             conda-forge/noarch::fsspec-2022.11.0-pyhd8ed1ab_0 None
  future             conda-forge/noarch::future-0.18.2-pyhd8ed1ab_6 None
  gettext            conda-forge/osx-arm64::gettext-0.21.1-h0186832_0 None
  giflib             conda-forge/osx-arm64::giflib-5.2.1-h27ca646_2 None
  glib               conda-forge/osx-arm64::glib-2.74.1-hb5ab8b9_1 None
  glib-tools         conda-forge/osx-arm64::glib-tools-2.74.1-hb5ab8b9_1 None
  gst-plugins-base   conda-forge/osx-arm64::gst-plugins-base-1.21.1-h8b7775e_1 None
  gstreamer          conda-forge/osx-arm64::gstreamer-1.21.1-hcb7b3dd_1 None
  heapdict           conda-forge/noarch::heapdict-1.0.1-py_0 None
  hsluv              conda-forge/noarch::hsluv-5.0.2-pyh44b312d_0 None
  icu                conda-forge/osx-arm64::icu-70.1-h6b3803e_0 None
  idna               conda-forge/noarch::idna-3.4-pyhd8ed1ab_0 None
  imagecodecs        conda-forge/osx-arm64::imagecodecs-2022.9.26-py310h24fba19_3 None
  imageio            conda-forge/noarch::imageio-2.22.0-pyhfa7a67d_0 None
  imagesize          conda-forge/noarch::imagesize-1.4.1-pyhd8ed1ab_0 None
  importlib-metadata conda-forge/noarch::importlib-metadata-5.0.0-pyha770c72_1 None
  importlib_resourc~ conda-forge/noarch::importlib_resources-5.10.0-pyhd8ed1ab_0 None
  in-n-out           conda-forge/noarch::in-n-out-0.1.6-pyhd8ed1ab_0 None
  ipykernel          conda-forge/noarch::ipykernel-6.17.1-pyh736e0ef_0 None
  ipython            conda-forge/noarch::ipython-8.6.0-pyhd1c38e8_1 None
  ipython_genutils   conda-forge/noarch::ipython_genutils-0.2.0-py_1 None
  jedi               conda-forge/noarch::jedi-0.18.1-pyhd8ed1ab_2 None
  jinja2             conda-forge/noarch::jinja2-3.1.2-pyhd8ed1ab_1 None
  jpeg               conda-forge/osx-arm64::jpeg-9e-he4db4b2_2 None
  jsonschema         conda-forge/noarch::jsonschema-4.17.0-pyhd8ed1ab_0 None
  jupyter_client     conda-forge/noarch::jupyter_client-7.3.4-pyhd8ed1ab_0 None
  jupyter_core       conda-forge/osx-arm64::jupyter_core-5.0.0-py310hbe9552e_0 None
  jxrlib             conda-forge/osx-arm64::jxrlib-1.1-h27ca646_2 None
  kiwisolver         conda-forge/osx-arm64::kiwisolver-1.4.4-py310h2887b22_1 None
  krb5               conda-forge/osx-arm64::krb5-1.19.3-hf9b2bbe_0 None
  lcms2              conda-forge/osx-arm64::lcms2-2.14-h8193b64_0 None
  lerc               conda-forge/osx-arm64::lerc-4.0.0-h9a09cb3_0 None
  libaec             conda-forge/osx-arm64::libaec-1.0.6-hbdafb3b_0 None
  libavif            conda-forge/osx-arm64::libavif-0.11.1-h3d80962_0 None
  libblas            conda-forge/osx-arm64::libblas-3.9.0-16_osxarm64_openblas None
  libbrotlicommon    conda-forge/osx-arm64::libbrotlicommon-1.0.9-h1a8c8d9_8 None
  libbrotlidec       conda-forge/osx-arm64::libbrotlidec-1.0.9-h1a8c8d9_8 None
  libbrotlienc       conda-forge/osx-arm64::libbrotlienc-1.0.9-h1a8c8d9_8 None
  libcblas           conda-forge/osx-arm64::libcblas-3.9.0-16_osxarm64_openblas None
  libclang           conda-forge/osx-arm64::libclang-15.0.4-default_h9e54d93_0 None
  libclang13         conda-forge/osx-arm64::libclang13-15.0.4-default_h7d574e7_0 None
  libcurl            conda-forge/osx-arm64::libcurl-7.86.0-hd538317_1 None
  libcxx             conda-forge/osx-arm64::libcxx-14.0.6-h2692d47_0 None
  libdeflate         conda-forge/osx-arm64::libdeflate-1.14-h1a8c8d9_0 None
  libedit            conda-forge/osx-arm64::libedit-3.1.20191231-hc8eb9b7_2 None
  libev              conda-forge/osx-arm64::libev-4.33-h642e427_1 None
  libffi             conda-forge/osx-arm64::libffi-3.4.2-h3422bc3_5 None
  libgfortran        conda-forge/osx-arm64::libgfortran-5.0.0-11_3_0_hd922786_26 None
  libgfortran5       conda-forge/osx-arm64::libgfortran5-11.3.0-hdaf2cc0_26 None
  libglib            conda-forge/osx-arm64::libglib-2.74.1-h4646484_1 None
  libiconv           conda-forge/osx-arm64::libiconv-1.17-he4db4b2_0 None
  liblapack          conda-forge/osx-arm64::liblapack-3.9.0-16_osxarm64_openblas None
  libllvm15          conda-forge/osx-arm64::libllvm15-15.0.4-h62b9111_1 None
  libnghttp2         conda-forge/osx-arm64::libnghttp2-1.47.0-h232270b_1 None
  libogg             conda-forge/osx-arm64::libogg-1.3.4-h27ca646_1 None
  libopenblas        conda-forge/osx-arm64::libopenblas-0.3.21-openmp_hc731615_3 None
  libopus            conda-forge/osx-arm64::libopus-1.3.1-h27ca646_1 None
  libpng             conda-forge/osx-arm64::libpng-1.6.38-h76d750c_0 None
  libpq              conda-forge/osx-arm64::libpq-14.5-h3f71257_1 None
  libsodium          conda-forge/osx-arm64::libsodium-1.0.18-h27ca646_1 None
  libsqlite          conda-forge/osx-arm64::libsqlite-3.39.4-h76d750c_0 None
  libssh2            conda-forge/osx-arm64::libssh2-1.10.0-hb80f160_3 None
  libtiff            conda-forge/osx-arm64::libtiff-4.4.0-hfa0b094_4 None
  libvorbis          conda-forge/osx-arm64::libvorbis-1.3.7-h9f76cd9_0 None
  libwebp-base       conda-forge/osx-arm64::libwebp-base-1.2.4-h57fd34a_0 None
  libxcb             conda-forge/osx-arm64::libxcb-1.13-h9b22ae9_1004 None
  libxml2            conda-forge/osx-arm64::libxml2-2.10.3-h87b0503_0 None
  libzlib            conda-forge/osx-arm64::libzlib-1.2.13-h03a7124_4 None
  libzopfli          conda-forge/osx-arm64::libzopfli-1.0.3-h9f76cd9_0 None
  llvm-openmp        conda-forge/osx-arm64::llvm-openmp-15.0.4-h7cfbb63_0 None
  locket             conda-forge/noarch::locket-1.0.0-pyhd8ed1ab_0 None
  lz4                conda-forge/osx-arm64::lz4-4.0.2-py310ha6df754_0 None
  lz4-c              conda-forge/osx-arm64::lz4-c-1.9.3-hbdafb3b_1 None
  magicgui           conda-forge/noarch::magicgui-0.6.0-pyhd8ed1ab_0 None
  markupsafe         conda-forge/osx-arm64::markupsafe-2.1.1-py310h8e9501a_2 None
  matplotlib-inline  conda-forge/noarch::matplotlib-inline-0.1.6-pyhd8ed1ab_0 None
  msgpack-python     conda-forge/osx-arm64::msgpack-python-1.0.4-py310h2887b22_1 None
  mysql-common       conda-forge/osx-arm64::mysql-common-8.0.31-hab468bb_0 None
  mysql-libs         conda-forge/osx-arm64::mysql-libs-8.0.31-hea58576_0 None
  napari             conda-forge/noarch::napari-0.4.17-pyh275ddea_0_pyqt None
  napari-console     conda-forge/noarch::napari-console-0.0.6-pyhd8ed1ab_0 None
  napari-plugin-eng~ conda-forge/noarch::napari-plugin-engine-0.2.0-pyhd8ed1ab_2 None
  napari-svg         conda-forge/noarch::napari-svg-0.1.6-pyhd8ed1ab_0 None
  ncurses            conda-forge/osx-arm64::ncurses-6.3-h07bb92c_1 None
  nest-asyncio       conda-forge/noarch::nest-asyncio-1.5.6-pyhd8ed1ab_0 None
  networkx           conda-forge/noarch::networkx-2.8.8-pyhd8ed1ab_0 None
  npe2               conda-forge/noarch::npe2-0.6.1-pyhd8ed1ab_1 None
  nspr               conda-forge/osx-arm64::nspr-4.32-hbdafb3b_1 None
  nss                conda-forge/osx-arm64::nss-3.78-h1483a63_0 None
  numcodecs          conda-forge/osx-arm64::numcodecs-0.10.2-py310hc6dc59f_0 None
  numpy              conda-forge/osx-arm64::numpy-1.23.4-py310h5d7c261_1 None
  numpydoc           conda-forge/noarch::numpydoc-1.5.0-pyhd8ed1ab_0 None
  openjpeg           conda-forge/osx-arm64::openjpeg-2.5.0-h5d4e404_1 None
  openssl            conda-forge/osx-arm64::openssl-1.1.1s-h03a7124_0 None
  packaging          conda-forge/noarch::packaging-21.3-pyhd8ed1ab_0 None
  pandas             conda-forge/osx-arm64::pandas-1.5.1-py310h2b830bf_1 None
  parso              conda-forge/noarch::parso-0.8.3-pyhd8ed1ab_0 None
  partd              conda-forge/noarch::partd-1.3.0-pyhd8ed1ab_0 None
  pcre2              conda-forge/osx-arm64::pcre2-10.40-hb34f9b4_0 None
  pep517             conda-forge/noarch::pep517-0.13.0-pyhd8ed1ab_0 None
  pexpect            conda-forge/noarch::pexpect-4.8.0-pyh1a96a4e_2 None
  pickleshare        conda-forge/noarch::pickleshare-0.7.5-py_1003 None
  pillow             conda-forge/osx-arm64::pillow-9.2.0-py310h9337a76_3 None
  pint               conda-forge/noarch::pint-0.20.1-pyhd8ed1ab_0 None
  pkgutil-resolve-n~ conda-forge/noarch::pkgutil-resolve-name-1.3.10-pyhd8ed1ab_0 None
  platformdirs       conda-forge/noarch::platformdirs-2.5.2-pyhd8ed1ab_1 None
  ply                conda-forge/noarch::ply-3.11-py_1 None
  pooch              conda-forge/noarch::pooch-1.6.0-pyhd8ed1ab_0 None
  prompt-toolkit     conda-forge/noarch::prompt-toolkit-3.0.32-pyha770c72_0 None
  psutil             conda-forge/osx-arm64::psutil-5.9.4-py310h8e9501a_0 None
  psygnal            conda-forge/osx-arm64::psygnal-0.6.0-py310h2887b22_0 None
  pthread-stubs      conda-forge/osx-arm64::pthread-stubs-0.4-h27ca646_1001 None
  ptyprocess         conda-forge/noarch::ptyprocess-0.7.0-pyhd3deb0d_0 None
  pure_eval          conda-forge/noarch::pure_eval-0.2.2-pyhd8ed1ab_0 None
  pycparser          conda-forge/noarch::pycparser-2.21-pyhd8ed1ab_0 None
  pydantic           conda-forge/osx-arm64::pydantic-1.10.2-py310h8e9501a_1 None
  pygments           conda-forge/noarch::pygments-2.13.0-pyhd8ed1ab_0 None
  pyopengl           conda-forge/noarch::pyopengl-3.1.6-pyhd8ed1ab_1 None
  pyopenssl          conda-forge/noarch::pyopenssl-22.1.0-pyhd8ed1ab_0 None
  pyparsing          conda-forge/noarch::pyparsing-3.0.9-pyhd8ed1ab_0 None
  pyqt               conda-forge/osx-arm64::pyqt-5.15.7-py310h7aaa74b_2 None
  pyqt5-sip          conda-forge/osx-arm64::pyqt5-sip-12.11.0-py310h0f1eb42_2 None
  pyrsistent         conda-forge/osx-arm64::pyrsistent-0.19.2-py310h8e9501a_0 None
  pysocks            conda-forge/noarch::pysocks-1.7.1-pyha2e5f31_6 None
  python             conda-forge/osx-arm64::python-3.10.6-hbce4517_0_cpython None
  python-build       conda-forge/noarch::python-build-0.9.0-pyhd8ed1ab_0 None
  python-dateutil    conda-forge/noarch::python-dateutil-2.8.2-pyhd8ed1ab_0 None
  python_abi         conda-forge/osx-arm64::python_abi-3.10-2_cp310 None
  pytomlpp           conda-forge/osx-arm64::pytomlpp-1.0.11-py310h2887b22_1 None
  pytz               conda-forge/noarch::pytz-2022.6-pyhd8ed1ab_0 None
  pywavelets         conda-forge/osx-arm64::pywavelets-1.3.0-py310hf1a086a_2 None
  pyyaml             conda-forge/osx-arm64::pyyaml-6.0-py310h8e9501a_5 None
  pyzmq              conda-forge/osx-arm64::pyzmq-24.0.1-py310hc407298_1 None
  qt-main            conda-forge/osx-arm64::qt-main-5.15.6-hf3dd84c_1 None
  qtconsole-base     conda-forge/noarch::qtconsole-base-5.4.0-pyha770c72_0 None
  qtpy               conda-forge/noarch::qtpy-2.3.0-pyhd8ed1ab_0 None
  readline           conda-forge/osx-arm64::readline-8.1.2-h46ed386_0 None
  requests           conda-forge/noarch::requests-2.28.1-pyhd8ed1ab_1 None
  rich               conda-forge/noarch::rich-12.6.0-pyhd8ed1ab_0 None
  scikit-image       conda-forge/osx-arm64::scikit-image-0.19.3-py310h2b830bf_2 None
  scipy              conda-forge/osx-arm64::scipy-1.9.3-py310ha0d8a01_2 None
  setuptools         conda-forge/noarch::setuptools-65.5.1-pyhd8ed1ab_0 None
  shellingham        conda-forge/noarch::shellingham-1.5.0-pyhd8ed1ab_0 None
  sip                conda-forge/osx-arm64::sip-6.7.4-py310h0f1eb42_0 None
  six                conda-forge/noarch::six-1.16.0-pyh6c4a22f_0 None
  snappy             conda-forge/osx-arm64::snappy-1.1.9-h17c5cce_2 None
  snowballstemmer    conda-forge/noarch::snowballstemmer-2.2.0-pyhd8ed1ab_0 None
  sortedcontainers   conda-forge/noarch::sortedcontainers-2.4.0-pyhd8ed1ab_0 None
  sphinx             conda-forge/noarch::sphinx-4.5.0-pyh6c4a22f_0 None
  sphinxcontrib-app~ conda-forge/noarch::sphinxcontrib-applehelp-1.0.2-py_0 None
  sphinxcontrib-dev~ conda-forge/noarch::sphinxcontrib-devhelp-1.0.2-py_0 None
  sphinxcontrib-htm~ conda-forge/noarch::sphinxcontrib-htmlhelp-2.0.0-pyhd8ed1ab_0 None
  sphinxcontrib-jsm~ conda-forge/noarch::sphinxcontrib-jsmath-1.0.1-py_0 None
  sphinxcontrib-qth~ conda-forge/noarch::sphinxcontrib-qthelp-1.0.3-py_0 None
  sphinxcontrib-ser~ conda-forge/noarch::sphinxcontrib-serializinghtml-1.1.5-pyhd8ed1ab_2 None
  sqlite             conda-forge/osx-arm64::sqlite-3.39.4-h2229b38_0 None
  stack_data         conda-forge/noarch::stack_data-0.6.0-pyhd8ed1ab_0 None
  superqt            conda-forge/noarch::superqt-0.4.0-pyhd8ed1ab_0 None
  tblib              conda-forge/noarch::tblib-1.7.0-pyhd8ed1ab_0 None
  tifffile           conda-forge/noarch::tifffile-2022.10.10-pyhd8ed1ab_0 None
  tk                 conda-forge/osx-arm64::tk-8.6.12-he1e0b03_0 None
  toml               conda-forge/noarch::toml-0.10.2-pyhd8ed1ab_0 None
  tomli              conda-forge/noarch::tomli-2.0.1-pyhd8ed1ab_0 None
  toolz              conda-forge/noarch::toolz-0.12.0-pyhd8ed1ab_0 None
  tornado            conda-forge/osx-arm64::tornado-6.1-py310hf8d0d8f_3 None
  tqdm               conda-forge/noarch::tqdm-4.64.1-pyhd8ed1ab_0 None
  traitlets          conda-forge/noarch::traitlets-5.5.0-pyhd8ed1ab_0 None
  typer              conda-forge/noarch::typer-0.7.0-pyhd8ed1ab_0 None
  typing-extensions  conda-forge/noarch::typing-extensions-4.4.0-hd8ed1ab_0 None
  typing_extensions  conda-forge/noarch::typing_extensions-4.4.0-pyha770c72_0 None
  tzdata             conda-forge/noarch::tzdata-2022f-h191b570_0 None
  urllib3            conda-forge/noarch::urllib3-1.26.11-pyhd8ed1ab_0 None
  vispy              conda-forge/osx-arm64::vispy-0.11.0-py310hb614ae6_1 None
  wcwidth            conda-forge/noarch::wcwidth-0.2.5-pyh9f0ad1d_2 None
  wrapt              conda-forge/osx-arm64::wrapt-1.14.1-py310h8e9501a_1 None
  xorg-libxau        conda-forge/osx-arm64::xorg-libxau-1.0.9-h27ca646_0 None
  xorg-libxdmcp      conda-forge/osx-arm64::xorg-libxdmcp-1.1.3-h27ca646_0 None
  xz                 conda-forge/osx-arm64::xz-5.2.6-h57fd34a_0 None
  yaml               conda-forge/osx-arm64::yaml-0.2.5-h3422bc3_2 None
  zarr               conda-forge/noarch::zarr-2.13.3-pyhd8ed1ab_0 None
  zeromq             conda-forge/osx-arm64::zeromq-4.3.4-hbdafb3b_1 None
  zfp                conda-forge/osx-arm64::zfp-1.0.0-hb6e4faa_3 None
  zict               conda-forge/noarch::zict-2.2.0-pyhd8ed1ab_0 None
  zipp               conda-forge/noarch::zipp-3.10.0-pyhd8ed1ab_0 None
  zlib-ng            conda-forge/osx-arm64::zlib-ng-2.0.6-he4db4b2_0 None
  zstd               conda-forge/osx-arm64::zstd-1.5.2-h8128057_4 None
```

</details>


Discussion:
next argument to split the plugin dialog to a separate package and make core of napari lighter  
I would love to start doing that if core agrees :)
@jaimergp yes, we are in the process of producing a more thorough roadmap and vision for napari, but ⚠️ spoiler alert ⚠️ we definitely want to pull out the plugin installer into its own package! Let me know if you want to chat about what you might have in mind.
Hey @jni!

My plan would be:

1. Create `napari/napari-plugin-manager`.
2. Copy code over, including tests, packaging and other project scaffolding. At this point, it's just a regular package that will act as an external dependency, not a napari plugin.
3. Open PR in `napari/napari` to replace the built-in plugin manager with the external one. 
4. Migrate issues and lingering PRs to `napari/napari-plugin-manager`.
5. Discuss whether the plugin manager is made optional (and maybe a plugin?). Open PRs as needed.
6. Maintain it :D 

Just imagining how nice merging the PR in step 3 must feel makes me drool.
💯 to all that @jaimergp, with explicit encouragement of (5), which I kinda want to build into (3)... I think it should be totally optional in napari but included in the bundle. The more I think about it, the more absurd the installer seems — imagine if matplotlib shipped with its own little package manager where you could install seaborn, cartopy, or [any of these 175 other projects](https://pypi.org/search/?c=Framework+%3A%3A+Matplotlib)!

So I am drooling right here with you @jaimergp 😂 
@jaimergp have at it! https://github.com/napari/napari-plugin-manager

Resolving patch:
diff --git a/napari/_qt/dialogs/qt_package_installer.py b/napari/_qt/dialogs/qt_package_installer.py
deleted file mode 100644
--- a/napari/_qt/dialogs/qt_package_installer.py
+++ /dev/null
@@ -1,556 +0,0 @@
-"""
-A tool-agnostic installation logic for the plugin manager.
-
-The main object is `InstallerQueue`, a `QProcess` subclass
-with the notion of a job queue. The queued jobs are represented
-by a `deque` of `*InstallerTool` dataclasses that contain the
-executable path, arguments and environment modifications.
-Available actions for each tool are `install`, `uninstall`
-and `cancel`.
-"""
-import atexit
-import contextlib
-import os
-import sys
-from collections import deque
-from dataclasses import dataclass
-from enum import auto
-from functools import lru_cache
-from logging import getLogger
-from pathlib import Path
-from subprocess import call
-from tempfile import gettempdir, mkstemp
-from typing import Deque, Optional, Sequence, Tuple
-
-from npe2 import PluginManager
-from qtpy.QtCore import QObject, QProcess, QProcessEnvironment, Signal
-from qtpy.QtWidgets import QTextEdit
-
-from napari._version import (
-    version as _napari_version,
-    version_tuple as _napari_version_tuple,
-)
-from napari.plugins import plugin_manager
-from napari.plugins.npe2api import _user_agent
-from napari.utils.misc import StringEnum
-from napari.utils.translations import trans
-
-JobId = int
-log = getLogger(__name__)
-
-
-class InstallerActions(StringEnum):
-    "Available actions for the plugin manager"
-    INSTALL = auto()
-    UNINSTALL = auto()
-    CANCEL = auto()
-    UPGRADE = auto()
-
-
-class InstallerTools(StringEnum):
-    "Available tools for InstallerQueue jobs"
-    CONDA = auto()
-    PIP = auto()
-
-
-@dataclass(frozen=True)
-class AbstractInstallerTool:
-    action: InstallerActions
-    pkgs: Tuple[str, ...]
-    origins: Tuple[str, ...] = ()
-    prefix: Optional[str] = None
-
-    @property
-    def ident(self):
-        return hash((self.action, *self.pkgs, *self.origins, self.prefix))
-
-    # abstract method
-    @classmethod
-    def executable(cls):
-        "Path to the executable that will run the task"
-        raise NotImplementedError
-
-    # abstract method
-    def arguments(self):
-        "Arguments supplied to the executable"
-        raise NotImplementedError
-
-    # abstract method
-    def environment(
-        self, env: QProcessEnvironment = None
-    ) -> QProcessEnvironment:
-        "Changes needed in the environment variables."
-        raise NotImplementedError
-
-    @staticmethod
-    def constraints() -> Sequence[str]:
-        """
-        Version constraints to limit unwanted changes in installation.
-        """
-        return [f"napari=={_napari_version}", "pydantic<2"]
-
-    @classmethod
-    def available(cls) -> bool:
-        """
-        Check if the tool is available by performing a little test
-        """
-        raise NotImplementedError
-
-
-class PipInstallerTool(AbstractInstallerTool):
-    @classmethod
-    def executable(cls):
-        return str(_get_python_exe())
-
-    @classmethod
-    def available(cls):
-        return call([cls.executable(), "-m", "pip", "--version"]) == 0
-
-    def arguments(self) -> Tuple[str, ...]:
-        args = ['-m', 'pip']
-        if self.action == InstallerActions.INSTALL:
-            args += ['install', '-c', self._constraints_file()]
-            for origin in self.origins:
-                args += ['--extra-index-url', origin]
-        elif self.action == InstallerActions.UPGRADE:
-            args += [
-                'install',
-                '--upgrade',
-                '-c',
-                self._constraints_file(),
-            ]
-            for origin in self.origins:
-                args += ['--extra-index-url', origin]
-        elif self.action == InstallerActions.UNINSTALL:
-            args += ['uninstall', '-y']
-        else:
-            raise ValueError(f"Action '{self.action}' not supported!")
-        if 10 <= log.getEffectiveLevel() < 30:  # DEBUG level
-            args.append('-vvv')
-        if self.prefix is not None:
-            args.extend(['--prefix', str(self.prefix)])
-        return (*args, *self.pkgs)
-
-    def environment(
-        self, env: QProcessEnvironment = None
-    ) -> QProcessEnvironment:
-        if env is None:
-            env = QProcessEnvironment.systemEnvironment()
-        env.insert("PIP_USER_AGENT_USER_DATA", _user_agent())
-        return env
-
-    @classmethod
-    @lru_cache(maxsize=0)
-    def _constraints_file(cls) -> str:
-        _, path = mkstemp("-napari-constraints.txt", text=True)
-        with open(path, "w") as f:
-            f.write("\n".join(cls.constraints()))
-        atexit.register(os.unlink, path)
-        return path
-
-
-class CondaInstallerTool(AbstractInstallerTool):
-    @classmethod
-    def executable(cls):
-        bat = ".bat" if os.name == "nt" else ""
-        for path in (
-            Path(os.environ.get('MAMBA_EXE', '')),
-            Path(os.environ.get('CONDA_EXE', '')),
-            # $CONDA is usually only available on GitHub Actions
-            Path(os.environ.get('CONDA', '')) / 'condabin' / f'conda{bat}',
-        ):
-            if path.is_file():
-                return str(path)
-        return f'conda{bat}'  # cross our fingers 'conda' is in PATH
-
-    @classmethod
-    def available(cls):
-        executable = cls.executable()
-        try:
-            return call([executable, "--version"]) == 0
-        except FileNotFoundError:  # pragma: no cover
-            return False
-
-    def arguments(self) -> Tuple[str, ...]:
-        prefix = self.prefix or self._default_prefix()
-        if self.action == InstallerActions.UPGRADE:
-            args = ['update', '-y', '--prefix', prefix]
-        else:
-            args = [self.action.value, '-y', '--prefix', prefix]
-        args.append('--override-channels')
-        for channel in (*self.origins, *self._default_channels()):
-            args.extend(["-c", channel])
-        return (*args, *self.pkgs)
-
-    def environment(
-        self, env: QProcessEnvironment = None
-    ) -> QProcessEnvironment:
-        if env is None:
-            env = QProcessEnvironment.systemEnvironment()
-        self._add_constraints_to_env(env)
-        if 10 <= log.getEffectiveLevel() < 30:  # DEBUG level
-            env.insert('CONDA_VERBOSITY', '3')
-        if os.name == "nt":
-            if not env.contains("TEMP"):
-                temp = gettempdir()
-                env.insert("TMP", temp)
-                env.insert("TEMP", temp)
-            if not env.contains("USERPROFILE"):
-                env.insert("HOME", os.path.expanduser("~"))
-                env.insert("USERPROFILE", os.path.expanduser("~"))
-        if sys.platform == 'darwin' and env.contains('PYTHONEXECUTABLE'):
-            # Fix for macOS when napari launched from terminal
-            # related to https://github.com/napari/napari/pull/5531
-            env.remove("PYTHONEXECUTABLE")
-        return env
-
-    @staticmethod
-    def constraints() -> Sequence[str]:
-        # FIXME
-        # dev or rc versions might not be available in public channels
-        # but only installed locally - if we try to pin those, mamba
-        # will fail to pin it because there's no record of that version
-        # in the remote index, only locally; to work around this bug
-        # we will have to pin to e.g. 0.4.* instead of 0.4.17.* for now
-        version_lower = _napari_version.lower()
-        is_dev = "rc" in version_lower or "dev" in version_lower
-        pin_level = 2 if is_dev else 3
-        version = ".".join([str(x) for x in _napari_version_tuple[:pin_level]])
-
-        return [f"napari={version}", "pydantic<2.0a0"]
-
-    def _add_constraints_to_env(
-        self, env: QProcessEnvironment
-    ) -> QProcessEnvironment:
-        PINNED = 'CONDA_PINNED_PACKAGES'
-        constraints = self.constraints()
-        if env.contains(PINNED):
-            constraints.append(env.value(PINNED))
-        env.insert(PINNED, "&".join(constraints))
-        return env
-
-    def _default_channels(self):
-        return ('conda-forge',)
-
-    def _default_prefix(self):
-        if (Path(sys.prefix) / "conda-meta").is_dir():
-            return sys.prefix
-        raise ValueError("Prefix has not been specified!")
-
-
-class InstallerQueue(QProcess):
-    """Queue for installation and uninstallation tasks in the plugin manager."""
-
-    # emitted when all jobs are finished
-    # not to be confused with finished, which is emitted when each job is finished
-    allFinished = Signal()
-
-    def __init__(self, parent: Optional[QObject] = None) -> None:
-        super().__init__(parent)
-        self._queue: Deque[AbstractInstallerTool] = deque()
-        self._output_widget = None
-
-        self.setProcessChannelMode(QProcess.MergedChannels)
-        self.readyReadStandardOutput.connect(self._on_stdout_ready)
-        self.readyReadStandardError.connect(self._on_stderr_ready)
-
-        self.finished.connect(self._on_process_finished)
-        self.errorOccurred.connect(self._on_error_occurred)
-
-    # -------------------------- Public API ------------------------------
-    def install(
-        self,
-        tool: InstallerTools,
-        pkgs: Sequence[str],
-        *,
-        prefix: Optional[str] = None,
-        origins: Sequence[str] = (),
-        **kwargs,
-    ) -> JobId:
-        """Install packages in `pkgs` into `prefix` using `tool` with additional
-        `origins` as source for `pkgs`.
-
-        Parameters
-        ----------
-        tool : InstallerTools
-            Which type of installation tool to use.
-        pkgs : Sequence[str]
-            List of packages to install.
-        prefix : Optional[str], optional
-            Optional prefix to install packages into.
-        origins : Optional[Sequence[str]], optional
-            Additional sources for packages to be downloaded from.
-
-        Returns
-        -------
-        JobId : int
-            ID that can be used to cancel the process.
-        """
-        item = self._build_queue_item(
-            tool=tool,
-            action=InstallerActions.INSTALL,
-            pkgs=pkgs,
-            prefix=prefix,
-            origins=origins,
-            **kwargs,
-        )
-        return self._queue_item(item)
-
-    def upgrade(
-        self,
-        tool: InstallerTools,
-        pkgs: Sequence[str],
-        *,
-        prefix: Optional[str] = None,
-        origins: Sequence[str] = (),
-        **kwargs,
-    ) -> JobId:
-        """Upgrade packages in `pkgs` into `prefix` using `tool` with additional
-        `origins` as source for `pkgs`.
-
-        Parameters
-        ----------
-        tool : InstallerTools
-            Which type of installation tool to use.
-        pkgs : Sequence[str]
-            List of packages to install.
-        prefix : Optional[str], optional
-            Optional prefix to install packages into.
-        origins : Optional[Sequence[str]], optional
-            Additional sources for packages to be downloaded from.
-
-        Returns
-        -------
-        JobId : int
-            ID that can be used to cancel the process.
-        """
-        item = self._build_queue_item(
-            tool=tool,
-            action=InstallerActions.UPGRADE,
-            pkgs=pkgs,
-            prefix=prefix,
-            origins=origins,
-            **kwargs,
-        )
-        return self._queue_item(item)
-
-    def uninstall(
-        self,
-        tool: InstallerTools,
-        pkgs: Sequence[str],
-        *,
-        prefix: Optional[str] = None,
-        **kwargs,
-    ) -> JobId:
-        """Uninstall packages in `pkgs` from `prefix` using `tool`.
-
-        Parameters
-        ----------
-        tool : InstallerTools
-            Which type of installation tool to use.
-        pkgs : Sequence[str]
-            List of packages to uninstall.
-        prefix : Optional[str], optional
-            Optional prefix from which to uninstall packages.
-
-        Returns
-        -------
-        JobId : int
-            ID that can be used to cancel the process.
-        """
-        item = self._build_queue_item(
-            tool=tool,
-            action=InstallerActions.UNINSTALL,
-            pkgs=pkgs,
-            prefix=prefix,
-            **kwargs,
-        )
-        return self._queue_item(item)
-
-    def cancel(self, job_id: Optional[JobId] = None):
-        """Cancel `job_id` if it is running.
-
-        Parameters
-        ----------
-        job_id : Optional[JobId], optional
-            Job ID to cancel.  If not provided, cancel all jobs.
-        """
-        if job_id is None:
-            # cancel all jobs
-            self._queue.clear()
-            self._end_process()
-            return
-
-        for i, item in enumerate(self._queue):
-            if item.ident == job_id:
-                if i == 0:  # first in queue, currently running
-                    self._end_process()
-                else:  # still pending, just remove from queue
-                    self._queue.remove(item)
-                return
-        msg = f"No job with id {job_id}. Current queue:\n - "
-        msg += "\n - ".join(
-            [
-                f"{item.ident} -> {item.executable()} {item.arguments()}"
-                for item in self._queue
-            ]
-        )
-        raise ValueError(msg)
-
-    def waitForFinished(self, msecs: int = 10000) -> bool:
-        """Block and wait for all jobs to finish.
-
-        Parameters
-        ----------
-        msecs : int, optional
-            Time to wait, by default 10000
-        """
-        while self.hasJobs():
-            super().waitForFinished(msecs)
-        return True
-
-    def hasJobs(self) -> bool:
-        """True if there are jobs remaining in the queue."""
-        return bool(self._queue)
-
-    def set_output_widget(self, output_widget: QTextEdit):
-        if output_widget:
-            self._output_widget = output_widget
-
-    # -------------------------- Private methods ------------------------------
-    def _log(self, msg: str):
-        log.debug(msg)
-        if self._output_widget:
-            self._output_widget.append(msg)
-
-    def _get_tool(self, tool: InstallerTools):
-        if tool == InstallerTools.PIP:
-            return PipInstallerTool
-        if tool == InstallerTools.CONDA:
-            return CondaInstallerTool
-        raise ValueError(f"InstallerTool {tool} not recognized!")
-
-    def _build_queue_item(
-        self,
-        tool: InstallerTools,
-        action: InstallerActions,
-        pkgs: Sequence[str],
-        prefix: Optional[str] = None,
-        origins: Sequence[str] = (),
-        **kwargs,
-    ) -> AbstractInstallerTool:
-        return self._get_tool(tool)(
-            pkgs=pkgs,
-            action=action,
-            origins=origins,
-            prefix=prefix,
-            **kwargs,
-        )
-
-    def _queue_item(self, item: AbstractInstallerTool) -> JobId:
-        self._queue.append(item)
-        self._process_queue()
-        return item.ident
-
-    def _process_queue(self):
-        if not self._queue:
-            self.allFinished.emit()
-            return
-        tool = self._queue[0]
-        self.setProgram(str(tool.executable()))
-        self.setProcessEnvironment(tool.environment())
-        self.setArguments([str(arg) for arg in tool.arguments()])
-        # this might throw a warning because the same process
-        # was already running but it's ok
-        self._log(
-            trans._(
-                "Starting '{program}' with args {args}",
-                program=self.program(),
-                args=self.arguments(),
-            )
-        )
-        self.start()
-
-    def _end_process(self):
-        if os.name == 'nt':
-            # TODO: this might be too agressive and won't allow rollbacks!
-            # investigate whether we can also do .terminate()
-            self.kill()
-        else:
-            self.terminate()
-        if self._output_widget:
-            self._output_widget.append(
-                trans._("\nTask was cancelled by the user.")
-            )
-
-    def _on_process_finished(
-        self, exit_code: int, exit_status: QProcess.ExitStatus
-    ):
-        try:
-            current = self._queue[0]
-        except IndexError:
-            current = None
-        if (
-            current
-            and current.action == InstallerActions.UNINSTALL
-            and exit_status == QProcess.ExitStatus.NormalExit
-            and exit_code == 0
-        ):
-            pm2 = PluginManager.instance()
-            npe1_plugins = set(plugin_manager.iter_available())
-            for pkg in current.pkgs:
-                if pkg in pm2:
-                    pm2.unregister(pkg)
-                elif pkg in npe1_plugins:
-                    plugin_manager.unregister(pkg)
-                else:
-                    log.warning(
-                        'Cannot unregister %s, not a known napari plugin.', pkg
-                    )
-        self._on_process_done(exit_code=exit_code, exit_status=exit_status)
-
-    def _on_error_occurred(self, error: QProcess.ProcessError):
-        self._on_process_done(error=error)
-
-    def _on_process_done(
-        self,
-        exit_code: Optional[int] = None,
-        exit_status: Optional[QProcess.ExitStatus] = None,
-        error: Optional[QProcess.ProcessError] = None,
-    ):
-        with contextlib.suppress(IndexError):
-            self._queue.popleft()
-        if error:
-            msg = trans._(
-                "Task finished with errors! Error: {error}.", error=error
-            )
-        else:
-            msg = trans._(
-                "Task finished with exit code {exit_code} with status {exit_status}.",
-                exit_code=exit_code,
-                exit_status=exit_status,
-            )
-        self._log(msg)
-        self._process_queue()
-
-    def _on_stdout_ready(self):
-        text = self.readAllStandardOutput().data().decode()
-        if text:
-            self._log(text)
-
-    def _on_stderr_ready(self):
-        text = self.readAllStandardError().data().decode()
-        if text:
-            self._log(text)
-
-
-def _get_python_exe():
-    # Workaround: see if sys.executable is set to something something napari on Mac
-    if (
-        sys.executable.endswith("napari")
-        and sys.platform == 'darwin'
-        and (python := Path(sys.prefix) / "bin" / "python3").is_file()
-    ):
-        # sys.prefix should be <napari.app>/Contents/Resources/Support/Python/Resources
-        return str(python)
-    return sys.executable
diff --git a/napari/_qt/dialogs/qt_plugin_dialog.py b/napari/_qt/dialogs/qt_plugin_dialog.py
deleted file mode 100644
--- a/napari/_qt/dialogs/qt_plugin_dialog.py
+++ /dev/null
@@ -1,1086 +0,0 @@
-import importlib.metadata
-import os
-import re
-import sys
-from enum import Enum, auto
-from functools import partial
-from pathlib import Path
-from typing import Dict, List, Literal, Optional, Sequence, Tuple
-
-import npe2
-from qtpy.QtCore import QEvent, QPoint, QSize, Qt, QTimer, Slot
-from qtpy.QtGui import QFont, QMovie
-from qtpy.QtWidgets import (
-    QCheckBox,
-    QComboBox,
-    QDialog,
-    QFrame,
-    QGridLayout,
-    QHBoxLayout,
-    QLabel,
-    QLineEdit,
-    QListWidget,
-    QListWidgetItem,
-    QPushButton,
-    QSizePolicy,
-    QSplitter,
-    QTextEdit,
-    QVBoxLayout,
-    QWidget,
-)
-from superqt import QCollapsible, QElidingLabel
-
-import napari.plugins
-import napari.resources
-from napari._qt.dialogs.qt_package_installer import (
-    InstallerActions,
-    InstallerQueue,
-    InstallerTools,
-)
-from napari._qt.qt_resources import QColoredSVGIcon
-from napari._qt.qthreading import create_worker
-from napari._qt.widgets.qt_message_popup import WarnPopup
-from napari._qt.widgets.qt_tooltip import QtToolTipLabel
-from napari.plugins.npe2api import iter_napari_plugin_info
-from napari.plugins.utils import normalized_name
-from napari.settings import get_settings
-from napari.utils.misc import (
-    parse_version,
-    running_as_constructor_app,
-)
-from napari.utils.translations import trans
-
-# TODO: add error icon and handle pip install errors
-
-# Scaling factor for each list widget item when expanding.
-SCALE = 1.6
-
-CONDA = 'Conda'
-PYPI = 'PyPI'
-
-
-def is_conda_package(pkg: str):
-    """Determines if plugin was installed through conda.
-
-    Returns
-    -------
-    bool: True if a conda package, False if not
-    """
-
-    # Installed conda packages within a conda installation and environment can be identified as files
-    # with the template `<package-name>-<version>-<build-string>.json` saved within a `conda-meta` folder within
-    # the given environment of interest.
-
-    conda_meta_dir = Path(sys.prefix) / 'conda-meta'
-    return any(
-        re.match(rf"{pkg}-[^-]+-[^-]+.json", p.name)
-        for p in conda_meta_dir.glob(f"{pkg}-*-*.json")
-    )
-
-
-class PluginListItem(QFrame):
-    """An entry in the plugin dialog.  This will include the package name, summary,
-    author, source, version, and buttons to update, install/uninstall, etc."""
-
-    def __init__(
-        self,
-        package_name: str,
-        version: str = '',
-        url: str = '',
-        summary: str = '',
-        author: str = '',
-        license: str = "UNKNOWN",  # noqa: A002
-        *,
-        plugin_name: str = None,
-        parent: QWidget = None,
-        enabled: bool = True,
-        installed: bool = False,
-        npe_version=1,
-        versions_conda: List[str] = None,
-        versions_pypi: List[str] = None,
-    ) -> None:
-        super().__init__(parent)
-        self.url = url
-        self._versions_conda = versions_conda
-        self._versions_pypi = versions_pypi
-        self.setup_ui(enabled)
-        self.plugin_name.setText(package_name)
-
-        if len(versions_pypi) > 0:
-            self._populate_version_dropdown(PYPI)
-        else:
-            self._populate_version_dropdown(CONDA)
-
-        self.package_name.setText(version)
-        if summary:
-            self.summary.setText(summary + '<br />')
-        if author:
-            self.package_author.setText(author)
-        self.package_author.setWordWrap(True)
-        self.cancel_btn.setVisible(False)
-
-        self._handle_npe2_plugin(npe_version)
-
-        if installed:
-            if is_conda_package(package_name):
-                self.source.setText(CONDA)
-            self.enabled_checkbox.show()
-            self.action_button.setText(trans._("Uninstall"))
-            self.action_button.setObjectName("remove_button")
-            self.info_choice_wdg.hide()
-            self.install_info_button.addWidget(self.info_widget)
-            self.info_widget.show()
-        else:
-            self.enabled_checkbox.hide()
-            self.action_button.setText(trans._("Install"))
-            self.action_button.setObjectName("install_button")
-            self.info_widget.hide()
-            self.install_info_button.addWidget(self.info_choice_wdg)
-            self.install_info_button.setFixedWidth(170)
-
-            self.info_choice_wdg.show()
-
-    def _handle_npe2_plugin(self, npe_version):
-        if npe_version in (None, 1):
-            return
-        opacity = 0.4 if npe_version == 'shim' else 1
-        lbl = trans._('npe1 (adapted)') if npe_version == 'shim' else 'npe2'
-        npe2_icon = QLabel(self)
-        icon = QColoredSVGIcon.from_resources('logo_silhouette')
-        npe2_icon.setPixmap(
-            icon.colored(color='#33F0FF', opacity=opacity).pixmap(20, 20)
-        )
-        self.row1.insertWidget(2, QLabel(lbl))
-        self.row1.insertWidget(2, npe2_icon)
-
-    def set_busy(
-        self,
-        text: str,
-        action_name: Literal[
-            "install", "uninstall", "cancel", "upgrade"
-        ] = None,
-    ):
-        """Updates status text and what buttons are visible when any button is pushed.
-
-        Parameters
-        ----------
-        text: str
-            The new string to be displayed as the status.
-        action_name: str
-            The action of the button pressed.
-
-        """
-        self.item_status.setText(text)
-        if action_name == 'upgrade':
-            self.cancel_btn.setVisible(True)
-            self.action_button.setVisible(False)
-        elif action_name in {'uninstall', 'install'}:
-            self.action_button.setVisible(False)
-            self.cancel_btn.setVisible(True)
-        elif action_name == 'cancel':
-            self.action_button.setVisible(True)
-            self.action_button.setDisabled(False)
-            self.cancel_btn.setVisible(False)
-        else:  # pragma: no cover
-            raise ValueError(f"Not supported {action_name}")
-
-    def setup_ui(self, enabled=True):
-        """Define the layout of the PluginListItem"""
-
-        self.v_lay = QVBoxLayout(self)
-        self.v_lay.setContentsMargins(-1, 6, -1, 6)
-        self.v_lay.setSpacing(0)
-        self.row1 = QHBoxLayout()
-        self.row1.setSpacing(6)
-        self.enabled_checkbox = QCheckBox(self)
-        self.enabled_checkbox.setChecked(enabled)
-        self.enabled_checkbox.stateChanged.connect(self._on_enabled_checkbox)
-        self.enabled_checkbox.setToolTip(trans._("enable/disable"))
-        sizePolicy = QSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
-        sizePolicy.setHorizontalStretch(0)
-        sizePolicy.setVerticalStretch(0)
-        sizePolicy.setHeightForWidth(
-            self.enabled_checkbox.sizePolicy().hasHeightForWidth()
-        )
-        self.enabled_checkbox.setSizePolicy(sizePolicy)
-        self.enabled_checkbox.setMinimumSize(QSize(20, 0))
-        self.enabled_checkbox.setText("")
-        self.row1.addWidget(self.enabled_checkbox)
-        self.plugin_name = QPushButton(self)
-        # Do not want to highlight on hover unless there is a website.
-        if self.url and self.url != 'UNKNOWN':
-            self.plugin_name.setObjectName('plugin_name_web')
-        else:
-            self.plugin_name.setObjectName('plugin_name')
-
-        sizePolicy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Minimum)
-        sizePolicy.setHorizontalStretch(0)
-        sizePolicy.setVerticalStretch(0)
-        sizePolicy.setHeightForWidth(
-            self.plugin_name.sizePolicy().hasHeightForWidth()
-        )
-        self.plugin_name.setSizePolicy(sizePolicy)
-        font15 = QFont()
-        font15.setPointSize(15)
-        font15.setUnderline(True)
-        self.plugin_name.setFont(font15)
-        self.row1.addWidget(self.plugin_name)
-
-        icon = QColoredSVGIcon.from_resources("warning")
-        self.warning_tooltip = QtToolTipLabel(self)
-        # TODO: This color should come from the theme but the theme needs
-        # to provide the right color. Default warning should be orange, not
-        # red. Code example:
-        # theme_name = get_settings().appearance.theme
-        # napari.utils.theme.get_theme(theme_name, as_dict=False).warning.as_hex()
-        self.warning_tooltip.setPixmap(
-            icon.colored(color="#E3B617").pixmap(15, 15)
-        )
-        self.warning_tooltip.setVisible(False)
-        self.row1.addWidget(self.warning_tooltip)
-
-        self.item_status = QLabel(self)
-        self.item_status.setObjectName("small_italic_text")
-        self.item_status.setSizePolicy(sizePolicy)
-        self.row1.addWidget(self.item_status)
-        self.row1.addStretch()
-        self.v_lay.addLayout(self.row1)
-
-        self.row2 = QGridLayout()
-        self.error_indicator = QPushButton()
-        self.error_indicator.setObjectName("warning_icon")
-        self.error_indicator.setCursor(Qt.CursorShape.PointingHandCursor)
-        self.error_indicator.hide()
-        self.row2.addWidget(
-            self.error_indicator,
-            0,
-            0,
-            1,
-            1,
-            alignment=Qt.AlignmentFlag.AlignTop,
-        )
-        self.row2.setSpacing(4)
-        self.summary = QElidingLabel(parent=self)
-        self.summary.setObjectName('summary_text')
-        self.summary.setWordWrap(True)
-
-        sizePolicy = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
-
-        sizePolicy.setHorizontalStretch(1)
-        sizePolicy.setVerticalStretch(0)
-        self.summary.setSizePolicy(sizePolicy)
-        self.row2.addWidget(
-            self.summary, 0, 1, 1, 3, alignment=Qt.AlignmentFlag.AlignTop
-        )
-
-        self.package_author = QElidingLabel(self)
-        self.package_author.setObjectName('author_text')
-        self.package_author.setWordWrap(True)
-        self.package_author.setSizePolicy(sizePolicy)
-        self.row2.addWidget(
-            self.package_author,
-            0,
-            4,
-            1,
-            2,
-            alignment=Qt.AlignmentFlag.AlignTop,
-        )
-
-        self.update_btn = QPushButton('Update', self)
-        sizePolicy.setRetainSizeWhenHidden(True)
-        self.update_btn.setSizePolicy(sizePolicy)
-        self.update_btn.setObjectName("install_button")
-        self.update_btn.setVisible(False)
-
-        self.row2.addWidget(
-            self.update_btn, 0, 6, 1, 1, alignment=Qt.AlignmentFlag.AlignTop
-        )
-
-        self.info_choice_wdg = QWidget(self)
-        self.info_choice_wdg.setObjectName('install_choice')
-        coll_icon = QColoredSVGIcon.from_resources('right_arrow').colored(
-            color='white',
-        )
-        exp_icon = QColoredSVGIcon.from_resources('down_arrow').colored(
-            color='white',
-        )
-        self.install_info_button = QCollapsible(
-            "Installation Info", collapsedIcon=coll_icon, expandedIcon=exp_icon
-        )
-        self.install_info_button.setObjectName("install_info_button")
-
-        # To make the icon appear on the right
-        self.install_info_button.setLayoutDirection(Qt.RightToLeft)
-
-        # Remove any extra margins
-        self.install_info_button.content().layout().setContentsMargins(
-            0, 0, 0, 0
-        )
-        self.install_info_button.content().setContentsMargins(0, 0, 0, 0)
-        self.install_info_button.content().layout().setSpacing(0)
-        self.install_info_button.layout().setContentsMargins(0, 0, 0, 0)
-        self.install_info_button.layout().setSpacing(2)
-        self.install_info_button.setSizePolicy(sizePolicy)
-
-        self.source_choice_text = QLabel('Source:')
-        self.version_choice_text = QLabel('Version:')
-        self.source_choice_dropdown = QComboBox()
-
-        if len(self._versions_pypi) is not None:
-            self.source_choice_dropdown.addItem(PYPI)
-
-        if len(self._versions_conda) is not None:
-            self.source_choice_dropdown.addItem(CONDA)
-
-        self.source_choice_dropdown.currentTextChanged.connect(
-            self._populate_version_dropdown
-        )
-        self.version_choice_dropdown = QComboBox()
-        self.row2.addWidget(
-            self.install_info_button,
-            0,
-            7,
-            1,
-            1,
-            alignment=Qt.AlignmentFlag.AlignTop,
-        )
-
-        info_layout = QGridLayout()
-        info_layout.setContentsMargins(0, 0, 0, 0)
-        info_layout.setVerticalSpacing(0)
-        info_layout.addWidget(self.source_choice_text, 0, 0, 1, 1)
-        info_layout.addWidget(self.source_choice_dropdown, 1, 0, 1, 1)
-        info_layout.addWidget(self.version_choice_text, 0, 1, 1, 1)
-        info_layout.addWidget(self.version_choice_dropdown, 1, 1, 1, 1)
-        self.info_choice_wdg.setLayout(info_layout)
-        self.info_choice_wdg.setLayoutDirection(Qt.LeftToRight)
-        self.info_choice_wdg.setObjectName("install_choice_widget")
-        self.info_choice_wdg.hide()
-
-        self.cancel_btn = QPushButton("Cancel", self)
-        self.cancel_btn.setSizePolicy(sizePolicy)
-        self.cancel_btn.setObjectName("remove_button")
-        self.row2.addWidget(
-            self.cancel_btn, 0, 8, 1, 1, alignment=Qt.AlignmentFlag.AlignTop
-        )
-
-        self.action_button = QPushButton(self)
-        self.action_button.setFixedWidth(70)
-        sizePolicy1 = QSizePolicy(QSizePolicy.Preferred, QSizePolicy.Preferred)
-        self.action_button.setSizePolicy(sizePolicy1)
-        self.row2.addWidget(
-            self.action_button, 0, 8, 1, 1, alignment=Qt.AlignmentFlag.AlignTop
-        )
-
-        self.v_lay.addLayout(self.row2)
-
-        self.info_widget = QWidget(self)
-        self.info_widget.setLayoutDirection(Qt.LeftToRight)
-        self.info_widget.setObjectName("info_widget")
-        info_layout = QGridLayout()
-        info_layout.setContentsMargins(0, 0, 0, 0)
-        info_layout.setVerticalSpacing(0)
-        self.version_text = QLabel('Version:')
-        self.package_name = QLabel()
-        self.source_text = QLabel('Source:')
-        self.source = QLabel(PYPI)
-
-        info_layout.addWidget(self.source_text, 0, 0)
-        info_layout.addWidget(self.source, 1, 0)
-        info_layout.addWidget(self.version_text, 0, 1)
-        info_layout.addWidget(self.package_name, 1, 1)
-
-        self.install_info_button.setFixedWidth(150)
-        self.install_info_button.layout().setContentsMargins(0, 0, 0, 0)
-        self.info_widget.setLayout(info_layout)
-
-    def _populate_version_dropdown(self, source: Literal["PyPI", "Conda"]):
-        """Display the versions available after selecting a source: pypi or conda."""
-        if source == PYPI:
-            versions = self._versions_pypi
-        else:
-            versions = self._versions_conda
-        self.version_choice_dropdown.clear()
-        for version in versions:
-            self.version_choice_dropdown.addItem(version)
-
-    def _on_enabled_checkbox(self, state: int):
-        """Called with `state` when checkbox is clicked."""
-        enabled = bool(state)
-        plugin_name = self.plugin_name.text()
-        pm2 = npe2.PluginManager.instance()
-        if plugin_name in pm2:
-            pm2.enable(plugin_name) if state else pm2.disable(plugin_name)
-            return
-
-        for (
-            npe1_name,
-            _,
-            distname,
-        ) in napari.plugins.plugin_manager.iter_available():
-            if distname and (normalized_name(distname) == plugin_name):
-                napari.plugins.plugin_manager.set_blocked(
-                    npe1_name, not enabled
-                )
-                return
-
-    def show_warning(self, message: str = ""):
-        """Show warning icon and tooltip."""
-        self.warning_tooltip.setVisible(bool(message))
-        self.warning_tooltip.setToolTip(message)
-
-
-class QPluginList(QListWidget):
-    def __init__(self, parent: QWidget, installer: InstallerQueue) -> None:
-        super().__init__(parent)
-        self.installer = installer
-        self.setSortingEnabled(True)
-        self._remove_list = []
-
-    def _count_visible(self) -> int:
-        """Return the number of visible items.
-
-        Visible items are the result of the normal `count` method minus
-        any hidden items.
-        """
-        hidden = 0
-        count = self.count()
-        for i in range(count):
-            item = self.item(i)
-            hidden += item.isHidden()
-
-        return count - hidden
-
-    @Slot(tuple)
-    def addItem(
-        self,
-        project_info_versions: Tuple[
-            npe2.PackageMetadata, List[str], List[str]
-        ],
-        installed=False,
-        plugin_name=None,
-        enabled=True,
-        npe_version=None,
-    ):
-        project_info, versions_pypi, versions_conda = project_info_versions
-
-        pkg_name = project_info.name
-        # don't add duplicates
-        if (
-            self.findItems(pkg_name, Qt.MatchFlag.MatchFixedString)
-            and not plugin_name
-        ):
-            return
-
-        # including summary here for sake of filtering below.
-        searchable_text = f"{pkg_name} {project_info.summary}"
-        item = QListWidgetItem(searchable_text, self)
-        item.version = project_info.version
-        super().addItem(item)
-        widg = PluginListItem(
-            package_name=pkg_name,
-            version=project_info.version,
-            url=project_info.home_page,
-            summary=project_info.summary,
-            author=project_info.author,
-            license=project_info.license,
-            parent=self,
-            plugin_name=plugin_name,
-            enabled=enabled,
-            installed=installed,
-            npe_version=npe_version,
-            versions_conda=versions_conda,
-            versions_pypi=versions_pypi,
-        )
-        item.widget = widg
-        item.npe_version = npe_version
-        action_name = 'uninstall' if installed else 'install'
-        item.setSizeHint(widg.sizeHint())
-        self.setItemWidget(item, widg)
-
-        if project_info.home_page:
-            import webbrowser
-
-            # FIXME: Partial may lead to leak memory when connecting to Qt signals.
-            widg.plugin_name.clicked.connect(
-                partial(webbrowser.open, project_info.home_page)
-            )
-
-        # FIXME: Partial may lead to leak memory when connecting to Qt signals.
-        widg.action_button.clicked.connect(
-            partial(
-                self.handle_action,
-                item,
-                pkg_name,
-                action_name,
-                version=widg.version_choice_dropdown.currentText(),
-                installer_choice=widg.source_choice_dropdown.currentText(),
-            )
-        )
-
-        widg.update_btn.clicked.connect(
-            partial(
-                self.handle_action,
-                item,
-                pkg_name,
-                InstallerActions.UPGRADE,
-            )
-        )
-        widg.cancel_btn.clicked.connect(
-            partial(
-                self.handle_action, item, pkg_name, InstallerActions.CANCEL
-            )
-        )
-
-        item.setSizeHint(item.widget.size())
-        widg.install_info_button.setDuration(0)
-        widg.install_info_button.toggled.connect(
-            lambda: self._resize_pluginlistitem(item)
-        )
-
-    def _resize_pluginlistitem(self, item):
-        """Resize the plugin list item, especially after toggling QCollapsible."""
-        height = item.widget.height()
-        if item.widget.install_info_button.isExpanded():
-            item.widget.setFixedHeight(int(height * SCALE))
-        else:
-            item.widget.setFixedHeight(int(height / SCALE))
-        item.setSizeHint(item.widget.size())
-
-    def handle_action(
-        self,
-        item: QListWidgetItem,
-        pkg_name: str,
-        action_name: InstallerActions,
-        version: str = None,
-        installer_choice: Optional[str] = None,
-    ):
-        """Determine which action is called (install, uninstall, update, cancel).
-        Update buttons appropriately and run the action."""
-        tool = (
-            InstallerTools.CONDA
-            if item.widget.source_choice_dropdown.currentText() == CONDA
-            or is_conda_package(pkg_name)
-            else InstallerTools.PIP
-        )
-
-        widget = item.widget
-        item.setText(f"0-{item.text()}")
-        self._remove_list.append((pkg_name, item))
-        self._warn_dialog = None
-        # TODO: NPE version unknown before installing
-        if item.npe_version != 1 and action_name == InstallerActions.UNINSTALL:
-            # show warning pop up dialog
-            message = trans._(
-                'When installing/uninstalling npe2 plugins, you must '
-                'restart napari for UI changes to take effect.'
-            )
-            self._warn_dialog = WarnPopup(text=message)
-
-            delta_x = 75
-            global_point = widget.action_button.mapToGlobal(
-                widget.action_button.rect().topLeft()
-            )
-            global_point = QPoint(global_point.x() - delta_x, global_point.y())
-            self._warn_dialog.move(global_point)
-
-        if action_name == InstallerActions.INSTALL:
-            if version:
-                pkg_name += (
-                    f"=={item.widget.version_choice_dropdown.currentText()}"
-                )
-            widget.set_busy(trans._("installing..."), action_name)
-
-            job_id = self.installer.install(
-                tool=tool,
-                pkgs=[pkg_name],
-                # origins="TODO",
-            )
-            if self._warn_dialog:
-                self._warn_dialog.exec_()
-            self.scrollToTop()
-
-        if action_name == InstallerActions.UPGRADE:
-            if hasattr(item, 'latest_version'):
-                pkg_name += f"=={item.latest_version}"
-
-            widget.set_busy(trans._("updating..."), action_name)
-            widget.action_button.setDisabled(True)
-
-            job_id = self.installer.upgrade(
-                tool=tool,
-                pkgs=[pkg_name],
-                # origins="TODO",
-            )
-            if self._warn_dialog:
-                self._warn_dialog.exec_()
-            self.scrollToTop()
-
-        elif action_name == InstallerActions.UNINSTALL:
-            widget.set_busy(trans._("uninstalling..."), action_name)
-            widget.update_btn.setDisabled(True)
-            job_id = self.installer.uninstall(
-                tool=tool,
-                pkgs=[pkg_name],
-                # origins="TODO",
-                # upgrade=False,
-            )
-            widget.setProperty("current_job_id", job_id)
-            if self._warn_dialog:
-                self._warn_dialog.exec_()
-            self.scrollToTop()
-        elif action_name == InstallerActions.CANCEL:
-            widget.set_busy(trans._("cancelling..."), action_name)
-            try:
-                job_id = widget.property("current_job_id")
-                self.installer.cancel(job_id)
-            finally:
-                widget.setProperty("current_job_id", None)
-
-    @Slot(npe2.PackageMetadata, bool)
-    def tag_outdated(
-        self, project_info: npe2.PackageMetadata, is_available: bool
-    ):
-        """Determines if an installed plugin is up to date with the latest version.
-        If it is not, the latest version will be displayed on the update button.
-        """
-        if not is_available:
-            return
-
-        for item in self.findItems(
-            project_info.name, Qt.MatchFlag.MatchStartsWith
-        ):
-            current = item.version
-            latest = project_info.version
-            if parse_version(current) >= parse_version(latest):
-                continue
-            if hasattr(item, 'outdated'):
-                # already tagged it
-                continue
-
-            item.outdated = True
-            item.latest_version = latest
-            widg = self.itemWidget(item)
-            widg.update_btn.setVisible(True)
-            widg.update_btn.setText(
-                trans._("update (v{latest})", latest=latest)
-            )
-
-    def tag_unavailable(self, project_info: npe2.PackageMetadata):
-        """
-        Tag list items as unavailable for install with conda-forge.
-
-        This will disable the item and the install button and add a warning
-        icon with a hover tooltip.
-        """
-        for item in self.findItems(
-            project_info.name, Qt.MatchFlag.MatchStartsWith
-        ):
-            widget = self.itemWidget(item)
-            widget.show_warning(
-                trans._(
-                    "Plugin not yet available for installation within the bundle application"
-                )
-            )
-            widget.setObjectName("unavailable")
-            widget.style().unpolish(widget)
-            widget.style().polish(widget)
-            widget.action_button.setEnabled(False)
-            widget.warning_tooltip.setVisible(True)
-
-    def filter(self, text: str):
-        """Filter items to those containing `text`."""
-        if text:
-            # PySide has some issues, so we compare using id
-            # See: https://bugreports.qt.io/browse/PYSIDE-74
-            shown = [
-                id(it)
-                for it in self.findItems(text, Qt.MatchFlag.MatchContains)
-            ]
-            for i in range(self.count()):
-                item = self.item(i)
-                item.setHidden(id(item) not in shown)
-        else:
-            for i in range(self.count()):
-                item = self.item(i)
-                item.setHidden(False)
-
-
-class RefreshState(Enum):
-    REFRESHING = auto()
-    OUTDATED = auto()
-    DONE = auto()
-
-
-class QtPluginDialog(QDialog):
-    def __init__(self, parent=None) -> None:
-        super().__init__(parent)
-        self.refresh_state = RefreshState.DONE
-        self.already_installed = set()
-        self.available_set = set()
-
-        self._plugin_data = []  # Store plugin data while populating lists
-        self.all_plugin_data = []  # Store all plugin data
-        self._add_items_timer = QTimer(self)
-        # Add items in batches to avoid blocking the UI
-        self._add_items_timer.setInterval(100)
-        self._add_items_timer.timeout.connect(self._add_items)
-        self._add_items_timer.timeout.connect(self._update_count_in_label)
-
-        self.installer = InstallerQueue()
-        self.setWindowTitle(trans._('Plugin Manager'))
-        self.setup_ui()
-        self.setWindowTitle('Plugin Manager')
-        self.installer.set_output_widget(self.stdout_text)
-        self.installer.started.connect(self._on_installer_start)
-        self.installer.finished.connect(self._on_installer_done)
-        self.refresh()
-
-    def _on_installer_start(self):
-        """Updates dialog buttons and status when installing a plugin."""
-        self.cancel_all_btn.setVisible(True)
-        self.working_indicator.show()
-        self.process_success_indicator.hide()
-        self.process_error_indicator.hide()
-        self.close_btn.setDisabled(True)
-
-    def _on_installer_done(self, exit_code):
-        """Updates buttons and status when plugin is done installing."""
-        self.working_indicator.hide()
-        if exit_code:
-            self.process_error_indicator.show()
-        else:
-            self.process_success_indicator.show()
-        self.cancel_all_btn.setVisible(False)
-        self.close_btn.setDisabled(False)
-        self.refresh()
-
-    def closeEvent(self, event):
-        self._add_items_timer.stop()
-        if self.close_btn.isEnabled():
-            super().closeEvent(event)
-        event.ignore()
-
-    def refresh(self):
-        if self.refresh_state != RefreshState.DONE:
-            self.refresh_state = RefreshState.OUTDATED
-            return
-        self.refresh_state = RefreshState.REFRESHING
-        self.installed_list.clear()
-        self.available_list.clear()
-
-        self.already_installed = set()
-        self.available_set = set()
-
-        def _add_to_installed(distname, enabled, npe_version=1):
-            norm_name = normalized_name(distname or '')
-            if distname:
-                try:
-                    meta = importlib.metadata.metadata(distname)
-
-                except importlib.metadata.PackageNotFoundError:
-                    self.refresh_state = RefreshState.OUTDATED
-                    return  # a race condition has occurred and the package is uninstalled by another thread
-                if len(meta) == 0:
-                    # will not add builtins.
-                    return
-                self.already_installed.add(norm_name)
-            else:
-                meta = {}
-
-            self.installed_list.addItem(
-                (
-                    npe2.PackageMetadata(
-                        metadata_version="1.0",
-                        name=norm_name,
-                        version=meta.get('version', ''),
-                        summary=meta.get('summary', ''),
-                        home_page=meta.get('Home-page', ''),
-                        author=meta.get('author', ''),
-                        license=meta.get('license', ''),
-                    ),
-                    [],
-                    [],
-                ),
-                installed=True,
-                enabled=enabled,
-                npe_version=npe_version,
-            )
-
-        pm2 = npe2.PluginManager.instance()
-        discovered = pm2.discover()
-        for manifest in pm2.iter_manifests():
-            distname = normalized_name(manifest.name or '')
-            if distname in self.already_installed or distname == 'napari':
-                continue
-            enabled = not pm2.is_disabled(manifest.name)
-            # if it's an Npe1 adaptor, call it v1
-            npev = 'shim' if manifest.npe1_shim else 2
-            _add_to_installed(distname, enabled, npe_version=npev)
-
-        napari.plugins.plugin_manager.discover()  # since they might not be loaded yet
-        for (
-            plugin_name,
-            _,
-            distname,
-        ) in napari.plugins.plugin_manager.iter_available():
-            # not showing these in the plugin dialog
-            if plugin_name in ('napari_plugin_engine',):
-                continue
-            if normalized_name(distname or '') in self.already_installed:
-                continue
-            _add_to_installed(
-                distname,
-                not napari.plugins.plugin_manager.is_blocked(plugin_name),
-            )
-
-        self.installed_label.setText(
-            trans._(
-                "Installed Plugins ({amount})",
-                amount=len(self.already_installed),
-            )
-        )
-
-        # fetch available plugins
-        get_settings()
-
-        self.worker = create_worker(iter_napari_plugin_info)
-
-        self.worker.yielded.connect(self._handle_yield)
-        self.worker.finished.connect(self.working_indicator.hide)
-        self.worker.finished.connect(self._end_refresh)
-        self.worker.start()
-        self._add_items_timer.start()
-
-        if discovered:
-            message = trans._(
-                'When installing/uninstalling npe2 plugins, '
-                'you must restart napari for UI changes to take effect.'
-            )
-            self._warn_dialog = WarnPopup(text=message)
-            global_point = self.process_error_indicator.mapToGlobal(
-                self.process_error_indicator.rect().topLeft()
-            )
-            global_point = QPoint(global_point.x(), global_point.y() - 75)
-            self._warn_dialog.move(global_point)
-            self._warn_dialog.exec_()
-
-    def setup_ui(self):
-        """Defines the layout for the PluginDialog."""
-
-        self.resize(950, 640)
-        vlay_1 = QVBoxLayout(self)
-        self.h_splitter = QSplitter(self)
-        vlay_1.addWidget(self.h_splitter)
-        self.h_splitter.setOrientation(Qt.Orientation.Horizontal)
-        self.v_splitter = QSplitter(self.h_splitter)
-        self.v_splitter.setOrientation(Qt.Orientation.Vertical)
-        self.v_splitter.setMinimumWidth(500)
-
-        installed = QWidget(self.v_splitter)
-        lay = QVBoxLayout(installed)
-        lay.setContentsMargins(0, 2, 0, 2)
-        self.installed_label = QLabel(trans._("Installed Plugins"))
-        self.packages_filter = QLineEdit()
-        self.packages_filter.setPlaceholderText(trans._("filter..."))
-        self.packages_filter.setMaximumWidth(350)
-        self.packages_filter.setClearButtonEnabled(True)
-        mid_layout = QVBoxLayout()
-        mid_layout.addWidget(self.packages_filter)
-        mid_layout.addWidget(self.installed_label)
-        lay.addLayout(mid_layout)
-
-        self.installed_list = QPluginList(installed, self.installer)
-        self.packages_filter.textChanged.connect(self.installed_list.filter)
-        lay.addWidget(self.installed_list)
-
-        uninstalled = QWidget(self.v_splitter)
-        lay = QVBoxLayout(uninstalled)
-        lay.setContentsMargins(0, 2, 0, 2)
-        self.avail_label = QLabel(trans._("Available Plugins"))
-        mid_layout = QHBoxLayout()
-        mid_layout.addWidget(self.avail_label)
-        mid_layout.addStretch()
-        lay.addLayout(mid_layout)
-        self.available_list = QPluginList(uninstalled, self.installer)
-        self.packages_filter.textChanged.connect(self.available_list.filter)
-        lay.addWidget(self.available_list)
-
-        self.stdout_text = QTextEdit(self.v_splitter)
-        self.stdout_text.setReadOnly(True)
-        self.stdout_text.setObjectName("plugin_manager_process_status")
-        self.stdout_text.hide()
-
-        buttonBox = QHBoxLayout()
-        self.working_indicator = QLabel(trans._("loading ..."), self)
-        sp = self.working_indicator.sizePolicy()
-        sp.setRetainSizeWhenHidden(True)
-        self.working_indicator.setSizePolicy(sp)
-        self.process_error_indicator = QLabel(self)
-        self.process_error_indicator.setObjectName("error_label")
-        self.process_error_indicator.hide()
-        self.process_success_indicator = QLabel(self)
-        self.process_success_indicator.setObjectName("success_label")
-        self.process_success_indicator.hide()
-        load_gif = str(Path(napari.resources.__file__).parent / "loading.gif")
-        mov = QMovie(load_gif)
-        mov.setScaledSize(QSize(18, 18))
-        self.working_indicator.setMovie(mov)
-        mov.start()
-
-        visibility_direct_entry = not running_as_constructor_app()
-        self.direct_entry_edit = QLineEdit(self)
-        self.direct_entry_edit.installEventFilter(self)
-        self.direct_entry_edit.setPlaceholderText(
-            trans._('install by name/url, or drop file...')
-        )
-        self.direct_entry_edit.setVisible(visibility_direct_entry)
-        self.direct_entry_btn = QPushButton(trans._("Install"), self)
-        self.direct_entry_btn.setVisible(visibility_direct_entry)
-        self.direct_entry_btn.clicked.connect(self._install_packages)
-
-        self.show_status_btn = QPushButton(trans._("Show Status"), self)
-        self.show_status_btn.setFixedWidth(100)
-
-        self.cancel_all_btn = QPushButton(trans._("cancel all actions"), self)
-        self.cancel_all_btn.setObjectName("remove_button")
-        self.cancel_all_btn.setVisible(False)
-        self.cancel_all_btn.clicked.connect(self.installer.cancel)
-
-        self.close_btn = QPushButton(trans._("Close"), self)
-        self.close_btn.clicked.connect(self.accept)
-        self.close_btn.setObjectName("close_button")
-        buttonBox.addWidget(self.show_status_btn)
-        buttonBox.addWidget(self.working_indicator)
-        buttonBox.addWidget(self.direct_entry_edit)
-        buttonBox.addWidget(self.direct_entry_btn)
-        if not visibility_direct_entry:
-            buttonBox.addStretch()
-        buttonBox.addWidget(self.process_success_indicator)
-        buttonBox.addWidget(self.process_error_indicator)
-        buttonBox.addSpacing(20)
-        buttonBox.addWidget(self.cancel_all_btn)
-        buttonBox.addSpacing(20)
-        buttonBox.addWidget(self.close_btn)
-        buttonBox.setContentsMargins(0, 0, 4, 0)
-        vlay_1.addLayout(buttonBox)
-
-        self.show_status_btn.setCheckable(True)
-        self.show_status_btn.setChecked(False)
-        self.show_status_btn.toggled.connect(self._toggle_status)
-
-        self.v_splitter.setStretchFactor(1, 2)
-        self.h_splitter.setStretchFactor(0, 2)
-
-        self.packages_filter.setFocus()
-
-    def _update_count_in_label(self):
-        """Counts all available but not installed plugins. Updates value."""
-
-        count = self.available_list.count()
-        self.avail_label.setText(
-            trans._("Available Plugins ({count})", count=count)
-        )
-
-    def _end_refresh(self):
-        refresh_state = self.refresh_state
-        self.refresh_state = RefreshState.DONE
-        if refresh_state == RefreshState.OUTDATED:
-            self.refresh()
-
-    def eventFilter(self, watched, event):
-        if event.type() == QEvent.DragEnter:
-            # we need to accept this event explicitly to be able
-            # to receive QDropEvents!
-            event.accept()
-        if event.type() == QEvent.Drop:
-            md = event.mimeData()
-            if md.hasUrls():
-                files = [url.toLocalFile() for url in md.urls()]
-                self.direct_entry_edit.setText(files[0])
-                return True
-        return super().eventFilter(watched, event)
-
-    def _toggle_status(self, show):
-        if show:
-            self.show_status_btn.setText(trans._("Hide Status"))
-            self.stdout_text.show()
-        else:
-            self.show_status_btn.setText(trans._("Show Status"))
-            self.stdout_text.hide()
-
-    def _install_packages(
-        self,
-        packages: Sequence[str] = (),
-        versions: Optional[Sequence[str]] = None,
-    ):
-        if not packages:
-            _packages = self.direct_entry_edit.text()
-            packages = (
-                [_packages] if os.path.exists(_packages) else _packages.split()
-            )
-            self.direct_entry_edit.clear()
-        if packages:
-            self.installer.install(
-                packages,
-                versions=versions,
-            )
-
-    def _add_items(self):
-        """Add items to the lists one by one using a timer to prevent freezing the UI."""
-        if len(self._plugin_data) == 0:
-            if (
-                self.installed_list.count() + self.available_list.count()
-                == len(self.all_plugin_data)
-            ):
-                self._add_items_timer.stop()
-            return
-
-        data = self._plugin_data.pop(0)
-        project_info, is_available, extra_info = data
-        if project_info.name in self.already_installed:
-            self.installed_list.tag_outdated(project_info, is_available)
-        else:
-            if project_info.name not in self.available_set:
-                self.available_set.add(project_info.name)
-                self.available_list.addItem(
-                    (
-                        project_info,
-                        extra_info['pypi_versions'],
-                        extra_info['conda_versions'],
-                    )
-                )
-            if not is_available:
-                self.available_list.tag_unavailable(project_info)
-
-        self.filter()
-
-    def _handle_yield(self, data: Tuple[npe2.PackageMetadata, bool, Dict]):
-        """Output from a worker process.
-
-        Includes information about the plugin, including available versions on conda and pypi.
-
-        The data is stored but the actual items are added via a timer in the `_add_items`
-        method to prevent the UI from freezing by adding all items at once.
-        """
-        self._plugin_data.append(data)
-        self.all_plugin_data.append(data)
-
-    def filter(self, text: Optional[str] = None) -> None:
-        """Filter by text or set current text as filter."""
-        if text is None:
-            text = self.packages_filter.text()
-        else:
-            self.packages_filter.setText(text)
-
-        self.installed_list.filter(text)
-        self.available_list.filter(text)
-
-
-if __name__ == "__main__":
-    from qtpy.QtWidgets import QApplication
-
-    app = QApplication([])
-    w = QtPluginDialog()
-    w.show()
-    app.exec_()
diff --git a/napari/_qt/menus/plugins_menu.py b/napari/_qt/menus/plugins_menu.py
--- a/napari/_qt/menus/plugins_menu.py
+++ b/napari/_qt/menus/plugins_menu.py
@@ -1,9 +1,9 @@
 from itertools import chain
-from typing import TYPE_CHECKING, Sequence
+from logging import getLogger
+from typing import TYPE_CHECKING, Sequence, Union
 
 from qtpy.QtWidgets import QAction
 
-from napari._qt.dialogs.qt_plugin_dialog import QtPluginDialog
 from napari._qt.dialogs.qt_plugin_report import QtPluginErrReporter
 from napari._qt.menus._util import NapariMenu
 from napari.plugins import _npe2
@@ -13,6 +13,9 @@
     from napari._qt.qt_main_window import Window
 
 
+logger = getLogger(__name__)
+
+
 class PluginsMenu(NapariMenu):
     def __init__(self, window: 'Window') -> None:
         self._win = window
@@ -34,8 +37,9 @@ def __init__(self, window: 'Window') -> None:
 
     def _build(self, event=None):
         self.clear()
-        action = self.addAction(trans._("Plugin Manager"))
-        action.triggered.connect(self._show_plugin_install_dialog)
+        if self._plugin_manager_dialog_cls() is not None:
+            action = self.addAction(trans._("Plugin Manager"))
+            action.triggered.connect(self._show_plugin_install_dialog)
         action = self.addAction(trans._("Plugin Errors..."))
         action.setStatusTip(
             trans._(
@@ -113,9 +117,22 @@ def _add_toggle_widget(*, key=key, hook_type=hook_type):
                 menu.addAction(action)
             action.triggered.connect(_add_toggle_widget)
 
+    def _plugin_manager_dialog_cls(self) -> Union[type, None]:
+        """Return the plugin manager class, if available."""
+        try:
+            # TODO: Register via plugin system?
+            from napari_plugin_manager.qt_plugin_dialog import QtPluginDialog
+        except ImportError as exc:
+            logger.debug("QtPluginDialog not available", exc_info=exc)
+            return None
+        else:
+            return QtPluginDialog
+
     def _show_plugin_install_dialog(self):
         """Show dialog that allows users to sort the call order of plugins."""
-        QtPluginDialog(self._win._qt_window).exec_()
+        # We don't check whether the class is not None, because this
+        # function should only be connected in that case.
+        self._plugin_manager_dialog_cls()(self._win._qt_window).exec_()
 
     def _show_plugin_err_reporter(self):
         """Show dialog that allows users to review and report plugin errors."""


Test patch:
diff --git a/napari/_qt/dialogs/_tests/test_installer_process.py b/napari/_qt/dialogs/_tests/test_installer_process.py
deleted file mode 100644
--- a/napari/_qt/dialogs/_tests/test_installer_process.py
+++ /dev/null
@@ -1,226 +0,0 @@
-import re
-import sys
-import time
-from pathlib import Path
-from types import MethodType
-from typing import TYPE_CHECKING
-
-import pytest
-from qtpy.QtCore import QProcessEnvironment
-
-from napari._qt.dialogs.qt_package_installer import (
-    AbstractInstallerTool,
-    CondaInstallerTool,
-    InstallerQueue,
-    InstallerTools,
-    PipInstallerTool,
-)
-
-if TYPE_CHECKING:
-    from virtualenv.run import Session
-
-
-@pytest.fixture
-def tmp_virtualenv(tmp_path) -> 'Session':
-    virtualenv = pytest.importorskip('virtualenv')
-
-    cmd = [str(tmp_path), '--no-setuptools', '--no-wheel', '--activators', '']
-    return virtualenv.cli_run(cmd)
-
-
-@pytest.fixture
-def tmp_conda_env(tmp_path):
-    import subprocess
-
-    try:
-        subprocess.check_output(
-            [
-                CondaInstallerTool.executable(),
-                'create',
-                '-yq',
-                '-p',
-                str(tmp_path),
-                '--override-channels',
-                '-c',
-                'conda-forge',
-                f'python={sys.version_info.major}.{sys.version_info.minor}',
-            ],
-            stderr=subprocess.STDOUT,
-            text=True,
-            timeout=300,
-        )
-    except subprocess.CalledProcessError as exc:
-        print(exc.output)
-        raise
-
-    return tmp_path
-
-
-def test_pip_installer_tasks(qtbot, tmp_virtualenv: 'Session', monkeypatch):
-    installer = InstallerQueue()
-    monkeypatch.setattr(
-        PipInstallerTool, "executable", lambda *a: tmp_virtualenv.creator.exe
-    )
-    with qtbot.waitSignal(installer.allFinished, timeout=20000):
-        installer.install(
-            tool=InstallerTools.PIP,
-            pkgs=['pip-install-test'],
-        )
-        installer.install(
-            tool=InstallerTools.PIP,
-            pkgs=['typing-extensions'],
-        )
-        job_id = installer.install(
-            tool=InstallerTools.PIP,
-            pkgs=['requests'],
-        )
-        assert isinstance(job_id, int)
-        installer.cancel(job_id)
-
-    assert not installer.hasJobs()
-
-    pkgs = 0
-    for pth in tmp_virtualenv.creator.libs:
-        if (pth / 'pip_install_test').exists():
-            pkgs += 1
-        if (pth / 'typing_extensions.py').exists():
-            pkgs += 1
-        if (pth / 'requests').exists():
-            raise AssertionError('requests got installed')
-
-    assert pkgs >= 2, 'package was not installed'
-
-    with qtbot.waitSignal(installer.allFinished, timeout=10000):
-        job_id = installer.uninstall(
-            tool=InstallerTools.PIP,
-            pkgs=['pip-install-test'],
-        )
-
-    for pth in tmp_virtualenv.creator.libs:
-        assert not (
-            pth / 'pip_install_test'
-        ).exists(), 'pip_install_test still installed'
-
-    assert not installer.hasJobs()
-
-
-def _assert_exit_code_not_zero(
-    self, exit_code=None, exit_status=None, error=None
-):
-    errors = []
-    if exit_code == 0:
-        errors.append("- 'exit_code' should have been non-zero!")
-    if error is not None:
-        errors.append("- 'error' should have been None!")
-    if errors:
-        raise AssertionError("\n".join(errors))
-    return self._on_process_done_original(exit_code, exit_status, error)
-
-
-class _NonExistingTool(AbstractInstallerTool):
-    def executable(self):
-        return f"this-tool-does-not-exist-{hash(time.time())}"
-
-    def arguments(self):
-        return ()
-
-    def environment(self, env=None):
-        return QProcessEnvironment.systemEnvironment()
-
-
-def _assert_error_used(self, exit_code=None, exit_status=None, error=None):
-    errors = []
-    if error is None:
-        errors.append("- 'error' should have been populated!")
-    if exit_code is not None:
-        errors.append("- 'exit_code' should not have been populated!")
-    if errors:
-        raise AssertionError("\n".join(errors))
-    return self._on_process_done_original(exit_code, exit_status, error)
-
-
-def test_installer_failures(qtbot, tmp_virtualenv: 'Session', monkeypatch):
-    installer = InstallerQueue()
-    monkeypatch.setattr(
-        PipInstallerTool, "executable", lambda *a: tmp_virtualenv.creator.exe
-    )
-
-    # CHECK 1) Errors should trigger finished and allFinished too
-    with qtbot.waitSignal(installer.allFinished, timeout=10000):
-        installer.install(
-            tool=InstallerTools.PIP,
-            pkgs=[f'this-package-does-not-exist-{hash(time.time())}'],
-        )
-
-    # Keep a reference before we monkey patch stuff
-    installer._on_process_done_original = installer._on_process_done
-
-    # CHECK 2) Non-existing packages should return non-zero
-    monkeypatch.setattr(
-        installer,
-        "_on_process_done",
-        MethodType(_assert_exit_code_not_zero, installer),
-    )
-    with qtbot.waitSignal(installer.allFinished, timeout=10000):
-        installer.install(
-            tool=InstallerTools.PIP,
-            pkgs=[f'this-package-does-not-exist-{hash(time.time())}'],
-        )
-
-    # CHECK 3) Non-existing tools should fail to start
-    monkeypatch.setattr(
-        installer,
-        "_on_process_done",
-        MethodType(_assert_error_used, installer),
-    )
-    monkeypatch.setattr(installer, "_get_tool", lambda *a: _NonExistingTool)
-    with qtbot.waitSignal(installer.allFinished, timeout=10000):
-        installer.install(
-            tool=_NonExistingTool,
-            pkgs=[f'this-package-does-not-exist-{hash(time.time())}'],
-        )
-
-
-@pytest.mark.skipif(
-    not CondaInstallerTool.available(), reason="Conda is not available."
-)
-def test_conda_installer(qtbot, tmp_conda_env: Path):
-    installer = InstallerQueue()
-
-    with qtbot.waitSignal(installer.allFinished, timeout=600_000):
-        installer.install(
-            tool=InstallerTools.CONDA,
-            pkgs=['typing-extensions'],
-            prefix=tmp_conda_env,
-        )
-
-    conda_meta = tmp_conda_env / "conda-meta"
-    glob_pat = "typing-extensions-*.json"
-
-    assert not installer.hasJobs()
-    assert list(conda_meta.glob(glob_pat))
-
-    with qtbot.waitSignal(installer.allFinished, timeout=600_000):
-        installer.uninstall(
-            tool=InstallerTools.CONDA,
-            pkgs=['typing-extensions'],
-            prefix=tmp_conda_env,
-        )
-
-    assert not installer.hasJobs()
-    assert not list(conda_meta.glob(glob_pat))
-
-
-def test_constraints_are_in_sync():
-    conda_constraints = sorted(CondaInstallerTool.constraints())
-    pip_constraints = sorted(PipInstallerTool.constraints())
-
-    assert len(conda_constraints) == len(pip_constraints)
-
-    name_re = re.compile(r"([a-z0-9_\-]+).*")
-    for conda_constraint, pip_constraint in zip(
-        conda_constraints, pip_constraints
-    ):
-        conda_name = name_re.match(conda_constraint).group(1)
-        pip_name = name_re.match(pip_constraint).group(1)
-        assert conda_name == pip_name
diff --git a/napari/_qt/dialogs/_tests/test_qt_plugin_dialog.py b/napari/_qt/dialogs/_tests/test_qt_plugin_dialog.py
deleted file mode 100644
--- a/napari/_qt/dialogs/_tests/test_qt_plugin_dialog.py
+++ /dev/null
@@ -1,386 +0,0 @@
-import importlib.metadata
-from typing import Generator, Optional, Tuple
-from unittest.mock import patch
-
-import npe2
-import pytest
-
-import napari.plugins
-from napari._qt.dialogs import qt_plugin_dialog
-from napari._qt.dialogs.qt_package_installer import InstallerActions
-from napari.plugins._tests.test_npe2 import mock_pm  # noqa
-from napari.utils.translations import trans
-
-N_MOCKED_PLUGINS = 2
-
-
-def _iter_napari_pypi_plugin_info(
-    conda_forge: bool = True,
-) -> Generator[
-    Tuple[Optional[npe2.PackageMetadata], bool], None, None
-]:  # pragma: no cover  (this function is used in thread and codecov has a problem with the collection of coverage in such cases)
-    """Mock the pypi method to collect available plugins.
-
-    This will mock napari.plugins.pypi.iter_napari_plugin_info` for pypi.
-
-    It will return two fake plugins that will populate the available plugins
-    list (the bottom one). The first plugin will not be available on
-    conda-forge so will be greyed out ("test-name-0"). The second plugin will
-    be available on conda-forge so will be enabled ("test-name-1").
-    """
-    # This mock `base_data`` will be the same for both fake plugins.
-    base_data = {
-        "metadata_version": "1.0",
-        "version": "0.1.0",
-        "summary": "some test package",
-        "home_page": "http://napari.org",
-        "author": "test author",
-        "license": "UNKNOWN",
-    }
-    for i in range(N_MOCKED_PLUGINS):
-        yield npe2.PackageMetadata(name=f"test-name-{i}", **base_data), bool(
-            i
-        ), {
-            "home_page": 'www.mywebsite.com',
-            "pypi_versions": ['3'],
-            "conda_versions": ['4.5'],
-        }
-
-
-class PluginsMock:
-    def __init__(self):
-        self.plugins = {
-            'test-name-0': True,
-            'test-name-1': True,
-            'my-plugin': True,
-        }
-
-
-class OldPluginsMock:
-    def __init__(self):
-        self.plugins = [('test-1', False, 'test-1')]
-        self.enabled = [True]
-
-
-@pytest.fixture
-def old_plugins(qtbot):
-    return OldPluginsMock()
-
-
-@pytest.fixture
-def plugins(qtbot):
-    return PluginsMock()
-
-
-@pytest.fixture
-def plugin_dialog(qtbot, monkeypatch, mock_pm, plugins, old_plugins):  # noqa
-    """Fixture that provides a plugin dialog for a normal napari install."""
-
-    class PluginManagerMock:
-        def instance(self):
-            return PluginManagerInstanceMock(plugins)
-
-    class PluginManagerInstanceMock:
-        def __init__(self, plugins):
-            self.plugins = plugins.plugins
-
-        def __iter__(self):
-            yield from self.plugins
-
-        def iter_manifests(self):
-            yield from [mock_pm.get_manifest('my-plugin')]
-
-        def is_disabled(self, name):
-            return False
-
-        def discover(self):
-            return ['plugin']
-
-        def enable(self, plugin):
-            self.plugins[plugin] = True
-
-        def disable(self, plugin):
-            self.plugins[plugin] = False
-
-    class WarnPopupMock:
-        def __init__(self, text):
-            pass
-
-        def exec_(self):
-            return None
-
-        def move(self, pos):
-            return False
-
-    def mock_metadata(name):
-        meta = {
-            'version': '0.1.0',
-            'summary': '',
-            'Home-page': '',
-            'author': '',
-            'license': '',
-        }
-        return meta
-
-    class OldPluginManagerMock:
-        def __init__(self):
-            self.plugins = old_plugins.plugins
-            self.enabled = old_plugins.enabled
-
-        def iter_available(self):
-            return self.plugins
-
-        def discover(self):
-            return None
-
-        def is_blocked(self, plugin):
-            return self.plugins[0][1]
-
-        def set_blocked(self, plugin, blocked):
-            self.enabled[0] = not blocked
-            return
-
-    monkeypatch.setattr(
-        qt_plugin_dialog,
-        "iter_napari_plugin_info",
-        _iter_napari_pypi_plugin_info,
-    )
-
-    monkeypatch.setattr(qt_plugin_dialog, 'WarnPopup', WarnPopupMock)
-
-    # This is patching `napari.utils.misc.running_as_constructor_app` function
-    # to mock a normal napari install.
-    monkeypatch.setattr(
-        qt_plugin_dialog,
-        "running_as_constructor_app",
-        lambda: False,
-    )
-
-    monkeypatch.setattr(
-        napari.plugins, 'plugin_manager', OldPluginManagerMock()
-    )
-
-    monkeypatch.setattr(importlib.metadata, 'metadata', mock_metadata)
-
-    monkeypatch.setattr(npe2, 'PluginManager', PluginManagerMock())
-
-    widget = qt_plugin_dialog.QtPluginDialog()
-    widget.show()
-    qtbot.wait(300)
-    qtbot.add_widget(widget)
-    yield widget
-    widget.hide()
-    widget._add_items_timer.stop()
-    assert not widget._add_items_timer.isActive()
-
-
-@pytest.fixture
-def plugin_dialog_constructor(qtbot, monkeypatch):
-    """
-    Fixture that provides a plugin dialog for a constructor based install.
-    """
-    monkeypatch.setattr(
-        qt_plugin_dialog,
-        "iter_napari_plugin_info",
-        _iter_napari_pypi_plugin_info,
-    )
-
-    # This is patching `napari.utils.misc.running_as_constructor_app` function
-    # to mock a constructor based install.
-    monkeypatch.setattr(
-        qt_plugin_dialog,
-        "running_as_constructor_app",
-        lambda: True,
-    )
-
-    def available_list_populated():
-        return widget.available_list.count() == N_MOCKED_PLUGINS
-
-    widget = qt_plugin_dialog.QtPluginDialog()
-    widget.show()
-    qtbot.add_widget(widget)
-    qtbot.waitUntil(available_list_populated, timeout=1000)
-
-    yield widget
-    widget.hide()
-    widget._add_items_timer.stop()
-
-
-def test_filter_not_available_plugins(plugin_dialog_constructor):
-    """
-    Check that the plugins listed under available plugins are
-    enabled and disabled accordingly.
-
-    The first plugin ("test-name-0") is not available on conda-forge and
-    should be disabled, and show a tooltip warning.
-
-    The second plugin ("test-name-1") is available on conda-forge and
-    should be enabled without the tooltip warning.
-    """
-    item = plugin_dialog_constructor.available_list.item(0)
-    widget = plugin_dialog_constructor.available_list.itemWidget(item)
-    if widget:
-        assert not widget.action_button.isEnabled()
-        assert widget.warning_tooltip.isVisible()
-
-    item = plugin_dialog_constructor.available_list.item(1)
-    widget = plugin_dialog_constructor.available_list.itemWidget(item)
-    assert widget.action_button.isEnabled()
-    assert not widget.warning_tooltip.isVisible()
-
-
-def test_filter_available_plugins(plugin_dialog):
-    """
-    Test the dialog is correctly filtering plugins in the available plugins
-    list (the bottom one).
-    """
-    plugin_dialog.filter("")
-    assert plugin_dialog.available_list.count() == 2
-    assert plugin_dialog.available_list._count_visible() == 2
-
-    plugin_dialog.filter("no-match@123")
-    assert plugin_dialog.available_list._count_visible() == 0
-
-    plugin_dialog.filter("")
-    plugin_dialog.filter("test-name-0")
-    assert plugin_dialog.available_list._count_visible() == 1
-
-
-def test_filter_installed_plugins(plugin_dialog):
-    """
-    Test the dialog is correctly filtering plugins in the installed plugins
-    list (the top one).
-    """
-    plugin_dialog.filter("")
-    assert plugin_dialog.installed_list._count_visible() >= 0
-
-    plugin_dialog.filter("no-match@123")
-    assert plugin_dialog.installed_list._count_visible() == 0
-
-
-def test_visible_widgets(plugin_dialog):
-    """
-    Test that the direct entry button and textbox are visible for
-    normal napari installs.
-    """
-
-    assert plugin_dialog.direct_entry_edit.isVisible()
-    assert plugin_dialog.direct_entry_btn.isVisible()
-
-
-def test_constructor_visible_widgets(plugin_dialog_constructor):
-    """
-    Test that the direct entry button and textbox are hidden for
-    constructor based napari installs.
-    """
-    assert not plugin_dialog_constructor.direct_entry_edit.isVisible()
-    assert not plugin_dialog_constructor.direct_entry_btn.isVisible()
-
-
-def test_version_dropdown(plugin_dialog):
-    """
-    Test that when the source drop down is changed, it displays the other versions properly.
-    """
-    widget = plugin_dialog.available_list.item(1).widget
-    assert widget.version_choice_dropdown.currentText() == "3"
-    # switch from PyPI source to conda one.
-    widget.source_choice_dropdown.setCurrentIndex(1)
-    assert widget.version_choice_dropdown.currentText() == "4.5"
-
-
-def test_plugin_list_count_items(plugin_dialog):
-    assert plugin_dialog.installed_list._count_visible() == 2
-
-
-def test_plugin_list_handle_action(plugin_dialog, qtbot):
-    item = plugin_dialog.installed_list.item(0)
-    with patch.object(qt_plugin_dialog.PluginListItem, "set_busy") as mock:
-        plugin_dialog.installed_list.handle_action(
-            item,
-            'test-name-1',
-            InstallerActions.UPGRADE,
-        )
-        mock.assert_called_with(
-            trans._("updating..."), InstallerActions.UPGRADE
-        )
-
-    with patch.object(qt_plugin_dialog.WarnPopup, "exec_") as mock:
-        plugin_dialog.installed_list.handle_action(
-            item,
-            'test-name-1',
-            InstallerActions.UNINSTALL,
-        )
-        assert mock.called
-
-    item = plugin_dialog.available_list.item(0)
-    with patch.object(qt_plugin_dialog.PluginListItem, "set_busy") as mock:
-        plugin_dialog.available_list.handle_action(
-            item,
-            'test-name-1',
-            InstallerActions.INSTALL,
-            version='3',
-        )
-        mock.assert_called_with(
-            trans._("installing..."), InstallerActions.INSTALL
-        )
-
-        plugin_dialog.available_list.handle_action(
-            item, 'test-name-1', InstallerActions.CANCEL, version='3'
-        )
-        mock.assert_called_with(
-            trans._("cancelling..."), InstallerActions.CANCEL
-        )
-
-    # Wait for refresh timer, state and worker to be done
-    qtbot.waitUntil(
-        lambda: not plugin_dialog._add_items_timer.isActive()
-        and plugin_dialog.refresh_state == qt_plugin_dialog.RefreshState.DONE
-    )
-    qtbot.waitUntil(lambda: not plugin_dialog.worker.is_running)
-
-
-def test_on_enabled_checkbox(plugin_dialog, qtbot, plugins, old_plugins):
-    # checks npe2 lines
-    item = plugin_dialog.installed_list.item(0)
-    widget = plugin_dialog.installed_list.itemWidget(item)
-
-    assert plugins.plugins['my-plugin'] is True
-    with qtbot.waitSignal(widget.enabled_checkbox.stateChanged, timeout=500):
-        widget.enabled_checkbox.setChecked(False)
-    assert plugins.plugins['my-plugin'] is False
-
-    # checks npe1 lines
-    item = plugin_dialog.installed_list.item(1)
-    widget = plugin_dialog.installed_list.itemWidget(item)
-
-    assert old_plugins.enabled[0] is True
-    with qtbot.waitSignal(widget.enabled_checkbox.stateChanged, timeout=500):
-        widget.enabled_checkbox.setChecked(False)
-    assert old_plugins.enabled[0] is False
-
-
-def test_add_items_outdated(plugin_dialog):
-    """Test that a plugin is tagged as outdated (a newer version is available), the update button becomes visible."""
-
-    # The plugin is being added to the available plugins list.  When the dialog is being built
-    # this one will be listed as available, and it will be found as already installed.
-    # Then, it will check if the installed version is a lower version than the one available.
-    # In this case, my-plugin is installed with version 0.1.0, so the one we are trying to install
-    # is newer, so the update button should pop up.
-    new_plugin = (
-        npe2.PackageMetadata(name="my-plugin", version="0.4.0"),
-        True,
-        {
-            "home_page": 'www.mywebsite.com',
-            "pypi_versions": ['0.4.0'],
-            "conda_versions": ['0.4.0'],
-        },
-    )
-
-    plugin_dialog._plugin_data = [new_plugin]
-
-    plugin_dialog._add_items()
-    item = plugin_dialog.installed_list.item(0)
-    widget = plugin_dialog.installed_list.itemWidget(item)
-
-    assert widget.update_btn.isVisible()
diff --git a/napari/_qt/menus/_tests/test_plugins_menu.py b/napari/_qt/menus/_tests/test_plugins_menu.py
--- a/napari/_qt/menus/_tests/test_plugins_menu.py
+++ b/napari/_qt/menus/_tests/test_plugins_menu.py
@@ -1,3 +1,5 @@
+import sys
+
 from npe2 import DynamicPlugin
 from qtpy.QtWidgets import QWidget
 
@@ -30,3 +32,44 @@ def widget2():
     plugin_action_menu.actions()[0].trigger()
     assert len(viewer.window._dock_widgets) == 1
     assert list(viewer.window._dock_widgets.data)[0] == 'Widget 1 (tmp_plugin)'
+
+
+def test_import_plugin_manager():
+    from napari_plugin_manager.qt_plugin_dialog import QtPluginDialog
+
+    assert QtPluginDialog is not None
+
+
+def test_plugin_manager(make_napari_viewer, monkeypatch, qtbot):
+    """Test that the plugin manager is accessible from the viewer"""
+    viewer = make_napari_viewer()
+
+    plugins_menu = viewer.window.plugins_menu
+    assert plugins_menu._plugin_manager_dialog_cls() is not None
+
+    actions = plugins_menu.actions()
+    for action in actions:
+        if action.text() == "Plugin Manager":
+            break
+    else:  # pragma: no cover
+        found = [action.text() for action in actions]
+        raise AssertionError(
+            f'Plugin Manager menu item not found. Only found: {", ".join(found)}'
+        )
+
+
+def test_no_plugin_manager(make_napari_viewer, monkeypatch):
+    """Test that the plugin manager is not accessible from the viewer when not installed"""
+    monkeypatch.setitem(sys.modules, 'napari_plugin_manager', None)
+    monkeypatch.setitem(
+        sys.modules, 'napari_plugin_manager.qt_plugin_dialog', None
+    )
+    viewer = make_napari_viewer()
+
+    plugins_menu = viewer.window.plugins_menu
+    assert plugins_menu._plugin_manager_dialog_cls() is None
+
+    actions = plugins_menu.actions()
+    for action in actions:
+        if action.text() == "Plugin Manager":
+            raise AssertionError(f"Plugin Manager was found: {action}")