# Slicer.packaging library

**URL:** <https://discourse.slicer.org/t/slicer-packaging-library/47056>\
**Category:** Support\
**Created:** [May 17, 2026, 5:50am UTC](https://discourse.slicer.org/t/slicer-packaging-library/47056 "2026-05-17T05:50:39Z")\
**Posts on this page:** 6\
**Page:** 1

<div class="post-metadata">

**Author:** ![muratmaga](https://sea2.discourse-cdn.com/flex002/user_avatar/discourse.slicer.org/muratmaga/32/3622_2.png) [@muratmaga](https://discourse.slicer.org/u/muratmaga)\
**Post date:** [May 17, 2026, 5:50am UTC](https://discourse.slicer.org/t/slicer-packaging-library/47056/1 "2026-05-17T05:50:39Z")

</div>

I came across this blog post on Linked in [https://www.kitware.com/improving-python-dependency-handling-for-3d-slicer-extension-development/](https://www.kitware.com/improving-python-dependency-handling-for-3d-slicer-extension-development/)

The post says it is already integrated into the Slicer preview preview build. This is definitely a great future and imrpovement, however, I don’t recall seeing anything about this in this forum.

I would be good to cross-post these announcements on the forum as well. Linked in is good for outreach, but the community is here.

---

<div class="post-metadata">

**Author:** ![pieper](https://sea2.discourse-cdn.com/flex002/user_avatar/discourse.slicer.org/pieper/32/8_2.png) [@pieper](https://discourse.slicer.org/u/pieper)\
**Post date:** [May 17, 2026, 7:56am UTC](https://discourse.slicer.org/t/slicer-packaging-library/47056/2 "2026-05-17T07:56:16Z")

</div>

There was a lot of discussion about this on the slicer-dev meetings, the agenda and minutes of which show up on the forum. E.g. [here](https://discourse.slicer.org/t/2026-03-24-weekly-meeting/46543/3) and of course in the corresponding pull request thread, so people watching the repo would be notified. The info is there, but maybe not flagged in a way that gets everyone’s attention.

> <https://github.com/Slicer/Slicer/pull/9010>
>
> \# Python Dependency Handling Improvements
> 
> \## Summary
> 
> This PR introduces a …new \`slicer.packaging\` module for managing Python dependencies in Slicer extensions. The goal is to provide a standardized, user-friendly way to check, prompt for, and install Python packages. The existing \`slicer.util.pip\_install\` and \`slicer.util.pip\_uninstall\` functions are retained as backward-compatible wrappers that delegate to \`slicer.packaging\`.
> 
> \*\*New/updated functions:\*\*
> 
> | Function | Purpose |
> |----------|---------|
> | \`slicer.packaging.load\_requirements(path)\` | Load a \`requirements.txt\` file into \`Requirement\` objects |
> | \`slicer.packaging.load\_pyproject\_dependencies(path)\` | Load \`\[project.dependencies\]\` from a \`pyproject.toml\` file into \`Requirement\` objects |
> | \`slicer.packaging.pip\_check(reqs)\` | Check if requirements are satisfied (pure Python, no subprocess). Accepts a string, a list of strings, a \`Requirement\`, or a list of \`Requirement\`s. |
> | \`slicer.packaging.pip\_install(...)\` | Canonical home; extended with modal progress dialog, non-blocking mode, status bar feedback, \`--no-deps\` support, and a discouraged \`skip\_packages\` workaround for transitive dependency conflicts. Also available as \`slicer.util.pip\_install\` (backward-compatible wrapper) |
> | \`slicer.packaging.pip\_ensure(reqs, requester="...")\` | High-level: checks, prompts, installs with progress, and offers restart if updated packages were already imported. Accepts the same flexible input as \`pip\_check\`. |
> 
> All install functions support optional \`constraints\` (constraints file), \`no\_deps\_requirements\` (packages to install with \`--no-deps\`), and \`skip\_packages\` (packages to exclude from the transitive dependency tree) parameters.
> 
> \*\*Typical usage in an extension:\*\*
> 
> \`\`\`python
> import slicer.packaging
> 
> reqs = slicer.packaging.load\_requirements(self.resourcePath("requirements.txt"))
> slicer.packaging.pip\_ensure(reqs, requester="MyExtension")
> import my\_dependency # Now safe
> \`\`\`
> 
> Or, with \`pyproject.toml\`:
> 
> \`\`\`python
> import slicer.packaging
> 
> reqs = slicer.packaging.load\_pyproject\_dependencies(self.resourcePath("pyproject.toml"))
> slicer.packaging.pip\_ensure(reqs, requester="MyExtension")
> import my\_dependency # Now safe
> \`\`\`
> 
> With \`skip\_packages\` (for extensions that need to exclude certain transitive dependencies; \*\*discouraged workaround\*\*, see \[the script repository\](https://github.com/Slicer/Slicer/blob/main/Docs/developer\_guide/script\_repository/packaging.md) for guidance):
> 
> \`\`\`python
> import slicer.packaging
> 
> skipped = slicer.packaging.pip\_ensure(
> "nnunetv2\>=2.3",
> skip\_packages=\["SimpleITK", "torch", "requests"\],
> requester="SlicerNNUNet",
> )
> \# skipped contains the requirement strings that were excluded
> \`\`\`
> 
> \*\*Behavior of \`pip\_install\`:\*\*
> 
> Four operating modes:
> 
> \- \`show\_progress=True, blocking=True\` : Modal progress dialog (new default behavior)
> \- \`show\_progress=True, blocking=False\`: Status bar messages
> \- \`show\_progress=False, blocking=True\`: Busy cursor only (the previous default behavior, except with a busy cursor now added)
> \- \`show\_progress=False, blocking=False\`: No visual indication that anything is happening (without looking at python console); specify callbacks to create more reasonable custom behaviors.
> 
> \## Changes
> 
> The PR is organized as 5 commits, each a self-contained change:
> 
> 1. \*\*\`ENH: Add slicer.packaging module for managing Python dependencies\`\*\*
> - \`Base/Python/slicer/packaging.py\` (new) — \`load\_requirements\`, \`load\_pyproject\_dependencies\`, \`pip\_check\`, \`pip\_ensure\`, \`pip\_install\`, \`pip\_uninstall\`, the non-blocking infrastructure, the modal progress dialog, the restart prompt, and the \`skip\_packages\` workaround
> - \`Base/Python/slicer/util.py\` — backward-compatible \`pip\_install\` / \`pip\_uninstall\` wrappers that delegate to \`slicer.packaging\`; updated error messages to point at the canonical name
> - \`Base/Python/slicer/tests/test\_slicer\_packaging.py\` (new) — full unit test suite for the new module
> - \`Base/Python/slicer/tests/test\_slicer\_util\_pip.py\` (new) — focused tests for the back-compat wrappers
> - \`Base/Python/CMakeLists.txt\` — register the new module
> - \`Applications/SlicerApp/Testing/Python/CMakeLists.txt\` — register the test files
> 
> 2. \*\*\`DOC: Document slicer.packaging in the developer guide\`\*\*
> - \`Docs/developer\_guide/script\_repository/packaging.md\` (new) — full "Python package management" section in the script repository
> - \`Docs/developer\_guide/script\_repository.md\` — include directive
> - \`Docs/developer\_guide/python\_faq.md\` — point developers at \`slicer.packaging\`, recommend \`pip\_ensure\` as the high-level workflow, refresh the best-practices list to reflect that \`pip\_ensure\` handles confirmation on its own
> - \`Docs/developer\_guide/script\_repository/gui.md\`, \`models.md\`, \`plots.md\` — sweep existing examples to use the canonical name
> - \`Docs/developer\_guide/slicer.md\` — wire \`slicer.packaging\` into the auto-generated API reference
> 
> 3. \*\*\`ENH: Show slicer.packaging usage in the new scripted module template\`\*\*
> - \`Utilities/Templates/Modules/Scripted/TemplateKey.py\` — commented-out \`pip\_ensure\` example at the top of \`onApplyButton\`
> - \`Utilities/Templates/Modules/Scripted/Resources/requirements.txt\` (new) — blank scaffold
> - \`Utilities/Templates/Modules/Scripted/CMakeLists.txt\` — list the requirements file as a module resource
> - \`Utilities/Scripts/SlicerWizard/TemplateManager.py\` — recognize \`requirements.txt\` as a template file so it actually rides along when the wizard generates a new module
> 
> 4. \*\*\`BUG: Clean up Python interactor startup\`\*\*
> - \`Base/Python/slicerqt.py\` (renamed from \`Base/Python/slicer/slicerqt.py\`) — moved out of the \`slicer/\` package directory so that CTK's \`executeFile\` no longer puts \`bin/Python/slicer/\` on \`sys.path\[0\]\` (which silently shadowed any same-named PyPI package, including the one we wanted for this PR). The wildcard \`from slicer.util import \*\` is replaced with an explicit 5-name list (\`array\`, \`exit\`, \`getNode\`, \`getNodesByClass\`, \`mainWindow\`).
> - \`Base/QTGUI/qSlicerPythonManager.cxx\` — updated path
> - \`Base/Python/CMakeLists.txt\` — register the new location
> 
> 5. \*\*\`BUG: Allow i18n translation functions to work without Qt\`\*\*
> - \`Base/Python/slicer/i18n.py\` — \`translate()\` no longer crashes when \`slicer.app\` is unavailable; it returns the untranslated text instead. This is what made it possible to wrap user-facing strings in \`slicer.packaging\` with \`\_()\` while keeping the module headless-safe.
> 
> \---
> 
> \<details\>
> \<summary\>\<strong\>Testing Snippets (click to expand)\</strong\>\</summary\>
> 
> Convenient snippets to quickly try things. First, run this once in the Python console:
> 
> \`\`\`python
> import slicer.packaging
> \`\`\`
> 
> \### load\_requirements
> 
> \`\`\`python
> import tempfile, os
> with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
> f.write("numpy\>=1.20\\npandas\>=2.0\\nscipy\\n")
> path = f.name
> reqs = slicer.packaging.load\_requirements(path)
> print(\[f"{r.name}: {r.specifier}" for r in reqs\])
> os.unlink(path)
> \`\`\`
> 
> \### load\_pyproject\_dependencies
> 
> \`\`\`python
> import tempfile, os
> with tempfile.NamedTemporaryFile(mode='w', suffix='.toml', delete=False) as f:
> f.write("\[project\]\\ndependencies = \[\\n")
> f.write(' "numpy\>=1.20",\\n "pandas\>=2.0",\\n "scipy",\\n\]\\n')
> path = f.name
> reqs = slicer.packaging.load\_pyproject\_dependencies(path)
> print(\[f"{r.name}: {r.specifier}" for r in reqs\])
> os.unlink(path)
> \`\`\`
> 
> \### pip\_check
> 
> \`\`\`python
> \# Simple string syntax
> slicer.packaging.pip\_check("numpy\>=1.0") # True
> slicer.packaging.pip\_check("numpy\>=99999.0") # False
> slicer.packaging.pip\_check("nonexistent-package-xyz") # False
> 
> \# Multiple requirements in a single space-separated string
> slicer.packaging.pip\_check("numpy\>=1.0 scipy\>=1.0") # True
> 
> \# Markers that don't apply are considered satisfied
> slicer.packaging.pip\_check("foo; sys\_platform == 'nonexistent'") # True
> 
> \# A list of strings also works
> slicer.packaging.pip\_check(\["numpy\>=1.0", "scipy\>=1.0"\]) # True
> 
> \# Or a packaging.requirements.Requirement object, if you already have one
> from packaging.requirements import Requirement
> slicer.packaging.pip\_check(Requirement("numpy\>=1.0")) # True
> \`\`\`
> 
> \### pip\_install with progress dialog (default)
> 
> \`\`\`python
> \# Default: shows modal progress dialog, blocks until complete
> \# (If already installed, completes quickly with "already satisfied")
> slicer.packaging.pip\_install("scikit-image", requester="ReviewTest")
> \`\`\`
> 
> To see the full installation flow, uninstall first:
> \`\`\`python
> slicer.packaging.pip\_uninstall("scikit-image")
> slicer.packaging.pip\_install("scikit-image", requester="ReviewTest")
> \`\`\`
> 
> If it's still too fast, try putting "torch" as the package :)
> 
> \### pip\_install without progress dialog
> 
> \`\`\`python
> \# Busy cursor only, no dialog
> slicer.packaging.pip\_install("scikit-image", show\_progress=False)
> \`\`\`
> 
> \### Non-blocking pip\_install with status bar messages
> 
> \`\`\`python
> slicer.packaging.pip\_install("scikit-image", blocking=False, requester="ReviewTest")
> \`\`\`
> 
> \### Non-blocking pip\_install with custom callbacks
> 
> \`\`\`python
> def onLog(line):
> print(f"\[pip\] {line}")
> 
> def onComplete(code):
> print(f"Done! Return code: {code}")
> 
> \# Using --help to see callbacks firing with lots of output
> slicer.packaging.pip\_install("--help", blocking=False, show\_progress=False, logCallback=onLog, completedCallback=onComplete)
> \`\`\`
> 
> \### Backward-compat wrappers in slicer.util
> 
> The old \`slicer.util.pip\_install\` and \`slicer.util.pip\_uninstall\` still work; they delegate to \`slicer.packaging\`. Quick sanity check:
> 
> \`\`\`python
> slicer.util.pip\_install("charset-normalizer")
> slicer.util.pip\_uninstall("charset-normalizer")
> \`\`\`
> 
> \### Check if pip install is in progress
> 
> \`\`\`python
> slicer.packaging.pip\_install("scikit-image", blocking=False, requester="ReviewTest")
> print(slicer.packaging.isPipInstallInProgress()) # probably True
> \`\`\`
> 
> versus just this:
> 
> \`\`\`python
> print(slicer.packaging.isPipInstallInProgress()) # False
> \`\`\`
> 
> \### pip\_ensure
> 
> \`\`\`python
> \# With prompt dialog (default). Does nothing if already installed --
> \# uninstall first if you want to see the full install flow:
> \# slicer.packaging.pip\_uninstall("charset-normalizer")
> slicer.packaging.pip\_ensure("charset-normalizer\>=3.0", requester="ReviewTest")
> 
> \# Without install prompt
> slicer.packaging.pip\_ensure("charset-normalizer\>=3.0", prompt\_install=False, requester="ReviewTest")
> \`\`\`
> 
> \### pip\_ensure restart prompt
> 
> After installation, \`pip\_ensure\` checks if any updated packages were already imported in the current session. If so, it shows a "Restart Recommended" dialog with details (old → new versions). The user can restart immediately or continue.
> 
> \`\`\`python
> import numpy # Ensure numpy is in sys.modules
> 
> \# Reinstall numpy (already imported) -- should trigger restart prompt
> slicer.packaging.pip\_uninstall("numpy")
> slicer.packaging.pip\_ensure("numpy\>=1.0", requester="ReviewTest")
> \# A "Restart Recommended" dialog should appear because numpy was already imported
> \`\`\`
> 
> To disable the restart prompt: \`slicer.packaging.pip\_ensure("numpy\>=1.0", prompt\_restart=False)\`
> 
> \### With constraints file
> 
> \`\`\`python
> import tempfile, os
> 
> \# Create a constraints file that pins charset-normalizer
> with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
> f.write("charset-normalizer==3.3.2\\n")
> constraints\_path = f.name
> 
> \# Install requests (which depends on charset-normalizer) with the constraint
> slicer.packaging.pip\_install("requests", constraints=constraints\_path)
> os.unlink(constraints\_path)
> \`\`\`
> 
> \### With skip\_packages (selective dependency installation)
> 
> \> \*\*Note:\*\* \`skip\_packages\` is a discouraged workaround for transitive dependency conflicts; see the script repository's "Python package management" section for guidance.
> 
> \`\`\`python
> \# Install scikit-image but skip imageio (one of its dependencies)
> skipped = slicer.packaging.pip\_install(
> "scikit-image",
> skip\_packages=\["imageio"\],
> requester="ReviewTest",
> )
> print(f"Skipped: {skipped}")
> 
> \# Or via pip\_ensure:
> skipped = slicer.packaging.pip\_ensure(
> "scikit-image\>=0.20",
> skip\_packages=\["imageio"\],
> requester="ReviewTest",
> )
> \`\`\`
> 
> \### PythonSlicer (command-line) usage
> 
> Run this from a terminal using Slicer's PythonSlicer executable (not the Slicer Python console):
> 
> \`\`\`bash
> /path/to/PythonSlicer -c "import slicer.packaging; slicer.packaging.pip\_install('charset-normalizer')"
> \`\`\`
> 
> This verifies that \`pip\_install\` works in the PythonSlicer environment where \`slicer.app\` and Qt are not available. The function automatically falls back to simple blocking mode.
> 
> \</details\>
> 
> \---
> 
> \<details\>
> \<summary\>\<strong\>Design Rationale (click to expand)\</strong\>\</summary\>
> 
> \### Why requirements.txt? And why also pyproject.toml?
> 
> \`requirements.txt\` is the primary recommended format. Slicer extensions aren't Python packages — they just need "install these things into this environment," which is exactly what \`requirements.txt\` is for. It's pip's native input format, every Python developer knows it, and it requires no boilerplate beyond the dependency list itself.
> 
> That said, \`pyproject.toml\` is the modern standard in the Python ecosystem (PEP 621). It offers structured parsing via \`tomllib\` (stdlib) with no ad-hoc text handling, and extensions that already have a \`pyproject.toml\` for other tooling (ruff, pytest, etc.) can keep dependencies in one file. So we provide \`load\_pyproject\_dependencies\` as an alternative for extensions that prefer it.
> 
> Both formats boil down to PEP 508 dependency strings. Both loader functions return the same \`list\[Requirement\]\` type, so the downstream API (\`pip\_check\`, \`pip\_ensure\`, \`pip\_install\`) works identically regardless of which one you use.
> 
> \`load\_pyproject\_dependencies\` reads only the \`\[project.dependencies\]\` list. Other fields in the \`\[project\]\` table (\`name\`, \`version\`, etc.) are not read or validated — extensions aren't Python packages and shouldn't need to provide them.
> 
> \### Why pure-Python pip\_check instead of pip --dry-run?
> 
> Installing is not done frequently, but \_checking\_ may be called frequently. A pure-Python implementation using \`importlib.metadata\` avoids the overhead of a subprocess. If we used \`pip --dry-run\` it would have to be a subprocess.
> 
> \### Why explicit pip\_ensure instead of lazy import magic?
> 
> We considered \[LazyImportGroup\](https://github.com/Slicer/Slicer/issues/7707) which intercepts first use of imports to trigger installation. It is elegant, but it reduces transparency and makes debugging harder for extension developers. For now we get this pattern which has more boilerplate but is more transparent and simple to debug:
> 
> \`\`\`python
> slicer.packaging.pip\_ensure(reqs, requester="MyExtension")
> import my\_dependency # Explicit, debuggable
> \`\`\`
> 
> IDE support works via \`TYPE\_CHECKING\`:
> \`\`\`python
> from typing import TYPE\_CHECKING
> if TYPE\_CHECKING:
> import my\_dependency # For type hints only
> \`\`\`
> 
> We can still consider the lazy import ideas in the future -- the lower level tools here would still be useful.
> 
> \### Why \`pip\_install\` now defaults to showing a progress dialog
> 
> Before this PR, \`pip\_install\` was always blocking with no progress feedback. The new defaults (\`blocking=True\`, \`show\_progress=True\`) mean existing code that calls \`pip\_install\` will now get a modal progress dialog "for free" without any code changes. This improves UX for many extensions immediately, while still allowing opt-out via \`show\_progress=False\`.
> 
> Hopefully it doesnt' break too many extensions...
> 
> \### Prevention of multiple non-blocking pip installs
> 
> When \`pip\_install\` runs with \`blocking=False\`, it returns immediately while pip runs in the background. If another non-blocking \`pip\_install\` is started before the first completes then we get chaos.
> 
> The solution adopted here is a module-level \`\_pip\_install\_in\_progress\` flag that raises \`RuntimeError\` if a second non-blocking install is attempted. Developers can check \`isPipInstallInProgress()\` first if they want to guard against this themselves.
> 
> \### Why \`no\_deps\_requirements\` parameter
> 
> Some Python packages declare overly strict dependency requirements that conflict with other packages in Slicer's environment. The standard workaround requires two separate pip calls:
> 
> \`\`\`python
> pip\_install("--no-deps problematic-package==1.0") # Ignore its deps
> pip\_install("numpy scipy") # Install known-good deps manually
> \`\`\`
> 
> The \`no\_deps\_requirements\` parameter handles this two-step process internally, making the intent self-documenting:
> 
> \`\`\`python
> pip\_install(requirements="numpy scipy", no\_deps\_requirements="problematic-pkg==1.0")
> \`\`\`
> 
> This also correctly handles non-blocking mode by chaining the two pip calls internally.
> 
> \### Why \`skip\_packages\` parameter
> 
> \`skip\_packages\` is a discouraged workaround, included reluctantly because it solves a real problem that has no clean alternative for some packages.
> 
> Multiple Slicer extensions (SlicerTotalSegmentator, SlicerNNUNet) independently implement ~70-90 lines of recursive selective-install code to install packages while excluding certain transitive dependencies. Common examples: SimpleITK (Slicer bundles a custom version), torch (must be installed via SlicerPyTorch for the correct CUDA/CPU build), and requests (already bundled, replacing it forces an unnecessary restart). For packages like nnUNet with deep transitive dependency trees, manually enumerating the full dependency list (the alternative \`no\_deps\_requirements\` approach) would impose a significant maintenance burden across every upstream version bump.
> 
> The \`skip\_packages\` parameter centralizes this logic so extensions don't each reinvent it. When provided, each package is installed with \`--no-deps\`, its dependency tree is walked recursively, and any package matching the skip list is excluded. Package METADATA is updated after installation so that \`pip check\` doesn't flag skipped packages as missing.
> 
> The downsides are real: METADATA scrubbing hides genuine conflicts, \`pip show\` no longer reflects the true dependency graph, and skip lists drift as Slicer's bundled packages change. Documentation discourages reaching for \`skip\_packages\` first and points developers at upstream fixes or \`no\_deps\_requirements\` where those are tractable.
> 
> \### \`skip\_packages\` vs \`no\_deps\_requirements\`
> 
> Both are available; they serve different purposes:
> 
> \- \*\*\`no\_deps\_requirements\`\*\*: "Install these packages without any of their deps." You provide the correct deps yourself. Fast (2 pip calls), doesn't modify METADATA. Use when a specific package has broken dependency declarations \*and\* the dependency tree is small enough to enumerate by hand.
> \- \*\*\`skip\_packages\`\*\*: "Install everything except these specific packages, anywhere in the dependency tree." Automatic recursive walk with METADATA scrubbing. Slower (one pip call per package). The escape hatch for cases where the dependency tree is too deep to enumerate manually.
> 
> They are mutually exclusive — providing both raises \`ValueError\`.
> 
> \### Why \`pip\_install\` doesn't accept \`list\[Requirement\]\`
> 
> It would feel natural to write \`pip\_install(load\_requirements("requirements.txt"))\`. The fact that you can't is a little unfortunate — it looks like it \*should\* work. But \`pip\_install\` is extremely widely used in the wild (via \`slicer.util.pip\_install\`), and we don't want to make breaking changes to its signature. It's also a low-level function that mirrors the pip CLI, taking the same kind of string arguments you'd pass on the command line, so it makes sense to leave it working the way it does.
> 
> The new structured \`list\[Requirement\]\` input type goes into the new \`slicer.packaging\` functions instead: \`pip\_check\` and \`pip\_ensure\`. In practice, \`slicer.packaging.pip\_ensure(load\_requirements("requirements.txt"))\` is the call you want anyway -- it checks what's already installed, prompts the user, installs only what's missing, and detects whether a restart is needed. So accepting \`Requirement\` objects at the \`pip\_ensure\` level rather than the \`pip\_install\` level steers developers toward the safer, idempotent workflow.
> 
> \### Why \`pip\_\` function naming
> 
> The new functions follow the existing naming convention established by \`slicer.util.pip\_install\` and \`slicer.util.pip\_uninstall\`. All pip-related functions now live together in \`slicer.packaging\` with the \`pip\_\` prefix, providing a consistent, discoverable API.
> 
> \### Why \`slicer.packaging\` (module naming)
> 
> \`slicer.packaging\` was always the preferred name -- it's descriptive and mirrors the well-known third-party \`packaging\` library. Getting there involved an unexpected detour.
> 
> The first attempt at the name failed: \`slicer/packaging.py\` shadowed the third-party \`packaging\` library, breaking \`from packaging.requirements import Requirement\` everywhere. Tracing the root cause led to CTK: \`ctkAbstractPythonManager::executeFile\` prepends the executed script's directory to \`sys.path\[0\]\` and never removes it. Since Slicer runs \`slicerqt.py\` at startup, and \`slicerqt.py\` lived inside \`bin/Python/slicer/\`, that directory landed on \`sys.path\[0\]\` -- which silently promoted every file inside the \`slicer/\` package to a top-level module name and shadowed any PyPI package of the same name.
> 
> The Slicer-side fix is to move \`slicerqt.py\` out of the \`slicer/\` package directory (commit 4 in this PR). With \`slicerqt.py\` at \`bin/Python/\`, CTK's prepend lands on a path that's already on \`sys.path\`, so the harmful side effect goes away. The CTK behavior is still a bug and worth fixing upstream separately, but the workaround on the Slicer side is good cleanup on its own merits: \`slicerqt.py\` is conceptually a startup script, not a member of the \`slicer\` package.
> 
> With \`slicerqt.py\` moved, \`slicer.packaging\` is once again a safe name and is what this PR ships.
> 
> \### Why \`import slicer.packaging\` is explicit
> 
> You need \`import slicer.packaging\` to use it -- like any normal Python submodule. The reason this is even worth mentioning is that historically \`slicer.util\` works without an explicit import, because the interactor startup script reaches into the user's \`\_\_main\_\_\` namespace and pre-populates a handful of names from it. This PR narrows that startup pre-population from a wildcard to an explicit 5-name list (see commit 4), which is part of why the contrast is now sharper. There's no special reason \`slicer.packaging\` couldn't be added to that startup list; we chose not to because top-level Python module imports in \`slicer.packaging\` (e.g. \`packaging.requirements\`, \`importlib.metadata\`) would be paid by every Slicer session even though most never install a Python package, and "type one extra import line in your extension" is a trivial cost.
> 
> \### Why \`pip\_uninstall\` was also modified
> 
> While the focus of this work is on dependency installation, \`pip\_uninstall\` was updated with the same non-blocking parameters (\`blocking\`, \`logCallback\`, \`completedCallback\`) for API consistency. Since both functions share the same underlying infrastructure (\`launchConsoleProcess\` and \`\_executePythonModule\`) in \`slicer.packaging\`, extending non-blocking support to \`pip\_uninstall\` required minimal additional code and ensures users have a symmetric API for both operations.
> 
> \### Non-blocking implementation
> 
> The non-blocking mode uses a QTimer-based polling approach inspired by \[SlicerMONAIAuto3DSeg\](https://github.com/lassoan/SlicerMONAIAuto3DSeg). A background thread reads process output into a queue while QTimer polls from the main thread, keeping the Qt event loop responsive.
> 
> \### How the restart prompt works
> 
> After \`pip\_ensure\` installs packages, it snapshots all installed package versions (before and after) using \`importlib.metadata\`. For any packages whose version changed, it checks whether their top-level import names appear in \`sys.modules\` (meaning they were already imported in the current session). The distribution-to-import-name mapping uses \`importlib.metadata.packages\_distributions()\` (Python 3.11+). If any already-imported packages were updated, a "Restart Recommended" dialog shows the affected packages with their old → new versions. The \`prompt\_restart=False\` parameter disables this check.
> 
> \</details\>
> 
> \---
> 
> \<details\>
> \<summary\>\<strong\>Interactive Feature Tour (click to expand)\</strong\>\</summary\>
> 
> Build Slicer from the \`python-dependency-handling-improvements\` branch, launch it, open the Python console, and paste the script below. A menu lets you pick which demos to run — or select "Run All" for the full walkthrough.
> 
> \`\`\`python
> import importlib.metadata as \_md
> import os
> import tempfile
> 
> import qt
> from packaging.requirements import Requirement
> 
> import slicer
> import slicer.packaging
> 
> \# ---------------------------------------------------------------------------
> \# Configuration — change these to try a different demo package
> \# ---------------------------------------------------------------------------
> 
> DEMO\_PACKAGE = "scikit-image" # pip install name
> DEMO\_IMPORT = "skimage" # Python import name
> DEMO\_CONSTRAINT = "\>=0.20,\<0.25" # version range for constraints demo
> DEMO\_SKIP\_DEP = "imageio" # dependency to skip in skip\_packages demo
> 
> \# ---------------------------------------------------------------------------
> \# State
> \# ---------------------------------------------------------------------------
> 
> try:
> pkg\_version\_before\_tour = \_md.version(DEMO\_PACKAGE)
> except \_md.PackageNotFoundError:
> pkg\_version\_before\_tour = None
> 
> 
> \# ---------------------------------------------------------------------------
> \# Helpers
> \# ---------------------------------------------------------------------------
> 
> def clear\_cache():
> """Purge the pip download cache so installs show real download progress."""
> slicer.packaging.\_executePythonModule("pip", \["cache", "purge"\])
> 
> 
> def uninstall\_pkg():
> """Remove the demo package if present."""
> try:
> slicer.util.pip\_uninstall(DEMO\_PACKAGE)
> except Exception:
> pass
> 
> 
> def fresh\_slate():
> """Uninstall the demo package and clear the cache."""
> uninstall\_pkg()
> clear\_cache()
> 
> 
> \# ---------------------------------------------------------------------------
> \# Demo 1 — Loading Dependencies
> \# ---------------------------------------------------------------------------
> 
> def demo\_loading\_deps():
> """load\_requirements() and load\_pyproject\_dependencies()"""
> 
> # --- requirements.txt ---
> slicer.util.infoDisplay(
> "load\_requirements(path) parses a requirements.txt file into\\n"
> "Requirement objects, skipping comments, blanks, and pip options.\\n"
> "\\n"
> "Watch the Python console for parsed results.",
> windowTitle="Demo 1: Loading Dependencies — requirements.txt",
> )
> 
> with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
> f.write("# Example requirements file\\n")
> f.write("numpy\>=1.20\\n")
> f.write(f"{DEMO\_PACKAGE}\>=0.20\\n")
> f.write("nonexistent-package-xyz\>=1.0\\n")
> f.write("-c constraints.txt\\n")
> f.write("\\n")
> path = f.name
> 
> reqs = slicer.packaging.load\_requirements(path)
> os.unlink(path)
> 
> lines = \[f" {r.name} {r.specifier}" for r in reqs\]
> print(f"\[Tour\] load\_requirements -\> {len(reqs)} requirements:\\n" + "\\n".join(lines))
> 
> # --- pyproject.toml ---
> with tempfile.NamedTemporaryFile(mode="w", suffix=".toml", delete=False) as f:
> f.write("\[project\]\\n")
> f.write("dependencies = \[\\n")
> f.write(' "numpy\>=1.20",\\n')
> f.write(f' "{DEMO\_PACKAGE}\>=0.20",\\n')
> f.write(' "nonexistent-package-xyz\>=1.0",\\n')
> f.write("\]\\n")
> path = f.name
> 
> reqs2 = slicer.packaging.load\_pyproject\_dependencies(path)
> os.unlink(path)
> 
> lines2 = \[f" {r.name} {r.specifier}" for r in reqs2\]
> print(f"\[Tour\] load\_pyproject\_dependencies -\> {len(reqs2)} requirements:\\n" + "\\n".join(lines2))
> 
> slicer.util.infoDisplay(
> f"Loaded {len(reqs)} requirements from requirements.txt\\n"
> f"and {len(reqs2)} from pyproject.toml.\\n"
> "\\n"
> "Both return the same Requirement objects — the downstream\\n"
> "API (pip\_check, pip\_ensure) works identically with either.",
> windowTitle="Demo 1: Loading Dependencies — result",
> )
> 
> 
> \# ---------------------------------------------------------------------------
> \# Demo 2 — Checking Requirements
> \# ---------------------------------------------------------------------------
> 
> def demo\_checking\_reqs():
> """pip\_check() — pure-Python requirement validation"""
> 
> slicer.util.infoDisplay(
> "pip\_check(req) checks if a requirement is satisfied.\\n"
> "Pure Python, no subprocess — fast enough to call frequently.\\n"
> "\\n"
> "We will test several cases against the current environment.",
> windowTitle="Demo 2: Checking Requirements",
> )
> 
> checks = \[
> ("numpy\>=1.0", "Bundled with Slicer — should be satisfied"),
> ("numpy\>=99999.0", "Impossibly high version — should fail"),
> ("nonexistent-xyz\>=1.0", "Package not installed"),
> ('foo; sys\_platform == "nonexistent"', "Marker does not apply — treated as satisfied"),
> \]
> 
> results = \[\]
> for spec, desc in checks:
> req = Requirement(spec)
> ok = slicer.packaging.pip\_check(req)
> mark = "SATISFIED" if ok else "NOT satisfied"
> results.append(f" \[{mark}\] {spec}\\n {desc}")
> 
> slicer.util.infoDisplay(
> "pip\_check results:\\n"
> "\\n" + "\\n\\n".join(results),
> windowTitle="Demo 2: Checking Requirements — results",
> )
> 
> 
> \# ---------------------------------------------------------------------------
> \# Demo 3 — Installing with Progress
> \# ---------------------------------------------------------------------------
> 
> def demo\_install\_progress():
> """pip\_install() — modal dialog and non-blocking status bar"""
> 
> # --- Part 1: Modal (blocking) ---
> fresh\_slate()
> 
> slicer.util.infoDisplay(
> "pip\_install() — modal progress dialog (the new default).\\n"
> "\\n"
> "Try expanding the Details section!",
> windowTitle="Demo 3a: Modal Progress Dialog",
> )
> 
> slicer.util.pip\_install(DEMO\_PACKAGE, requester="Feature Tour")
> 
> slicer.util.infoDisplay(
> "Modal install done.\\n"
> "\\n"
> "Now switching to non-blocking mode: the call returns\\n"
> "immediately and pip output appears in the status bar.",
> windowTitle="Demo 3a: Modal — done",
> )
> 
> # --- Part 2: Non-blocking ---
> fresh\_slate()
> 
> slicer.util.infoDisplay(
> "pip\_install() — non-blocking with status bar.\\n"
> "\\n"
> "Watch the STATUS BAR at the bottom. The UI stays interactive.\\n"
> "isPipInstallInProgress() will be printed to the console.",
> windowTitle="Demo 3b: Status Bar Mode",
> )
> 
> loop = qt.QEventLoop()
> \_result = \[None\]
> 
> def on\_complete(return\_code):
> \_result\[0\] = return\_code
> qt.QTimer.singleShot(0, loop.quit)
> 
> slicer.util.pip\_install(
> DEMO\_PACKAGE,
> blocking=False,
> show\_progress=True,
> requester="Feature Tour",
> completedCallback=on\_complete,
> )
> 
> # Check in-progress flag shortly after starting
> qt.QTimer.singleShot(500, lambda: print(
> f"\[Tour\] isPipInstallInProgress() = {slicer.packaging.isPipInstallInProgress()}"
> ))
> 
> loop.exec\_()
> 
> slicer.util.infoDisplay(
> f"Non-blocking install finished (return code {\_result\[0\]}).\\n"
> "The status bar showed pip output while the UI stayed responsive.",
> windowTitle="Demo 3b: Status Bar — done",
> )
> 
> 
> \# ---------------------------------------------------------------------------
> \# Demo 4 — Smart Install Workflow
> \# ---------------------------------------------------------------------------
> 
> def demo\_smart\_install():
> """pip\_ensure() — check, prompt, install, restart detection"""
> 
> # --- Part 1: Normal pip\_ensure ---
> fresh\_slate()
> 
> slicer.util.infoDisplay(
> "pip\_ensure() — the recommended high-level API for extensions.\\n"
> "Checks requirements, shows confirmation, installs with progress.\\n"
> "\\n"
> "You will see a confirmation dialog, then a progress dialog.",
> windowTitle="Demo 4a: pip\_ensure",
> )
> 
> reqs = \[Requirement(f"{DEMO\_PACKAGE}\>=0.20")\]
> slicer.packaging.pip\_ensure(reqs, requester="Feature Tour")
> 
> slicer.util.infoDisplay(
> "pip\_ensure done. Now we will call it again immediately.\\n"
> "\\n"
> "pip\_check sees the package is already installed, so\\n"
> "pip\_ensure skips everything — no dialogs, instant return.",
> windowTitle="Demo 4a: pip\_ensure — done",
> )
> 
> slicer.packaging.pip\_ensure(reqs, requester="Feature Tour (no-op)")
> 
> slicer.util.infoDisplay(
> "pip\_ensure returned instantly — nothing to install.\\n"
> "\\n"
> "Now: restart prompt demo. We will import the package, uninstall\\n"
> "it, then pip\_ensure again. Since it is in memory, a restart\\n"
> "dialog will appear. (Click NO when asked to restart to continue the tour.)",
> windowTitle="Demo 4a: pip\_ensure — no-op verified",
> )
> 
> # --- Part 2: Restart prompt ---
> import importlib
> mod = importlib.import\_module(DEMO\_IMPORT)
> print(f"\[Tour\] Imported {DEMO\_IMPORT} {mod.\_\_version\_\_}")
> 
> uninstall\_pkg()
> clear\_cache()
> 
> slicer.packaging.pip\_ensure(reqs, requester="Feature Tour (restart demo)")
> 
> slicer.util.infoDisplay(
> "Restart prompt demonstrated.\\n"
> "\\n"
> "This helps users know when a restart is needed after\\n"
> "updating packages that were already imported.",
> windowTitle="Demo 4b: Restart Prompt — done",
> )
> 
> 
> \# ---------------------------------------------------------------------------
> \# Demo 5 — Advanced Options
> \# ---------------------------------------------------------------------------
> 
> def demo\_advanced\_options():
> """Constraints file and skip\_packages"""
> 
> # --- Part 1: Constraints ---
> fresh\_slate()
> 
> slicer.util.infoDisplay(
> "Constraints file: limits which versions pip may install.\\n"
> f"\\n"
> f"Installing {DEMO\_PACKAGE} constrained to {DEMO\_CONSTRAINT}.",
> windowTitle="Demo 5a: Constraints",
> )
> 
> with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f:
> f.write(f"{DEMO\_PACKAGE}{DEMO\_CONSTRAINT}\\n")
> constraints\_path = f.name
> 
> slicer.util.pip\_install(
> DEMO\_PACKAGE,
> constraints=constraints\_path,
> requester="Feature Tour (constrained)",
> )
> os.unlink(constraints\_path)
> 
> ver = \_md.version(DEMO\_PACKAGE)
> slicer.util.infoDisplay(
> f"Installed {DEMO\_PACKAGE} {ver} (constrained to {DEMO\_CONSTRAINT}).\\n"
> "\\n"
> f"Now: skip\_packages demo — installing {DEMO\_PACKAGE}\\n"
> f"while skipping '{DEMO\_SKIP\_DEP}' (one of its dependencies).\\n"
> f"\\n"
> f"First we uninstall {DEMO\_SKIP\_DEP} (if present) so we can\\n"
> f"verify at the end that skip\_packages actually prevented it.",
> windowTitle="Demo 5a: Constraints — done",
> )
> 
> # --- Part 2: skip\_packages ---
> fresh\_slate()
> try:
> slicer.util.pip\_uninstall(DEMO\_SKIP\_DEP)
> except Exception:
> pass
> 
> skipped = slicer.util.pip\_install(
> DEMO\_PACKAGE,
> skip\_packages=\[DEMO\_SKIP\_DEP\],
> requester="Feature Tour (skip\_packages)",
> )
> 
> lines = \[f" {s}" for s in (skipped or \[\])\]
> print(f"\[Tour\] Skipped packages:\\n" + "\\n".join(lines))
> 
> # Verify the metadata scrub: re-install normally, check the skipped dep
> slicer.util.pip\_install(DEMO\_PACKAGE, requester="Feature Tour (verify scrub)")
> dep\_installed = slicer.packaging.pip\_check(Requirement(DEMO\_SKIP\_DEP))
> 
> slicer.util.infoDisplay(
> f"Skipped {len(skipped or \[\])} package(s).\\n"
> f"{DEMO\_SKIP\_DEP} installed after normal re-install: {dep\_installed}\\n"
> "\\n"
> "Re-installing normally did NOT pull in the skipped dependency\\n"
> "because the metadata scrub removed it.",
> windowTitle="Demo 5b: skip\_packages — verified",
> )
> 
> 
> \# ---------------------------------------------------------------------------
> \# Cleanup
> \# ---------------------------------------------------------------------------
> 
> def do\_cleanup():
> """Restore the user's environment."""
> if pkg\_version\_before\_tour is not None:
> msg = (
> f"{DEMO\_PACKAGE} {pkg\_version\_before\_tour} was installed before\\n"
> "the tour. Restore it now?"
> )
> else:
> msg = f"Uninstall {DEMO\_PACKAGE} to leave your environment clean?"
> 
> if slicer.util.confirmYesNoDisplay(msg, windowTitle="Feature Tour — Cleanup"):
> if pkg\_version\_before\_tour is not None:
> uninstall\_pkg()
> slicer.util.pip\_install(
> f"{DEMO\_PACKAGE}=={pkg\_version\_before\_tour}",
> requester="Feature Tour (restore)",
> )
> else:
> uninstall\_pkg()
> 
> slicer.util.infoDisplay(
> "Tour complete! For full API docs:\\n"
> " help(slicer.packaging.pip\_ensure)",
> windowTitle="Feature Tour — Done",
> )
> 
> 
> \# ---------------------------------------------------------------------------
> \# Menu and main loop
> \# ---------------------------------------------------------------------------
> 
> MENU\_ITEMS = \[
> "1. Loading Dependencies",
> "2. Checking Requirements",
> "3. Installing with Progress",
> "4. Smart Install Workflow",
> "5. Advanced Options",
> "---",
> "Run All",
> "Exit Tour",
> \]
> 
> DEMO\_FUNCS = {
> MENU\_ITEMS\[0\]: demo\_loading\_deps,
> MENU\_ITEMS\[1\]: demo\_checking\_reqs,
> MENU\_ITEMS\[2\]: demo\_install\_progress,
> MENU\_ITEMS\[3\]: demo\_smart\_install,
> MENU\_ITEMS\[4\]: demo\_advanced\_options,
> }
> 
> DEMO\_ORDER = MENU\_ITEMS\[:5\]
> 
> 
> def run\_tour():
> slicer.util.infoDisplay(
> "Welcome to the PR #9010 Feature Tour!\\n"
> "\\n"
> "Pick demos from the menu. Uses scikit-image as a demo package\\n"
> "(configurable via DEMO\_PACKAGE at the top of the script).\\n"
> 'Select "Run All" for the full experience.',
> windowTitle="Feature Tour — Welcome",
> )
> 
> while True:
> dialog = qt.QInputDialog(slicer.util.mainWindow())
> dialog.setWindowTitle("Feature Tour")
> dialog.setLabelText("Choose a demo:")
> dialog.setComboBoxItems(MENU\_ITEMS)
> dialog.setComboBoxEditable(False)
> 
> if dialog.exec\_() != qt.QDialog.Accepted:
> break
> 
> choice = dialog.textValue()
> 
> if choice == "Exit Tour" or choice == "---":
> break
> 
> if choice == "Run All":
> for key in DEMO\_ORDER:
> DEMO\_FUNCS\[key\]()
> else:
> func = DEMO\_FUNCS.get(choice)
> if func:
> func()
> 
> do\_cleanup()
> 
> 
> \# ---------------------------------------------------------------------------
> \# Start the tour
> \# ---------------------------------------------------------------------------
> 
> run\_tour()
> \`\`\`
> 
> \</details\>
> 
> \---
> 
> \<details\>
> \<summary\>\<strong\>References (click to expand)\</strong\>\</summary\>
> 
> \- This work is part of the \[44th Slicer project week\](https://projectweek.na-mic.org/PW44\_2026\_GranCanaria/Projects/PythonDependenciesInExtensions/)!
> \- \[#7171 — Improving Support for Python Package Dependencies in Slicer Extensions\](https://github.com/Slicer/Slicer/issues/7171) — This PR implements the "runtime installation" approach described in the issue: extensions declare dependencies via \`requirements.txt\`, and the new \`slicer.packaging\` module handles checking and installation with optional constraints file support for coordinating versions across extensions.
> \- \[#7707 — Allow scripted modules to declare and lazily install pip requirements\](https://github.com/Slicer/Slicer/issues/7707) — Proposes a \`LazyImportGroup\` context manager that intercepts imports and triggers installation on first attribute access. This PR takes a simpler, more explicit approach: developers call \`pip\_ensure()\` at the point dependencies are needed, then import normally. The explicit pattern trades some elegance for transparency and easier debugging. The \`LazyImportGroup\` approach could potentially be built on top of the primitives provided by \`slicer.packaging\` (\`load\_requirements\`, \`pip\_check\`, \`pip\_install\` with callbacks) if desired in the future. See #8181.
> \- At https://github.com/KitwareMedical/SlicerNNUnet/pull/21 is a demo of how some of the features here can simplify python dependency handling in the NNUnet extension.
> 
> \</details\>

---

<div class="post-metadata">

**Author:** ![lassoan](https://sea2.discourse-cdn.com/flex002/user_avatar/discourse.slicer.org/lassoan/32/13_2.png) [@lassoan](https://discourse.slicer.org/u/lassoan)\
**Post date:** [May 18, 2026, 4:32am UTC](https://discourse.slicer.org/t/slicer-packaging-library/47056/3 "2026-05-18T04:32:04Z")

</div>

I agree that it would be useful to advertise Slicer-related Kitware blog post here.

@ebrahim Please consider \posting a short summary here on the forum about this new feature, tagging it with `feature` so that we will find it when we create the release notes; and also to notify people who subscribed to learning about all new features. It is a developer-focused feature, but it is still worth letting people know.

---

<div class="post-metadata">

**Author:** ![muratmaga](https://sea2.discourse-cdn.com/flex002/user_avatar/discourse.slicer.org/muratmaga/32/3622_2.png) [@muratmaga](https://discourse.slicer.org/u/muratmaga)\
**Post date:** [May 18, 2026, 6:13am UTC](https://discourse.slicer.org/t/slicer-packaging-library/47056/4 "2026-05-18T06:13:14Z")

</div>

> [@pieper](#):
>
> . E.g. [here](https://discourse.slicer.org/t/2026-03-24-weekly-meeting/46543/3) and of course in the corresponding pull request thread, so people watching the repo would be notified.

I don’t watch Slicer repo as a I am not a core Slicer developer, and can’t attend the developer meetings due to time conflict, or review them. I don’t think I am unique amongst the extension maintainers. The point is this is a great feature, and would be useful and greatly appreciated exactly by people like me. It is shouldn’t be difficult to discover (Given the developer’s meeting data, it means I discovered it almost 8 weeks after it is integrated, and entirely by accident). I think this is what the announcement category is for.

---

<div class="post-metadata">

**Author:** ![pieper](https://sea2.discourse-cdn.com/flex002/user_avatar/discourse.slicer.org/pieper/32/8_2.png) [@pieper](https://discourse.slicer.org/u/pieper)\
**Post date:** [May 18, 2026, 7:35am UTC](https://discourse.slicer.org/t/slicer-packaging-library/47056/5 "2026-05-18T07:35:44Z")

</div>

I’m all for getting thie information out there. I mainly wanted to point out that I felt @ebrahim followed best practices well in this case, and there was plenty of notice for interested developers to discuss.

Cross-posting Slicer-related Kitware blog posts (or other posts, like linked in or whatever) is also a nice idea for people like me who don’t follow those closely.

---

<div class="post-metadata">

**Author:** ![ebrahim](https://sea2.discourse-cdn.com/flex002/user_avatar/discourse.slicer.org/ebrahim/32/13403_2.png) [@ebrahim](https://discourse.slicer.org/u/ebrahim)\
**Post date:** [May 18, 2026, 12:03pm UTC](https://discourse.slicer.org/t/slicer-packaging-library/47056/6 "2026-05-18T12:03:48Z")

</div>

I forgot to post an announcement to discourse, and by the time I remembered it felt like no longer fresh news 😅

I agree it should exist as an announcement; I will post a discourse announcement soon and link to the Kitware blog post for details as Steve suggested, ty!
