diff --git a/tutorials/holoscan-modules/README.md b/tutorials/holoscan-modules/README.md index 9771f8f..5af5d9a 100644 --- a/tutorials/holoscan-modules/README.md +++ b/tutorials/holoscan-modules/README.md @@ -17,17 +17,18 @@ Browse published modules in the [Modules tab](https://nvidia-holoscan.github.io/ ## Requirements - Holoscan SDK 5.0.0 or later with CUDA 13 -- CMake 4.0 or later and a C++20-capable compiler -- Python 3.12 through 3.14 for the `holoscan-cli==5.0.0` commands used by this walkthrough +- CMake 4.0 or later and a C++20-capable compiler for Modules with C++ operators +- Python 3.12 through 3.14 for the `holoscan-cli` commands used by these walkthroughs - Docker, Docker Buildx, and NVIDIA Container Toolkit for the recommended container workflow -SDK 5.0 Module operators are implemented in C++. The scaffold can add Python bindings so Python -applications can use them, but it does not generate Python-implemented operators or wheel packages. -The packaged artifact is a Debian package. +Module operators are implemented in C++ or Python. For C++ operators, the scaffold can add Python +bindings so Python applications can use them, and the packaged artifact is a Debian package. +From Holoscan CLI `5.1.0`, the bindings can also ship as a Python wheel, and +`holoscan create --language python` creates a pure-Python Module that ships as a Python wheel and +needs no C++ compiler. -The examples use `holoscan-cli==5.0.0` from PyPI, the version pinned by the current SDK 5 -template. CLI and SDK versions are independent: the scaffold uses CLI `5.0.0` while requiring -SDK `5.0.0` or later. See the +The examples install `holoscan-cli` from PyPI; each tutorial pins the CLI version it uses. CLI and +SDK versions are independent: the scaffold requires SDK `5.0.0` or later. See the [Holoscan CLI documentation](https://github.com/nvidia-holoscan/holoscan-cli) for configuration and integration details. diff --git a/tutorials/holoscan-modules/create-a-module/README.md b/tutorials/holoscan-modules/create-a-module/README.md index ae7ca73..7264b92 100644 --- a/tutorials/holoscan-modules/create-a-module/README.md +++ b/tutorials/holoscan-modules/create-a-module/README.md @@ -3,7 +3,8 @@ In this tutorial, we'll create a standalone **Holoscan Module** for Holoscan SDK 5.0. We'll start with an empty directory, build a reusable C++ operator, connect it to a finite example graph, optionally call it from Python, exercise it with deterministic tests, and finish with a Debian -package that another Holoscan project can install. +package that another Holoscan project can install. If you'd rather write the operators in Python, +[section 4.6](#46-implement-operators-in-pure-python) shows a pure-Python Module with no C++ code. By the end, you'll have an independently versioned repository and a repeatable development workflow. @@ -30,9 +31,11 @@ is not a reusable library does not need to become a Module. This tutorial creates a **standalone Module**: an independent `holoscan-` repository with its own build, tests, package, and CI. -In Holoscan SDK 5.0, Module operators are implemented in C++. The scaffold can also add Python bindings: a -small extension that registers the C++ operators with the SDK operator registry so Python -applications can use them. It does not generate Python-implemented operators or wheels. +Module operators are implemented in either C++ or Python. For C++ operators, the scaffold can +also add Python bindings: a small extension that registers the C++ operators with the SDK +operator registry so Python applications can use them, and that can ship as a Python wheel. For +Python operators, it creates a pure-Python Module that needs no C++ code or compiler and ships as a +Python wheel. ### Who creates a Holoscan Module? @@ -44,7 +47,8 @@ You may want to create one when: - you need a reproducible Debian package tied to a known Holoscan SDK version. In each case, the goal is the same: give consumers a stable CMake target and APT installation -experience without asking them to copy source files or understand your repository internals. +for C++ operators, or a `pip install` and `import holoscan.` for Python operators, without +asking them to copy source files or understand your repository internals. ### Why use the Module scaffold? @@ -88,8 +92,15 @@ pytest, and the Holoscan SDK Python package for the same SDK version, importable The SDK Debian packages don't include the Python package; the development container does. CMake downloads nanobind 3.0.1 while configuring when it isn't installed, which needs network access. -The CLI version does not select the SDK version. This tutorial pins Holoscan CLI `5.0.0` and -explicitly selects Holoscan SDK `5.0.0`. +A pure-Python Module ([section 4.6](#46-implement-operators-in-pure-python)) needs much less on +the host: Python 3.12 or later on Linux with glibc 2.39 or newer (for example Ubuntu 24.04), a +driver that supports CUDA 13, and CMake 3.24 or later for the CLI lifecycle commands. Its pip +dependencies install the Holoscan SDK `holoscan-core-cu13` wheel, so it needs no SDK Debian +package, CUDA Toolkit, compiler, or GoogleTest. + +The CLI version does not select the SDK version. This tutorial pins Holoscan CLI `5.1.0` and +explicitly selects Holoscan SDK `5.0.0`. Holoscan CLI `5.1.0` is the first release with the +pure-Python Module template. ## 3. Create an External Module @@ -108,7 +119,7 @@ interactive setup: ```bash python3.12 -m venv .cli-venv source .cli-venv/bin/activate -python -m pip install 'holoscan-cli[create]==5.0.0' +python -m pip install 'holoscan-cli[create]==5.1.0' holoscan create my-module # simplified command for interactive setup ``` @@ -118,14 +129,14 @@ write anything to disk, let's preview exactly what the CLI plans to create. With `uv`: ```bash -uvx --python 3.12 --from 'holoscan-cli[create]==5.0.0' \ +uvx --python 3.12 --from 'holoscan-cli[create]==5.1.0' \ holoscan create my-module \ --language cpp \ --interactive false \ --context holoscan_version=5.0.0 \ --dryrun -uvx --python 3.12 --from 'holoscan-cli[create]==5.0.0' \ +uvx --python 3.12 --from 'holoscan-cli[create]==5.1.0' \ holoscan create my-module \ --language cpp \ --interactive false \ @@ -160,8 +171,15 @@ To also use the operators from Python, pass `--language cpp python` instead. Thi bindings for the C++ operator, a Python version of the example, and a `pytest` suite; [section 4.5](#45-call-the-operator-from-python) walks through them. -Note that `--language python` on its -own is rejected because Holoscan SDK 5.0 does not yet support Python-implemented operators. +To write the operators in Python instead, pass `--language python`. This creates a pure-Python +Module with no C++ code; [section 4.6](#46-implement-operators-in-pure-python) walks through it. +A Module's operators are either all C++ or all Python: + +| `--language` | Operators | Use from Python | Package | +| --- | --- | --- | --- | +| `cpp` | C++ | No | Debian | +| `cpp python` | C++ | Through generated Python bindings | Debian, plus a wheel for the bindings | +| `python` | Python | Directly | Python wheel | ### 3.2 Set up the generated development environment @@ -170,14 +188,16 @@ Now that the repository exists, let's set up its development tools. The generate host and container commands on the same version. With `uv`: ```bash -uv run holoscan version --json -uv run holoscan list --json +uv run --only-group dev holoscan version --json +uv run --only-group dev holoscan list --json ``` The generated `pyproject.toml` selects NVIDIA's Python index for the CLI; the pip fallback uses the indexes in `requirements-cli.txt`. `pyproject.toml` has `package = false`: uv is provisioning the development environment, not building a Python version -of your Module. +of your Module. `--only-group dev` installs only the development tools. Without it, uv also +installs the runtime dependencies of a Module with Python operators or bindings, including the +`holoscan-core-cu13` wheel, which needs glibc 2.39 or newer. If `uv` is not available, create the same isolated environment with `pip`: @@ -190,7 +210,7 @@ holoscan list --json ``` The parent `.cli-venv` is only for project generation. From the generated Module root, use -`uv run` for later commands, or reactivate that Module's `.venv` if you used pip. +`uv run --only-group dev` for later commands, or reactivate that Module's `.venv` if you used pip. ### 3.3 Inspect the generated tree @@ -236,7 +256,38 @@ Key things to notice: Before publishing, update `metadata.json`, especially `source_repository`, authors, license, operator names, supported platforms, and `holoscan_sdk.minimum_required_version`. The generated metadata declares a C++ namespace and a Debian binary package; with Python bindings it also lists -`Python` and the `holoscan_my_module` Python namespace. +`Python` and the `holoscan.my_module` Python namespace. + +A Module created with `--language python` has no C++ sources, C++ tests, or Debian package +configuration. Its operators live in `operators/`. As for C++ operators, each one is gated on an +`OP_` CMake option, and the build tree and wheel install the enabled ones as the +`holoscan_my_module.operators` package. The CMake project compiles nothing; it stages the package +and runs the tests: + +```text +holoscan-my-module/ +├── metadata.json # language ["Python"], Python namespace, wheel package +├── pyproject.toml # Wheel build (scikit-build-core), CLI pin, ruff +├── CMakeLists.txt # CMake 3.24, no compiled languages +├── Dockerfile # SDK 5.0 CUDA 13 development image +├── python/ +│ ├── CMakeLists.txt # Stages holoscan_my_module in the build tree +│ └── holoscan_my_module/ # Package root (__init__.py, py.typed) +├── operators/ # Installed as holoscan_my_module.operators +│ ├── CMakeLists.txt # add_holohub_operator() per operator (OP_) +│ ├── __init__.py # Exports every enabled operator +│ └── my_module_op/ +│ ├── CMakeLists.txt # Stages the operator's Python sources +│ ├── __init__.py +│ ├── my_module_op.py # Typed Input/Output class attributes +│ └── metadata.json # Operator metadata (not packaged) +├── applications/ +│ └── my_module_pipeline/ +│ └── python/ # Graph → compile → run_async example +├── tests/ +│ └── python/ # OperatorTestHarness and pipeline tests +└── .github/workflows/ci.yml +``` ## 4. Implement, Build, and Test @@ -409,7 +460,7 @@ Python source and sink operators around the Module operator: ```python import holoscan as hs -from holoscan_my_module.operators import MyModuleOp +from holoscan.my_module.operators import MyModuleOp graph = hs.Graph("my_module_pipeline", default_clock=hs.RealtimeClock) source = graph.op(CounterSource, "source") @@ -447,7 +498,135 @@ PYTHONPATH=build/my_module_pipeline/python/lib:$PYTHONPATH \ For a native Python build, use `build-native/my_module_pipeline/python/lib` instead. -### 4.6 Build and test natively +### 4.6 Implement operators in pure Python + +If you generated the Module with `--language python`, its operators are Python classes under +`operators/` and there is no C++ to build. They use the same SDK 5 model as the +C++ operator: typed ports, a `compute()` method, and a graph that you compose, compile, and run. + +The generated operator in `operators/my_module_op/my_module_op.py` declares its ports +as class attributes and forwards each value: + +```python +import holoscan as hs + + +class MyModuleOp(hs.Operator): + """Forward each received value unchanged.""" + + inp = hs.Input("in", hs.payload.uint32) + out = hs.Output("out", hs.payload.uint32) + + def compute(self, context: hs.ExecutionContext) -> None: + self.out.emit(self.inp.receive_data()) +``` + +With one connected input and no `contract()` override, `compute()` runs once per received value. +Replace `hs.payload.uint32` with your payload type, and add parameters as `hs.Param` class +attributes. The example application composes the operator into a finite graph between a clocked +source and a counting sink: + +```python +import holoscan as hs +from holoscan.my_module.operators import MyModuleOp + +graph = hs.Graph("my_module_pipeline", default_clock=hs.RealtimeClock) +source = graph.op(CounterSource, "source") +forward = graph.op(MyModuleOp, "my_module_op") +sink = graph.op(CountingSink, "sink") +graph.add_flow(source.output, forward["in"]) # `in` is a Python keyword +graph.add_flow(forward.out, sink.input) + +plan = hs.compile(graph) +plan.require_no_errors() +``` + +Build, run, and test it with the same commands as section 4.3. The example has only a Python +variant, so `--language` isn't needed. `holoscan build` compiles nothing: it stages the package in +the build tree, and `holoscan run` puts that directory on `PYTHONPATH`. + +```bash +holoscan build my_module_pipeline --dryrun --verbose +holoscan build my_module_pipeline --verbose + +holoscan run my_module_pipeline --dryrun --verbose +holoscan run my_module_pipeline --verbose + +holoscan test --dryrun --verbose +holoscan test --verbose +``` + +The run prints `my_module_pipeline received 10 samples`. `holoscan test` runs the `tests/python` +pytest suite through CTest. The suite checks that `import holoscan.my_module` resolves to the +package, runs the example, and tests the operator on finite input with +`holoscan.testing.OperatorTestHarness`: + +```python +from holoscan.testing import OperatorTestHarness + + +def test_operator_forwards_each_value(): + with OperatorTestHarness(MyModuleOp) as harness: + harness.push_many("in", [1, 2, 3]) + result = harness.run() + assert result.values("out") == [1, 2, 3] +``` + +Replace the pass-through assertions with checks for your operator's real transformation. To add +an operator: + +1. Copy `operators/my_module_op/` to `operators//`: its `__init__.py` exports the class, and + its `CMakeLists.txt` calls `holoscan_my_module_add_python_operator()`. +2. Add `add_holohub_operator()` to `operators/CMakeLists.txt`, and + `cmake.define.OP_ = "ON"` to `[tool.scikit-build]` in `pyproject.toml` so the wheel ships + it. +3. Add it to the `if TYPE_CHECKING:` imports in `operators/__init__.py`, so type checkers and IDEs + resolve it. At runtime, `operators/__init__.py` exports every enabled operator. +4. Add it to `metadata.json:module.operator_names` and cover it in `tests/python`. + +To use the build tree from a Python shell or notebook, register it with a development hook from +the development container (`holoscan run-container`) or a host with the SDK wheels: + +```bash +holoscan install --dev --local +python3 -c "import holoscan.my_module; print(holoscan.my_module.__file__)" +holoscan install --dev --uninstall --local +``` + +The hook writes a `.pth` file that points at the staged package, so rebuilding picks up source +edits without reinstalling. + +You don't need the CLI to develop a pure-Python Module. On a host that meets the pure-Python +requirements in section 2, pip installs the Module in editable mode along with the SDK wheels it +depends on: + +```bash +python3 -m venv .venv-module +source .venv-module/bin/activate +python -m pip install "scikit-build-core~=1.0.3" cmake ninja pytest +python -m pip install --no-build-isolation -Ceditable.rebuild=true -e . +python -m pytest tests/python +``` + +The editable install rebuilds the enabled operators when the package is imported, so source edits +take effect without reinstalling. It needs `--no-build-isolation` so the rebuild can find CMake +and Ninja. + +When the operators are ready, build the wheel: + +```bash +holoscan package holoscan-my-module --pkg-generator WHEEL --dryrun --verbose +holoscan package holoscan-my-module --pkg-generator WHEEL --verbose +``` + +The wheel is written to `build/dist/holoscan_my_module-0.1.0-py3-none-any.whl`. It contains only +the Python package and the `holoscan.modules` entry point that makes `import holoscan.my_module` +work, and it depends on `holoscan-core-cu13`. If your operators import other SDK packages, add +`holoscan-vision-cu13`, `holoscan-visualization-cu13`, or `holoscan-inference-cu13` to +`[project].dependencies` in `pyproject.toml`. A pure-Python Module has no Debian package, so skip +section 4.9. + +### 4.7 Build and test natively The container is the easiest way to keep the toolchain reproducible. If your host already has a compatible SDK, CUDA toolkit, compiler, and CMake, you can use native mode for a faster local edit @@ -462,7 +641,7 @@ holoscan test --local --verbose unset HOLOSCAN_CLI_BUILD_PARENT_DIR ``` -The build goes to `build-native/my_module_pipeline`; CLI `5.0.0` tests with a separate dashboard +The build goes to `build-native/my_module_pipeline`; the CLI tests with a separate dashboard build in `build-/`. Select a specific SDK with `--local-sdk-root /path/to/sdk` or `HOLOSCAN_SDK_ROOT`. Holoscan SDK installs use architecture-qualified layouts such as `install-x86_64` and `install-aarch64`. @@ -475,7 +654,7 @@ cmake --build build -j"$(nproc)" ctest --test-dir build --output-on-failure -L unit ``` -### 4.7 (Optional) Declare dependencies on other Modules +### 4.8 (Optional) Declare dependencies on other Modules Modules become more useful when they compose cleanly. If this Module needs an operator from another Module, declare that relationship in `metadata.json:module.dependencies[]` and pin the @@ -504,11 +683,12 @@ lets a teammate or CI runner resolve the same source later. The [Use a Module tutorial](../use-a-module/README.md) shows how to override that pin with a local checkout during development without weakening the committed dependency. -### 4.8 Build the Debian package +### 4.9 Build the Debian package Now that the operator builds, its behavior is tested, and the demo exits cleanly, we're ready to -package it for another machine. Preview the packaging operation first, just as we did for the -development commands: +package it for another machine. This section applies to Modules with C++ operators; a pure-Python +Module ships a wheel instead (section 4.6). Preview the packaging operation first, just as we did +for the development commands: ```bash holoscan package holoscan-my-module --pkg-generator DEB --dryrun --verbose @@ -528,7 +708,7 @@ use it by appending its installation path to your local `PYTHONPATH` environment ```bash # First run the Python variant as shown in section 4.5. -cmake --install build/my_module_pipeline --component holoscan-my-module-python \ +cmake --install build/my_module_pipeline --component holoscan-my_module-python_libs \ --prefix "$PWD/build/install" PYTHONPATH="$PWD/build/install/lib/python3.12/site-packages:$PYTHONPATH" \ python3 -c "import holoscan_my_module; print(holoscan_my_module.__file__)" @@ -537,8 +717,20 @@ PYTHONPATH="$PWD/build/install/lib/python3.12/site-packages:$PYTHONPATH" \ Pass `--prefix` because the default `/usr/local` prefix isn't writable in the development container and isn't on Ubuntu's Python path. Run these commands inside the development container after building the application with Python bindings. A prefix inside `build/` stays out of Git. -Replace `python3.12` if you use a different Python version. Module projects based on Holoscan -SDK 5.0 do not fully support building Python wheels for distribution yet. +Replace `python3.12` if you use a different Python version. + +To ship the bindings to Python users instead, build them into a wheel: + +```bash +holoscan package holoscan-my-module --pkg-generator WHEEL --dryrun --verbose +holoscan package holoscan-my-module --pkg-generator WHEEL --verbose +``` + +scikit-build-core builds only the operator and its bindings, configured in `pyproject.toml`, and +writes `build/dist/holoscan_my_module-0.1.0-cp312-abi3-linux_x86_64.whl`. The wheel works with +Python 3.12 or later on the architecture it was built on, depends on `holoscan-core-cu13`, and +declares the `holoscan.modules` entry point, so `import holoscan.my_module` works after +`pip install`. The final proof is installation in a clean consumer where the Holoscan Debian dependency is available. Extracting the archive may be useful for inspection, but it does not prove that APT can @@ -550,7 +742,7 @@ package database. That alone does not satisfy the generated `Depends: holoscan ( Configure a repository that supplies the required SDK 5 Debian package before testing APT installation, and check its candidate version with `apt-cache policy holoscan`. -### 4.9 Configure shared defaults +### 4.10 Configure shared defaults Most Modules can begin with metadata-derived defaults. When your whole team needs the same CUDA selection, forwarded variable, Docker option, or base image, put that shared choice in @@ -572,7 +764,7 @@ so mount any referenced host path separately. Use exact image references that yo tested, and use `--dryrun --verbose` whenever you need to see which setting came from metadata, project configuration, the environment, or the command line. -### 4.10 Review the generated CI +### 4.11 Review the generated CI The scaffold also gives you a starting CI workflow in `.github/workflows/ci.yml`. It separates fast feedback from hardware-backed validation: @@ -583,20 +775,28 @@ fast feedback from hardware-backed validation: | CMake configure | Holoscan SDK container on a CPU runner | the generated CMake graph without building or using a GPU | | Build and test | self-hosted GPU | the C++ build and CTest suite in the SDK container | +A pure-Python Module's workflow has no C++ formatting or CMake configure job. Instead, it lints +the Python sources and metadata, builds the wheel on a CPU runner, and runs the pytest suite +through CTest in the SDK container on a GPU runner. + Treat this workflow as a useful baseline, not a finished release gate. Update the runner labels and image for your infrastructure, keep the CLI constraints synchronized, and add hardware or end-to-end coverage for the behavior your Module promises. -### 4.11 Publish and share +### 4.12 Publish and share Your Module is now ready to move from a development repository into a release process. Before you share it: 1. Push the canonical repository and set `metadata.json:module.source_repository` to its URL. 2. Replace placeholder authors, license, description, and supported-platform values. -3. Publish the inspected DEB to the APT repository used by your consumers. -4. Make `binary_packages.debian` and its install command match the package users can actually - obtain. +3. Publish the inspected DEB to the APT repository used by your consumers. Publish a pure-Python + Module's `py3-none-any` wheel to a Python package index such as [PyPI](https://pypi.org/). + The Python bindings wheel is tagged `linux_`, which PyPI rejects. Publish it to an index + that accepts that tag, or build it in a `manylinux` environment and repair it to a `manylinux` + tag first. +4. Make `binary_packages.debian` or `binary_packages.wheel` and the install commands match the + package users can actually obtain. 5. Document the supported SDK versions and the finite example that consumers can run after installation. @@ -614,22 +814,31 @@ Once you are familiar with the workflow, use this table as a quick reminder: | Inspect projects | `holoscan list --json` | | Inspect environment | `holoscan env-info --json` / `holoscan env-check --json` | | Add Python bindings | `holoscan create --language cpp python ...` | +| Create a pure-Python Module | `holoscan create --language python ...` | | Build demo | `holoscan build _pipeline --language cpp` | | Run finite demo | `holoscan run _pipeline --language cpp` | | Run Python demo | `holoscan run _pipeline --language python` | | Run CTest | `holoscan test` | | Package | `holoscan package holoscan- --pkg-generator DEB` | -| Install Python package | Build the Python variant, then `cmake --install build/_pipeline --component holoscan--python --prefix ` | +| Package a Python wheel | `holoscan package holoscan- --pkg-generator WHEEL` | +| Use the build tree from Python | `holoscan install --dev --local` | +| Install Python package | Build the Python variant, then `cmake --install build/_pipeline --component holoscan--python_libs --prefix ` | | Use an explicit root | `holoscan --project-root /path/to/module ` | ## 6. Troubleshooting -### The CLI rejects `--language python` or WHEEL +### The CLI rejects `--language python` + +Holoscan CLI `5.0.0` doesn't include the pure-Python Module template. Check +`holoscan version` and install CLI `5.1.0` or later. Use a CLI 4.x release for an SDK 4 Module. + +For a Module with C++ operators, use `--language cpp python` to call them from Python, and +install the Python package with `cmake --install` or build a wheel as shown in section 4.9. + +### pip can't find a `holoscan-core-cu13` wheel -SDK 5.0 Module operators are implemented in C++. Use `--language cpp python` to call them from -Python, and install the Python package with `cmake --install` as shown in section 4.8. -Python-implemented operators and wheels aren't supported by the SDK 5 template yet. Use a CLI 4.x -release for an SDK 4 Module. +The Holoscan SDK 5 wheels need Python 3.12 or later on x86_64 or aarch64 Linux with glibc 2.39 or +newer, for example Ubuntu 24.04. On an older host, use the development container instead. ### `import holoscan` fails after setting `PYTHONPATH` diff --git a/tutorials/holoscan-modules/create-a-module/metadata.json b/tutorials/holoscan-modules/create-a-module/metadata.json index 26c0ac5..2795bc4 100644 --- a/tutorials/holoscan-modules/create-a-module/metadata.json +++ b/tutorials/holoscan-modules/create-a-module/metadata.json @@ -10,8 +10,9 @@ } ], "language": ["C++", "Python"], - "version": "0.3.1", + "version": "0.4.0", "changelog": { + "0.4.0": "Add the pure-Python Module workflow (--language python) and wheel packaging from Holoscan CLI 5.1.0", "0.3.1": "Verify CLI 5.0.0 workflows and clarify image prerequisites, Python build paths, and native CTest behavior", "0.3.0": "Add the optional Python bindings workflow for SDK 5.0 Modules", "0.2.0": "Update the standalone Module workflow for the Holoscan SDK 5.0 C++ API and Debian packaging",