HPM Documentation
HPM (Houdini Package Manager) is a Rust-based package manager for SideFX
Houdini. It manages both Houdini packages and their Python dependencies,
produces reproducible installs with a lock file and SHA-256 checksums, and
generates the package.json files Houdini needs to load packages at launch.
User documentation
Published to hpm.readthedocs.io:
- User guide — install, commands, the
hpm.tomlmanifest, global configuration, troubleshooting. - Python dependencies —
[python_dependencies], Houdini-to-Python version mapping, venv sharing, cleanup. - Registries — configuring API and Git registries, per-user vs per-project, search and caching.
- Security — checksums, package signing,
hpm audit, threat model.
Contributor documentation
In-repo, not published:
- Architecture — system design, dependency resolution, cleanup, Python integration.
- API overview — crate structure and key public types. Full rustdoc via
cargo doc. - Testing guide — property-based testing strategy.
- CONTRIBUTING.md — development setup, workflow, pull request guidelines.
User Guide
HPM is a Rust-based package manager for SideFX Houdini. It manages both HPM
packages (Houdini tools, HDAs, scripts, shelf tools, toolbars, etc.) and the
Python dependencies those packages need, and it produces the Houdini
package.json files that let Houdini find them at launch.
This guide covers installation, the command surface, the hpm.toml manifest,
global configuration, and troubleshooting.
Table of contents
- Install
- First package
- Registries
- Command reference
- The
hpm.tomlmanifest - Global configuration
- Storage layout
- Houdini integration
- Output formats and automation
- Troubleshooting
Install
Prerequisites
- SideFX Houdini 20.5, 21.x, or 22.x (for integration — HPM itself runs fine without it).
- Rust 1.85+ if building from source.
- Git (optional; used by
hpm init --vcs git).
From a pre-built binary
Download the binary for your platform from the
latest release and put it on
your PATH. Verify with:
hpm --version
From source
git clone https://github.com/3db-dk/hpm.git
cd hpm
cargo build --release
# binary: target/release/hpm
Shell completions
# Bash (add to ~/.bashrc)
eval "$(hpm completions bash)"
# Zsh (add to ~/.zshrc)
eval "$(hpm completions zsh)"
# Fish
hpm completions fish | source
# PowerShell (add to $PROFILE)
hpm completions powershell | Out-String | Invoke-Expression
Supported shells: bash, zsh, fish, powershell, elvish.
First package
hpm init my-first-package --description "My first Houdini package"
cd my-first-package
The standard template creates this layout:
my-first-package/
├── hpm.toml # HPM manifest
├── README.md
├── otls/ # HDAs / .otl files
├── python/ # Python modules (python/__init__.py is pre-created)
├── scripts/ # Shelf tools and script hooks
├── presets/ # Node presets
├── config/ # Configuration files
└── tests/ # Test files
Pass --bare for just hpm.toml and README.md — use this when you have a
custom layout or are wrapping an existing codebase.
A typical workflow
hpm init my-tools # 1. scaffold
hpm add some-creator/utility-nodes@1.0.0 # 2. add deps
hpm add local-tools --path ../local-tools
hpm install # 3. resolve & install
hpm check # 4. sanity check
hpm list --tree # 5. inspect
Then wire Houdini up — see Houdini integration.
Registries
HPM resolves package names through one or more registries. A registry is
either an HTTP API endpoint or a Git repository serving a Cargo-style index.
Without at least one registry configured, hpm search and hpm add <name>@<version>
have nowhere to look.
# API registry (auto-detected by URL)
hpm registry add https://api.3db.dk/v1/registry --name houdinihub
# Git-index registry (auto-detected by .git suffix or host)
hpm registry add https://github.com/studio/hpm-packages.git --name studio --type git
hpm registry list
hpm registry update # refresh caches
hpm registry remove studio
Global registries live in ~/.hpm/config.toml under [[registries]]. A
project can also declare registries in its own hpm.toml under
[[registries]] — handy for studios that want each project to pin the
registries it resolves against. See [[registries]] below.
A dependency can target a specific registry:
[dependencies]
studio-tools = { version = "1.0.0", registry = "studio" }
Without registry, HPM resolves through every configured registry in order.
Command reference
Global options
Available on every command:
| Option | Description |
|---|---|
-v, --verbose | Increase verbosity (repeat for more detail). |
-q, --quiet | Suppress non-error output. |
--color <when> | auto, always, or never. |
--output <format> | human (default), json, json-lines, json-compact. |
-C, --directory <dir> | Run as if invoked from <dir>. |
hpm init
Create a new HPM package.
hpm init [OPTIONS] [NAME]
Options
| Flag | Default | Description |
|---|---|---|
--description <text> | — | Package description. |
--author <name> | git config user.* if set | Author ("Name <email>"). |
--version <v> | 0.1.0 | Initial version. |
--license <id> | MIT | License identifier. |
--houdini <range> | ^21 | [compat].houdini Cargo-style range (e.g. "^21", ">=20.5, <22", ">=21"). Default is bounded to a single Houdini major — see [compat] for why. |
--bare | off | Skip standard directories; create only hpm.toml and README.md. |
--vcs <vcs> | git | git or none. |
Examples
hpm init my-tools
hpm init --bare minimal
hpm init advanced-tools \
--description "Advanced geometry tools" \
--author "Artist <artist@studio.com>" \
--license Apache-2.0 \
--houdini ">=20.5, <22"
hpm add
Add one or more dependencies to hpm.toml.
hpm add [OPTIONS] <PACKAGE>...
<PACKAGE> is either a bare name (resolved from registries at install time)
or name@version. name uses the creator/slug form.
Options
| Flag | Description |
|---|---|
--path <dir> | Add as a local path dependency (only valid with a single package). Path dependencies install into a _dev/ subtree of the global packages dir so they never overwrite a registry install at the same (slug, version). |
--link | For path dependencies, install as a symlink (Unix) or NTFS junction (Windows) instead of copying. Working-tree edits become visible to a live Houdini session without re-running hpm install. Requires --path. |
-p, --package <path> | Path to the manifest to modify (hpm.toml or containing dir). Defaults to cwd. |
--optional | Mark all added dependencies as optional. |
Examples
hpm add studio/utility-nodes@1.0.0
hpm add studio/a@1.0.0 studio/b@2.0.0
hpm add local-tools --path ../local-tools
hpm add local-tools --path ../local-tools --link # live edits → Houdini
hpm add studio/visualize@1.0.0 --optional
hpm add studio/lib@1.0.0 -p /path/to/project
hpm remove
Remove a dependency from hpm.toml. This does not delete package files
from ~/.hpm/packages/ — run hpm clean for that.
hpm remove [OPTIONS] <PACKAGE>
| Flag | Description |
|---|---|
-p, --package <path> | Manifest to modify. |
hpm install
Resolve and install every dependency declared in hpm.toml.
hpm install [OPTIONS]
Install does the following:
- Loads
hpm.toml. - If
hpm.lockexists, verifies cached packages against stored checksums and warns if the lock is older than 90 days. - Resolves HPM dependencies through configured registries and downloads anything missing to
~/.hpm/packages/. - Collects Python dependencies from the root manifest and every installed dependency’s manifest, downloads a managed CPython matching the lower bound of the root manifest’s
[compat].houdinito~/.hpm/uv-python/(no-op if already present), resolves them with the bundleduv, and installs them into a content-addressable venv in~/.hpm/venvs/<hash>/. - Writes one Houdini manifest per installed dependency to
<project>/.hpm/packages/{name}.json. - Writes or updates
hpm.lock.
Options
| Flag | Description |
|---|---|
-m, --manifest <path> | Path to hpm.toml (or its containing directory). |
--frozen-lockfile | Fail if hpm.lock is missing or would need to change. Use in CI. |
hpm update
Update dependencies to their latest compatible versions.
hpm update [OPTIONS] [PACKAGES]...
With no packages, updates all. With specific packages, updates only those.
| Flag | Description |
|---|---|
-p, --package <path> | Manifest to operate on. |
--dry-run | Print the proposed plan without applying it. |
-y, --yes | Skip the confirmation prompt. |
Examples
hpm update --dry-run
hpm update
hpm update studio/geometry-tools
hpm update --yes --output json # CI-friendly
hpm list
Display installed dependencies and their metadata.
hpm list [OPTIONS]
| Flag | Description |
|---|---|
-p, --package <path> | Manifest to read. |
--tree | Render dependencies as a tree. |
hpm check
Validate hpm.toml and the surrounding project.
hpm check
Check runs:
hpm.tomlexists, parses, and passes manifest validation (scopedcreator/slugpath, semver version,[compat].houdiniparseable,[stage]per-platform consistency with[compat].platforms).- Generated Houdini
package.jsonserializes cleanly. - Soft warnings for: missing description, missing authors, missing keywords, missing
[compat].houdini, missing README or license file, missing.gitignorewhen a.gitdirectory is present, packages larger than 100 MB, and individual files larger than 10 MB.
Check is advisory — warnings do not fail the command.
hpm migrate
Rewrite a pre-0.16 (Manifest 1.x) hpm.toml to the current schema.
hpm migrate [OPTIONS]
| Option | Description |
|---|---|
-p, --package <PATH> | Path to hpm.toml or its directory (defaults to the current directory). |
--stdout | Print the migrated manifest to stdout instead of writing it. |
--check | Only report whether migration is needed; exit non-zero if so and write nothing. Useful as a CI gate. |
The 0.16.0 “Manifest 2.0” refactor renamed and reshaped five sections
([houdini] -> [compat].houdini, [env] + [dev.env] -> [runtime],
[native] -> [compat].platforms + [stage], [scripts.platform.<os>]
-> conditional cmd). Old-format manifests are still read
automatically — every command converts them on load and prints a
deprecation warning — but that compatibility is removed in 0.20.0, so
migrate before then.
Behaviour of the default (no-flag) form:
- Backs the original up as
hpm.toml.bak, then writes the converted manifest in place with a comment header recording the migration. - The
[native]->[stage]conversion is best-effort: the oldfilesentries were include-filters, while[stage.platform].placerules need a destination, so each derivedtopath is flagged for review (in the terminal and in the file header). Verify them — the guess is correct for the commondso/<plat>/*layout but not for relocating layouts.
hpm run
Execute a script defined in the manifest’s [scripts] table.
hpm run <SCRIPT> [-- ARGS...]
| Argument | Description |
|---|---|
<SCRIPT> | Name of the entry under [scripts]. |
ARGS... | Trailing arguments forwarded to the script verbatim, after shell-quoting. |
Behaviour:
- Looks up the named entry; if its
cmdis a conditional list, the first variant whosewhen.osmatches the host wins. Plain entries always match. - Sets
HPM_PACKAGE_ROOTto the manifest directory and runs the command from that directory through the host shell (sh -con Unix,cmd /Con Windows). - For table-form entries with
pythonorrequirements, materializes a uv-managed venv at~/.hpm/venvs/<hash>/, prepends itsbin/(orScripts/on Windows) toPATH, and setsVIRTUAL_ENV. Two scripts whosepython+requirementsresolve to the same closure share one venv on disk. - The script’s exit code becomes
hpm’s exit code, sohpm runis safe to chain in CI or wrap in a Houdini hook.
Example
[scripts.tt_setup]
cmd = "python scripts/tt_setup.py"
python = "3.11"
requirements = ["PySide6>=6.6"]
hpm run tt_setup --project /path/to/project
hpm search
Search every configured registry for packages matching a query.
hpm search <QUERY>
If no registries are configured, HPM prints instructions to add one.
hpm build
Materialise the install image into a directory. Runs [stage].prepack
scripts (compile DSO, collapse expanded HDAs, etc.), then copies workspace
files into the output directory using the same include/exclude/place
rules hpm pack would apply. The result is what a registry consumer’s
install would look like.
hpm build [OPTIONS]
hpm build is a one-shot CLI verb — it copies files and exits, with no
background watcher. The output directory is yours to manage; common
patterns:
- Single workstation, one Houdini at a time: leave the default
[stage].output_dir(dist/), point Houdini at it, rerunhpm buildwhenever you want a refresh. - Multiple Houdini sessions in parallel: pass
--output <tmpdir>per session and have each session’sHOUDINI_PACKAGE_PATHreference its own staging directory. Avoids cross-session DSO lock conflicts.
Options
| Flag | Description |
|---|---|
-m, --manifest <path> | Path to hpm.toml or containing dir. Defaults to cwd. |
-o, --output <dir> | Override [stage].output_dir. Relative paths resolve against the manifest dir; absolute paths are used verbatim. |
--platform <id> | Target platform. Defaults to host when [compat].platforms is declared. Required when host is not in the declared list. |
--no-prepack | Skip [stage].prepack scripts. Use in CI when build steps already ran out-of-band. |
--no-clean | Keep existing output-dir contents instead of wiping first. |
Workflow notes — live editing and DSO rebuild
These are user-level concerns; HPM doesn’t model them in the manifest:
- HDA editing. Edits made inside Houdini save back to whatever path
Houdini loaded the HDA from. If you load from
dist/otls/foo.hda(collapsed byhpm build), saves go into the build output and get clobbered on the nexthpm build. If you want round-trip editing, point Houdini at an unstaged expanded HDA dir during dev, and runhpm buildonly when you want to produce the publishable form. - DSO rebuild while Houdini is loaded. On Windows, a loaded
.dllis locked. With--output <tmpdir-A>for session A and--output <tmpdir-B>for session B, yourcmake --build(writing tobuild/<plat>/) doesn’t hit either lock, and a freshhpm build --output <tmpdir-C>writes to a third location — you only hit the lock when you try to rebuild into a directory a live Houdini still has loaded. The typical workflow is one temp dir per Houdini lifetime, thrown away on Houdini close.
hpm pack
Build a distributable archive from the current package.
hpm pack [OPTIONS]
Pack runs hpm check first, then:
- Auto-generates a Houdini-native
{slug}.jsoninside the archive unless the user has provided one. This file follows Houdini’s own package format, so the archive is usable by Houdini even without HPM. - Filters files by
[stage](per-platformplacerules andinclude/excludeglobs) when the manifest declares[compat].platforms. - Produces a
.ziparchive plus a SHA-256 checksum. - If a signing key is supplied, produces an Ed25519 signature over the archive bytes and emits a
keyId.
Options
| Flag | Description |
|---|---|
--key <path> | Ed25519 PKCS#8 PEM private key. Overrides HPM_SIGNING_KEY. |
--output <dir> | Output directory. Defaults to the current directory. |
--json | Emit the result as JSON (useful in CI). |
--platform <id> | Override host-platform detection. Valid: linux-x86_64, linux-aarch64, macos-x86_64, macos-aarch64, windows-x86_64, windows-aarch64, universal. Only legal when [compat].platforms is declared. |
Signing key resolution order
--key <path>(CLI flag).HPM_SIGNING_KEYenvironment variable. If its value starts with-----BEGIN, it’s treated as inline PEM; otherwise it’s a path.[signing].key_pathin~/.hpm/config.toml.
Generating a signing key
openssl genpkey -algorithm ed25519 -out signing.pem
openssl pkey -in signing.pem -pubout -out signing.pub.pem
Keep signing.pem secret. Publish signing.pub.pem so consumers can verify.
See Security for the wire format.
hpm audit
Run a security audit on the current project.
hpm audit [OPTIONS]
| Flag | Description |
|---|---|
-m, --manifest <path> | Manifest to audit. |
Audit checks:
- HTTP URLs — flags any
url = ...dependency whose URL ishttp://rather thanhttps://. - Lock file presence — warns if
hpm.lockis missing. - Lock file staleness — warns if
hpm.lockis older than 90 days. - Checksum verification — verifies every cached package in
~/.hpm/packages/matches the checksum stored inhpm.lock.
See Security for more.
hpm clean
Remove orphaned packages, dev (path-dep) installs, and/or venvs that no active project depends on.
hpm clean [OPTIONS]
| Flag | Description |
|---|---|
-n, --dry-run | Print what would be removed, without touching anything. |
-y, --yes | Skip confirmation. |
--python-only | Clean only orphaned venvs. |
--comprehensive | Clean packages, dev installs, and venvs. |
HPM identifies active projects via [projects] in ~/.hpm/config.toml
(explicit_paths plus recursive search_roots). Three classes of artifact
are considered:
- Registry/URL packages in
~/.hpm/packages/<slug>@<version>/: preserved if reachable from any active project’s dependency graph. - Dev installs in
~/.hpm/packages/_dev/<slug>@<version>/(created by{ path = "..." }deps, copy or link mode): preserved if any active project’s path-dep source manifest reports that(slug, version). Entries are listed as_dev/<slug>@<version>so the source of each is obvious. Link installs are unlinked safely — never followed. - Python venvs under
~/.hpm/venvs/: preserved if any kept package declares matching[python_dependencies]. Removed only when--python-onlyor--comprehensiveis set.
A project whose path-dep source can’t be read (workspace moved or
deleted) logs a warning and doesn’t block cleanup of other dev installs;
re-run hpm install after fixing the path to reinstate anything that was
swept.
hpm registry
Manage registries in ~/.hpm/config.toml. See Registries.
hpm registry add <URL> [--name <alias>] [--type api|git]
hpm registry list
hpm registry remove <NAME>
hpm registry update
If --type is omitted, HPM infers it: URLs ending in .git or hosted on
github.com / gitea.* are treated as git; everything else is api.
hpm completions
Emit shell completion scripts — see Install.
The hpm.toml manifest
A minimal manifest:
[package]
path = "my-studio/my-tools"
name = "My Tools"
version = "1.0.0"
All sections, in the order they appear in practice:
[package]
| Field | Required | Description |
|---|---|---|
path | yes | Scoped identifier, creator/slug. Both segments must be kebab-case (lowercase letters, digits, hyphens). Example: tumblehead/tumble-rig. |
name | yes | Freeform display name. |
version | yes | Semantic version per semver.org. major.minor.patch is required; pre-release identifiers (1.0.0-alpha.1, 1.0.0-rc.2) and build metadata (1.0.0+build.5) are accepted. |
description | no | Short description. |
authors | no | List of "Name <email>" strings. |
license | no | License identifier (e.g. MIT, Apache-2.0). |
readme | no | Path to README, relative to the package. Defaults to README.md for init-generated packages. |
homepage | no | Project homepage URL. |
repository | no | Repository URL. |
documentation | no | Documentation URL. |
keywords | no | List of keywords for discovery. |
categories | no | List of categories. |
[compat]
Target-environment compatibility for the package. Two axes:
houdini— a Cargo-style version requirement string.platforms— the native platforms this package supports. Omit (or use["universal"]) for pure-data / pure-Python packages; list the platforms the package ships binaries for otherwise.
[compat]
houdini = "^21" # default — Houdini 21.x only
# houdini = ">=20.5, <22" # explicit range
# houdini = "~21.5" # tilde: >=21.5, <21.6
# houdini = "21" # bare = caret = ^21
# houdini = ">=20.5" # unbounded above — only safe for pure-data
platforms = ["linux-x86_64", "macos-aarch64"] # omit for pure-data
The supported operators are =, >=, >, <=, <, ^, ~, and the
bare-version shorthand (aliases caret). Multiple comparators combine with
and when separated by commas. The same grammar is reused inside
[runtime] conditional values (when = { houdini = "^21" }).
The lower bound of [compat].houdini on the root manifest drives the
bundled Python version. A dependency package’s range is a compatibility
floor only and does not influence the venv ABI. See Python guide.
Why the default is bounded above
Houdini’s binary compatibility doesn’t survive a major-version bump.
A DSO compiled against the Houdini 21 SDK won’t load in Houdini 22;
some Python module signatures shift between majors too. The init
template defaults to houdini = "^21" (Houdini 21.x only) for that
reason — authors who ship binaries get a safe starting point, and
authors of pure-data / pure-Python packages can widen the range
explicitly after testing on the next major.
hpm check warns when [compat].platforms is non-empty but
[compat].houdini is unbounded above (e.g. ">=21"). That catches
the failure mode where a native-binary package installs cleanly on a
newer Houdini and then crashes at load.
[dependencies]
HPM package dependencies. Keys are scoped creator/slug paths. Values take
one of four shapes:
[dependencies]
# 1. Registry, version-only (shorthand)
"studio/utility-nodes" = "1.0.0"
# 2. Registry with options
"studio/material-library" = { version = "2.0.0", optional = true }
"studio/internal-tool" = { version = "1.0.0", registry = "studio" }
# 3. Direct URL (pre-built archive — ZIP or gzipped tar both accepted)
"studio/prebuilt" = { url = "https://pkg.example.com/prebuilt-1.0.0.zip", version = "1.0.0" }
# 4. Local path (development)
local-tools = { path = "../local-tools" }
local-tools = { path = "../local-tools", optional = true }
# 5. Local path, installed as a symlink/junction (live edits)
# Working-tree edits become visible to a live Houdini session without
# re-running `hpm install`. Otherwise identical to (4): same _dev/ namespace
# isolation, no effect on registry installs at the same coordinate.
local-tools = { path = "../local-tools", link = true }
The lock file (hpm.lock) records the resolved version and SHA-256 checksum
for each dependency, so subsequent installs are reproducible and tamper-evident.
[python_dependencies]
Python packages, installed through the bundled uv into a shared venv:
[python_dependencies]
# Version constraint shorthand
numpy = ">=1.20.0"
# Detailed form
requests = { version = ">=2.25.0", extras = ["security", "socks"] }
matplotlib = { version = "^3.5.0", optional = true }
Version constraints use PEP 440 syntax (same as pip/uv). See Python guide for the Houdini→Python version mapping and venv sharing behavior.
[runtime]
Environment variables to set when Houdini loads the package. The key is
the variable name; the value is a { method, value } pair:
[runtime]
MY_PLUGIN_ROOT = { method = "set", value = "$HPM_PACKAGE_ROOT/config" }
HOUDINI_TOOLBAR_PATH = { method = "prepend", value = "$HPM_PACKAGE_ROOT/toolbar" }
HOUDINI_AUDIT_LOG = { method = "append", value = "$HPM_PACKAGE_ROOT/logs/audit" }
| Method | Effect |
|---|---|
set | Replace the variable. |
prepend | Prepend to the existing variable (Houdini picks the platform separator). |
append | Append to the existing variable. |
Use $HPM_PACKAGE_ROOT to refer to the installed package directory. HPM
merges these entries with its built-in PYTHONPATH and HOUDINI_SCRIPT_PATH
entries when generating the Houdini manifest.
Required env vars
A package can declare an env var as required without giving it a value.
Any project that depends on the package must then supply the value in its
own [runtime] section in hpm.toml. hpm install (and project sync)
errors out otherwise — the package isn’t launchable without it.
# In the package's hpm.toml
[runtime]
PROJECT_ASSETS = { method = "set", required = true }
# In the consuming project's hpm.toml
[runtime]
PROJECT_ASSETS = { method = "set", value = "/mnt/studio/assets" }
required = true may be combined with a value; the value then acts as
a default and the project override becomes optional. Without a value, the
entry is a hard placeholder.
A consuming project can also override any package-declared env var by
re-declaring the same key in its own [runtime]. How the project entry
combines with the package’s depends on the project entry’s method:
Project method | Result |
|---|---|
set | Replaces the package’s contribution wholesale — only the project’s value is emitted. |
prepend / append | Extends it — the package’s own entry is emitted first, then the project’s, so Houdini merges both in load order with the requested method. |
So a project append/prepend adds to a package-provided value (e.g.
extending a package’s PYTHONPATH) rather than clobbering it. Use set
when you genuinely want to replace what the package contributes.
Conditional values
value accepts either a flat string or an ordered list of { when, set }
variants. The variants are selected against four axes; the first match
wins per the rules below.
[runtime.PXR_PLUGINPATH_NAME]
method = "prepend"
value = [
{ when = { houdini = "^21" }, set = "$HPM_PACKAGE_ROOT/resolver/houdini21/r" },
{ when = { houdini = "^22" }, set = "$HPM_PACKAGE_ROOT/resolver/houdini22/r" },
]
| Field | Form | Evaluated by | Compiles to |
|---|---|---|---|
houdini | Cargo-style req: "^21", "~21.5", ">=21, <22.5", "21" (alias for ^21) | Houdini at startup | houdini_version >= 'X' and houdini_version < 'Y' |
os | "linux", "macos", "windows" | Houdini at startup | houdini_os == '<os>' |
python | "3.11", "python3.10", etc. | Houdini at startup | houdini_python == 'python<v>' |
install_source | "dev" (path dependency) or "registry" (registry/URL install) | hpm at install time | filtered out before emission |
The first three axes lower into Houdini’s package.json expression form
per https://www.sidefx.com/docs/houdini/ref/plugins.html. install_source
is install-time evaluated by hpm — variants gated to a non-matching
install source are dropped before the Houdini package.json is written, so
a "dev" branch never ships to a registry consumer’s manifest and a
"registry" branch never fires in the dev’s own Houdini.
All present axes combine with and within a single when. Order matters:
Houdini picks the first matching branch. An empty when = {} is the
always-true fallback and should appear last. $HPM_PACKAGE_ROOT is
substituted in each branch, just like in flat values; any other $VAR
(e.g. $HOUDINI_MAJOR_RELEASE) passes through verbatim so Houdini’s own
variable expansion handles it.
Malformed selectors fail at manifest validation time, so authors find them before publish, not at install.
HDK plugin pattern (dev-only paths)
The canonical use of install_source is HDK plugin development: a
build-tree path that must reach the dev’s own Houdini but never leak to a
registry consumer. Express this with a single [runtime] entry whose
dev variant points at build/ and whose fallback points at the staged
artifact:
[runtime.HOUDINI_DSO_PATH]
method = "prepend"
value = [
# While developing the package locally:
{ when = { install_source = "dev", os = "windows" }, set = "$HPM_PACKAGE_ROOT/build/Release" },
{ when = { install_source = "dev", os = "linux" }, set = "$HPM_PACKAGE_ROOT/build/lib" },
{ when = { install_source = "dev", os = "macos" }, set = "$HPM_PACKAGE_ROOT/build/lib" },
# What ships in the published archive:
{ when = {}, set = "$HPM_PACKAGE_ROOT/dso" },
]
When this package is consumed via { path = "..." }, the dev variant fires
and points Houdini at the live build directory. When it ships through a
registry, hpm filters the dev variants out at install time, the fallback
fires, and Houdini sees only the published dso/ location. If you want
the variable to disappear entirely for non-dev consumers, omit the
fallback branch — an entry with no surviving variants is not emitted.
When a key appears in both the project and the package, the project
entry’s method decides whether it replaces or combines:
set— the project override replaces the package’s[runtime]entry (with its surviving variants).prepend/append— the package’s[runtime]entry (with surviving variants) is emitted first, then the project’s override, and Houdini merges them in load order.
[stage]
Defines how the install image is derived from the workspace. [stage]
governs both hpm pack (which streams an archive directly from the
workspace, applying these rules) and — when present — hpm build (which
materialises the same image into output_dir on disk so a path-dep
consumer can pick it up live).
[stage]
# Where `hpm build` materialises the install image. Default: "dist".
output_dir = "dist"
# Scripts (named entries from [scripts]) to run before staging. Fail-fast.
prepack = ["build-dso"]
# Gitignore-style globs applied on top of .gitignore and .hpmignore.
# Empty `include` means "everything not excluded". Always-excluded: .git/, .hpm/.
include = ["python/**", "otls/**", "config/**", "LICENSE", "README.md"]
exclude = ["src/**", "build/**", "tests/**"]
# Per-platform place rules. The platform key must appear in [compat].platforms.
[stage.platform.linux-x86_64]
place = [{ from = "build/linux/*.so", to = "dso/" }]
[stage.platform.macos-aarch64]
place = [{ from = "build/macos/*.dylib", to = "dso/" }]
[stage.platform.windows-x86_64]
place = [{ from = "build/win/*.dll", to = "dso/" }]
Platforms. Valid platform identifiers (TumbleTrove API build platform
enum verbatim): linux-x86_64, linux-aarch64, macos-x86_64,
macos-aarch64, windows-x86_64, windows-aarch64, universal. Use
universal for OS-agnostic content (pure-Python / data). Declare the
platforms you ship under [compat].platforms; each per-platform
[stage.platform.<plat>] table must reference a platform listed there.
Place rules. Each rule has a from glob (workspace-relative) and a
to path (archive-relative). If to ends with / it’s a directory; the
file’s basename is appended. Otherwise to is the literal archive path
(use when relocating a single file under a renamed name). Both from
and to use forward slashes regardless of host OS.
Per-platform packing semantics. When packing for --platform <X>:
- A path matched by
[stage.platform.X].place[*].fromis included at the rewrittentopath. The target’s claim wins over other platforms’. - A path matched only by
[stage.platform.Y].place[*].from(someY != X) is excluded. - A path matched by no
placerule and not covered by[stage].excludeis included as common content at its workspace-relative path. (If[stage].includeis non-empty, common content is restricted to paths matching one of those globs.)
This means listing the same from glob under every platform is a valid
way to declare “this content ships in every per-platform archive”
(e.g. a shared resolver path).
Build vs pack. hpm pack reads [stage] directly and streams the
archive from the workspace — useful in CI where you build immediately
before packing. hpm build runs prepack scripts and materialises the
same install image into output_dir on disk, so a path-dep consumer
working in another project can pick it up live (with link = true,
edits flow through without re-running hpm install).
[[registries]]
Per-project registries. Same shape as the global version in ~/.hpm/config.toml:
[[registries]]
name = "houdinihub"
url = "https://api.3db.dk/v1/registry"
type = "api"
[[registries]]
name = "studio"
url = "https://github.com/studio/hpm-packages.git"
type = "git"
Project registries are additive to global registries.
[scripts]
Named scripts for the package. Run them with hpm run <name> [args...],
which sets HPM_PACKAGE_ROOT to the manifest directory and forwards
trailing arguments to the script.
[scripts]
build = "python scripts/build.py"
test = "python -m pytest tests/"
Per-host variation
Scripts whose command differs per OS use a conditional cmd value — the
same when-grammar [runtime] uses, restricted to the os axis:
[scripts]
build = "cargo build" # runs on any host
[scripts.register]
cmd = [
{ when = { os = "windows" }, set = "\"$HPM_PACKAGE_ROOT/plugin/bin/tool.exe\" register" },
{ when = { os = "macos" }, set = "\"$HPM_PACKAGE_ROOT/plugin/bin/tool\" register" },
{ when = { os = "linux" }, set = "\"$HPM_PACKAGE_ROOT/plugin/bin/tool\" register" },
]
hpm run picks the first variant whose when.os matches the host. Add an
empty when = {} branch as a last entry to declare a fallback that
matches any host the explicit branches missed. A script with no matching
variant on the current host is treated as absent — hpm run errors with a
message that the script only matches other platforms.
Only the os axis is meaningful for scripts: HPM has no Houdini-version
or Python context at hpm run time, and install_source is irrelevant
because scripts run against the dev’s workspace, not an install. Setting
any non-os axis in a script when is rejected at manifest validation
time.
Per-script Python environments
A script that needs a pinned Python interpreter or extra packages can opt into a uv-managed virtual environment by switching from the shorthand string to the table form:
[scripts.tt_setup]
cmd = "python scripts/tt_setup.py"
python = "3.11"
requirements = ["PySide6>=6.6"]
hpm run tt_setup then resolves requirements through the same uv
pipeline that backs [python_dependencies], materialises a venv at
~/.hpm/venvs/<hash>/, prepends its bin/ (or Scripts/ on Windows) to
PATH, and sets VIRTUAL_ENV so python in the command resolves to the
pinned interpreter. Two scripts whose python + requirements resolve
to the same closure share one venv on disk. Plain-string entries keep
their prior behaviour and execute against whatever python is on PATH.
Conditional cmd + venv hints compose:
[scripts.regen]
cmd = [
{ when = { os = "windows" }, set = "python scripts\\regen.py" },
{ when = {}, set = "python scripts/regen.py" },
]
python = "3.11"
requirements = ["pyyaml"]
Both python and requirements are optional in the table form; omitting
both yields a regular script with no venv overhead.
The table form also accepts plain inline-table syntax:
[scripts]
tt_setup = { cmd = "python scripts/tt_setup.py", python = "3.11", requirements = ["PySide6>=6.6"] }
Consumers resolve scripts through PackageManifest::script_for(name) (or
resolved_scripts()) which returns the [ScriptEntry] verbatim;
call ScriptEntry::resolve_cmd(host_os) to pick the right variant.
Global configuration
HPM reads ~/.hpm/config.toml if it exists, then <cwd>/.hpm/config.toml
(project override) if it exists. Any missing sections fall back to defaults.
[install]
path = "packages/hpm" # relative install path inside projects
parallel_downloads = 8
[storage]
home_dir = "/Users/me/.hpm" # default: $HOME/.hpm
cache_dir = "/Users/me/.hpm/cache"
packages_dir = "/Users/me/.hpm/packages"
registry_cache_dir = "/Users/me/.hpm/registry"
[projects]
explicit_paths = [
"/Users/me/studio/pipeline",
]
search_roots = [
"/Users/me/houdini-projects",
]
max_search_depth = 3
ignore_patterns = [".git", ".hg", ".svn", "node_modules", "backup", "archive", ".cache", "temp", "tmp"]
[[registries]]
name = "houdinihub"
url = "https://api.3db.dk/v1/registry"
type = "api"
[signing]
key_path = "/Users/me/.hpm/signing.pem" # fallback for `hpm pack`
What each section controls
[install]
path— the directory inside a project wherehpm installwrites the per-dependency Houdini manifests. Under the hood this is also where.hpm/packagesis resolved; the defaultpackages/hpmrarely needs changing.parallel_downloads— maximum concurrent downloads from registries (default8).
[storage]
home_dir— HPM’s root on disk. Default$HOME/.hpmon every platform (Linux, macOS, Windows). All other storage paths derive from this by default.cache_dir,packages_dir,registry_cache_dir— override individual subdirectories without moving the whole root.
[projects] — drives hpm clean’s orphan detection.
explicit_paths— absolute project paths that are always considered active.search_roots— directories scanned recursively for active projects.max_search_depth— recursion limit for search_roots (default3).ignore_patterns— directory names/prefixes to skip during scanning. Matches full names or prefixes, not globs.
[[registries]] — list of registries. Same shape as
[[registries]] in hpm.toml.
[signing]
key_path— fallback Ed25519 PKCS#8 PEM path forhpm packwhen neither--keynorHPM_SIGNING_KEYis set.
Storage layout
HPM stores everything under ~/.hpm/ on every supported platform. Use
[storage] to change the root or individual subdirectories.
~/.hpm/
├── config.toml # global configuration (optional)
├── packages/ # extracted packages (global dedupe)
│ └── creator/
│ └── slug@1.0.0/
├── venvs/ # content-addressable Python venvs
│ └── a1b2c3d4e5f6/
│ ├── pyvenv.cfg
│ ├── lib/python3.11/site-packages/ # Lib\site-packages on Windows
│ └── metadata.json
├── cache/ # download cache
├── registry/ # registry index caches (one dir per registry)
├── tools/ # bundled uv binary
├── uv-cache/ # isolated uv cache (never touches your system uv)
├── uv-config/ # isolated uv config
├── uv-python/ # managed CPython installs (downloaded by uv on first launch)
└── logs/ # operational logs
Per-project layout:
<project>/
├── hpm.toml
├── hpm.lock # pinned versions + checksums
├── .hpm/
│ ├── config.toml # project-level overrides (optional)
│ └── packages/ # Houdini manifests, one per dependency
│ ├── utility-nodes.json
│ └── material-library.json
└── (your package sources)
Houdini integration
hpm install writes one Houdini package.json per dependency into
<project>/.hpm/packages/{name}.json. Each file points hpath at the
absolute location of the extracted package in ~/.hpm/packages/ and, for
packages that declare [python_dependencies], prepends the shared venv’s
site-packages onto PYTHONPATH:
{
"hpath": ["/Users/me/.hpm/packages/studio/utility-nodes@1.0.0"],
"env": [
{
"PYTHONPATH": {
"method": "prepend",
"value": "/Users/me/.hpm/venvs/a1b2c3d4e5f6/lib/python3.11/site-packages"
}
}
],
"enable": "houdini_version >= '20.5'"
}
method: "prepend" delegates path-separator handling to Houdini, so the same
manifest works on Unix (:) and Windows (;) without HPM embedding an
OS-specific joiner.
To make Houdini pick these up, add <project>/.hpm/packages to
HOUDINI_PACKAGE_PATH. For a studio-wide setup, set it in the shell or in
your DCC launcher; for a one-off project, set it when launching Houdini:
HOUDINI_PACKAGE_PATH="$PWD/.hpm/packages:$HOUDINI_PACKAGE_PATH" houdini
hpath points directly at the extracted package root, so Houdini auto-discovers
its convention subdirectories (otls/, desktop/, toolbar/, python_panels/,
viewer_states/, python3.11libs/pythonrc.py, keymaps/, …).
Output formats and automation
All commands emit human-readable output by default. The --output global
flag selects a machine-readable format instead:
| Format | When to use |
|---|---|
human | Default. Colored, styled for terminal use. |
json | Pretty-printed JSON. |
json-lines | One JSON object per line. Good for streaming and log ingestion. |
json-compact | Single-line JSON. Minimal bandwidth. |
Errors in any machine-readable format are also emitted as JSON, with fields
success, error, error_type, and elapsed_ms.
A typical CI recipe:
set -e
hpm install --frozen-lockfile # fail if lock is stale
hpm audit # warn on security issues
hpm pack --json --output dist/ # produce archive + manifest
hpm update --dry-run --output json is useful for nightly jobs that want to
detect available updates without applying them.
Troubleshooting
Package not found
Error: Package error: Package 'studio/foo' not found
Check hpm registry list — if it’s empty, add one with hpm registry add.
If registries are configured, run hpm registry update and try again.
Houdini version mapping failed
Error: No Python version mapping for Houdini 22; supported majors are 19, 20, 21.
An unsupported [compat].houdini lower bound is a hard error rather
than a silent fallback. Update hpm-core::python::collection if you need to add a new major,
or set the range to a supported lower bound.
Checksum mismatch at install time
Error: Package integrity check failed: ...
Means the cached package in ~/.hpm/packages/ no longer matches the
checksum recorded in hpm.lock. Either someone tampered with the cache, or
the cache predates a lock-file rewrite. Remove the offending directory
under ~/.hpm/packages/ and run hpm install again.
Lock file is stale
Lock file is 120 days old. Consider running 'hpm update' to check for newer versions.
Advisory warning. HPM will still install; hpm update refreshes the lock.
--frozen-lockfile fails in CI
The lock file either doesn’t exist yet or would need to change. If this is a
fresh project, run hpm install locally, commit hpm.lock, and retry. If
the project already has a lock and CI still fails, a dependency’s resolution
has drifted — review the diff from hpm update --dry-run.
Python packages aren’t importable inside Houdini
Check that:
HOUDINI_PACKAGE_PATHincludes<project>/.hpm/packages.- The generated
.hpm/packages/{name}.jsonhas aPYTHONPATHentry for the offending package. - The venv directory it points to exists and contains
site-packages/. If it doesn’t, upgrade past 0.7.2 — earlier versions had a bug where the venv’ssite-packageswas empty despite a successful install.
Restart Houdini after any change to HOUDINI_PACKAGE_PATH.
Debug logging
RUST_LOG=debug hpm install
RUST_LOG=hpm_core=debug,hpm_core::python=trace hpm install # per-module
Resetting state
# per-project reset
rm -rf .hpm/ hpm.lock
hpm install
# global reset (last resort)
rm -rf ~/.hpm/
hpm install # will re-download everything
Getting help
hpm --help,hpm <command> --help.- Report bugs at https://github.com/3db-dk/hpm/issues.
- The Python guide covers venvs, sharing, and
uvintegration in more depth. - The Security guide covers signing, checksums, and the threat model.
Python Guide
HPM bundles uv and uses it to manage the Python dependencies declared by Houdini packages. This guide covers how to declare them, how HPM maps Houdini versions to Python versions, how venv sharing works, and how cleanup and troubleshooting fit together.
Table of contents
- Overview
- Declaring dependencies
- Houdini to Python version mapping
- Virtual environment sharing
- Houdini integration
- Cleanup
- Troubleshooting
- Technical reference
Overview
HPM’s Python layer solves one specific problem: Houdini packages frequently want Python dependencies (numpy, pymongo, qtpy, watchdog, …) and those dependencies must be available to Houdini’s embedded Python interpreter at the right ABI version, without interfering with the system Python or with other Houdini packages.
HPM addresses this by:
- Resolving every package’s
[python_dependencies]with the bundleduv. - Installing the resolved packages into a content-addressable virtual environment in
~/.hpm/venvs/<hash>/. - Sharing that venv across every package whose resolved dependency set hashes to the same value.
- Emitting a Houdini manifest per package that prepends the venv’s
site-packagesontoPYTHONPATH. - Automatically mapping the lower bound of
[compat].houdinito a Python version compatible with Houdini’s embedded interpreter.
The bundled uv and its caches (~/.hpm/uv-cache/, ~/.hpm/uv-config/) are
fully isolated from any system uv you might have, so HPM never interferes
with other Python workflows.
Declaring dependencies
Python dependencies live in the [python_dependencies] section of
hpm.toml. Two forms are supported:
[python_dependencies]
# Shorthand: version constraint only
numpy = ">=1.20.0"
requests = "^2.28.0"
# Detailed: version, extras, optional
scipy = { version = ">=1.7.0", extras = ["sparse"] }
matplotlib = { version = "^3.5.0", optional = true }
plotly = { version = ">=5.0.0", optional = true }
Version constraints follow PEP 440 (the same grammar pip and uv use):
| Specifier | Meaning |
|---|---|
>=1.0.0 | Minimum version. |
^1.0.0 | Compatible release (>=1.0.0, <2.0.0). |
~=1.0.0 | Approximately equal (>=1.0.0, <1.1.0). |
==1.0.0 | Exact version. |
!=1.0.0 | Exclude a version. |
>1.0.0, <2.0.0 | Strict bounds. |
Best practice: allow compatible updates
[python_dependencies]
numpy = "^1.20.0" # allows 1.20.x, 1.21.x, …, but not 2.x
requests = ">=2.25.0" # minimum, with headroom for sharing
Avoid ==1.0.5-style pins — they prevent venv sharing across packages that
would otherwise converge on the same resolved set, and they block legitimate
security patches. Avoid * — it lets uv pick a version your peers don’t
have pinned, defeating the lock file’s reproducibility guarantee.
Houdini to Python version mapping
HPM reads [compat].houdini from the project’s root manifest (the
hpm.toml of the project being installed/launched), extracts its lower
bound, and maps that to the Python version Houdini ships that interpreter
with:
| Houdini version | Python version |
|---|---|
| 20.5, 20.x (x ≥ 5) | 3.10 |
| 21.x | 3.11 |
| 22.x | 3.13 |
A range like ">=20.5, <22" uses 20.5 for the mapping. Both "21" and
"21.0" are accepted as lower bounds — bare majors are treated as major.0.
The project’s Houdini version is authoritative for venv ABI selection.
A dependency package’s own [compat].houdini describes its compatibility
floor (the oldest Houdini it supports) — it does not influence which CPython
the venv targets. If it did, a project on Houdini 22 (Python 3.13) consuming
a [compat].houdini = ">=21.0" package would silently get a 3.11 venv whose
C-extension wheels would crash on import inside Houdini 22.
Unsupported: Houdini 19.x and 20.0 – 20.4
These ship Python 3.7 and 3.9 respectively, both past upstream end-of-life. HPM refuses to create venvs against them rather than installing a dead ABI. If you need to run one of those Houdini versions, stay on HPM 0.7.x.
No silent fallback
If [compat].houdini’s lower bound is unparseable ("latest") or points at
a Houdini major outside this table (">=23", ">=18"), hpm install
errors out rather than silently picking a wrong Python — an
ABI-mismatched venv would let the install succeed and then break
C-extension imports (pymongo, watchdog, …) at Houdini launch instead.
Error: No Python version mapping for Houdini 23; supported versions are 20.5+, 21, 22.
Houdini 19.x (Python 3.7) and 20.0–20.4 (Python 3.9) are past EOL.
If you need a new Houdini major before HPM ships support for it, update the
mapping in crates/hpm-core/src/python/collection.rs::map_houdini_to_python_version
and open a PR.
Virtual environment sharing
When multiple packages resolve to the same set of (python_version, packages, versions, extras), HPM installs them once and shares the venv:
Package A: numpy==1.24.0, requests==2.28.0 → hash a1b2c3d4
Package B: numpy==1.24.0, requests==2.28.0 → hash a1b2c3d4 (shared with A)
Package C: numpy==1.25.0, requests==2.28.0 → hash f6e5d4c3 (different)
The hash is a SHA-256 over the sorted resolved set plus the Python version, truncated to 12 hex characters. Any change to the resolved set — a newer lockfile, a different extras list, a different Python version — produces a new hash and therefore a new venv.
Why this matters
- Disk usage drops dramatically for studios with many packages that all want, say,
numpyandqtpy. - Install speed — a matching hash means no resolution and no install, just a pointer from
.hpm/packages/{name}.jsonto the existing venv. - Consistency — every package sharing a venv sees the same transitive dependency versions.
The per-venv metadata.json records which HPM packages are using the venv,
which hpm clean --python-only uses to detect orphans.
Per-script venvs
[scripts] entries can opt into the same venv machinery for out-of-process
hooks (Houdini setup wizards, lifecycle scripts, anything that runs before
or outside Houdini’s embedded Python). The table form takes a python
version and inline requirements; hpm run resolves them through uv,
materializes a venv under ~/.hpm/venvs/<hash>/, and prepends its bin/
(or Scripts/) to PATH for the script process. See
[scripts] in the user guide for syntax. Two
scripts whose resolved closures match share storage with each other and,
where the resolved set happens to coincide, with [python_dependencies]
venvs.
Houdini integration
Once hpm install has produced the venv, it writes a Houdini manifest per
dependency into <project>/.hpm/packages/{name}.json:
{
"hpath": ["/Users/me/.hpm/packages/studio/geometry-tools@1.0.0"],
"env": [
{
"PYTHONPATH": {
"method": "prepend",
"value": "/Users/me/.hpm/venvs/a1b2c3d4e5f6/lib/python3.11/site-packages"
}
}
],
"enable": "houdini_version >= '20.5'"
}
method: "prepend" delegates path-separator handling to Houdini, so the
same manifest works on Unix (:) and Windows (;) without embedding an
OS-specific joiner.
Point Houdini’s HOUDINI_PACKAGE_PATH at <project>/.hpm/packages so these
manifests are picked up at launch:
export HOUDINI_PACKAGE_PATH="$PROJECT/.hpm/packages:$HOUDINI_PACKAGE_PATH"
houdini
Restart Houdini after any change.
Once loaded, your Python dependencies are importable from Houdini’s Python context:
import hou
import numpy as np
import scipy.spatial
points = np.array([[0, 0, 0], [1, 1, 1], [2, 2, 2]])
tree = scipy.spatial.KDTree(points)
Cleanup
hpm clean has Python-aware modes that use the venv metadata to detect
unused environments:
hpm clean --python-only --dry-run # preview orphan venvs
hpm clean --python-only # remove them
hpm clean --comprehensive # packages + venvs in one pass
hpm clean --comprehensive --yes # non-interactive, for scripts
HPM never removes a venv that a package in an active project still uses.
Active projects come from [projects] in ~/.hpm/config.toml — see the
user guide.
Example output:
$ hpm clean --python-only --dry-run
Analyzing Python virtual environments for cleanup (dry run)...
Found 3 orphaned virtual environments that would be removed:
- ~/.hpm/venvs/abc123def (145 MB, created 30 days ago)
- ~/.hpm/venvs/def456ghi ( 89 MB, created 15 days ago)
- ~/.hpm/venvs/ghi789jkl (234 MB, created 7 days ago)
Would free approximately: 468 MB
Troubleshooting
Conflicting versions
Error: Conflicting versions for package numpy:
- studio/geometry-tools requires numpy>=1.20,<1.21
- studio/mesh-tools requires numpy>=1.25
Options:
- Relax one of the constraints so a shared resolution exists.
- Mark one package’s
numpyasoptional = true— it won’t participate in resolution unless you opt in. - Split the conflicting packages into separate projects, each with its own lock file and venv.
Python packages aren’t importable in Houdini
Check, in order:
HOUDINI_PACKAGE_PATHincludes<project>/.hpm/packages. Print it in a shelf tool to confirm..hpm/packages/{name}.jsonexists for the offending package and has aPYTHONPATHentry.- The venv the
PYTHONPATHpoints at exists and itssite-packages/contains adist-info/for the offending package. If it doesn’t, upgrade past 0.7.2 — earlier versions had auv pip install --targetbug that leftsite-packagesempty despite a successful install. 0.7.2 self-heals these legacy venvs on the nexthpm install.
uv fails to create a venv
Symptom: hpm install errors out before any packages install.
Likely causes and fixes:
No interpreter found in virtual environments, managed installations, search path, or registry. HPM auto-installs a managed CPython matching the project’s Houdini ABI on first launch. If the auto-install was interrupted (e.g. offline at the time), retry with a connection —uv python install <ver>will resume into~/.hpm/uv-python/. If you’ve upgraded from HPM ≤0.10.1 and previously hit this error, just retrying on the new version fixes it.- Python interpreter unavailable for the target version.
uvdownloads interpreters on demand; a network failure at that step surfaces here. Retry, or clear the cache withrm -rf ~/.hpm/uv-cache/ ~/.hpm/uv-python/and retry. - Disk space. Each venv is tens to hundreds of MB, plus ~50 MB for each managed CPython under
~/.hpm/uv-python/; checkdf. - Permissions. Ensure
~/.hpm/is writable by your user.
RUST_LOG=debug hpm install # full HPM debug logs
RUST_LOG=hpm_core::python=trace hpm install # Python-specific
Venv sizes are growing
HPM deliberately keeps venvs around so incremental installs stay fast. Run
hpm clean --python-only --dry-run periodically to see what could be
reclaimed, then hpm clean --python-only (or --comprehensive).
du -sh ~/.hpm/venvs/ # check total size
du -sh ~/.hpm/venvs/* | sort -h # find the largest
Technical reference
Storage layout
~/.hpm/
├── packages/
│ └── creator/
│ └── slug@1.0.0/
├── venvs/
│ └── <hash>/ # 12-char SHA-256 truncation
│ ├── pyvenv.cfg
│ ├── bin/python # Unix; Scripts\python.exe on Windows
│ ├── lib/python3.11/site-packages/ # Lib\site-packages on Windows
│ └── metadata.json # resolved deps + using packages
├── tools/
│ └── uv # bundled uv binary
├── uv-cache/ # isolated uv cache
├── uv-config/ # isolated uv config
├── uv-python/ # managed CPython installs (UV_PYTHON_INSTALL_DIR)
└── cache/
Content hash
#![allow(unused)]
fn main() {
// crates/hpm-core/src/python/types.rs (simplified)
pub fn calculate_content_hash(resolved: &ResolvedDependencySet) -> String {
let mut hasher = Sha256::new();
hasher.update(format!("python:{}", resolved.python_version));
let mut packages: Vec<_> = resolved.packages.iter().collect();
packages.sort_by_key(|(name, _)| name.as_str());
for (name, spec) in packages {
hasher.update(name.as_bytes());
hasher.update(spec.version.as_bytes());
for extra in spec.extras.iter().flatten() {
hasher.update(extra.as_bytes());
}
}
hex::encode(hasher.finalize())[..12].to_string()
}
}
The hash is stable across machines: give uv the same constraints and the
same index, and HPM’s manifest generator and venv deduplication will agree
on the same 12-character prefix.
Install flow
- Collect
[python_dependencies]from the root manifest and every installed HPM dependency’s manifest. - Read
[compat].houdinifrom the root manifest, extract its lower bound, and map it to a Python version. Per-package[compat].houdiniis ignored for ABI selection. - Resolve the merged dependency set with
uv(lockfile-aware). - Hash the resolved set + Python version → venv directory name.
- If that directory exists and its
site-packages/has adist-info/for each resolved package, reuse it. Otherwise, delete and rebuild. - Run
uv pip install --python <venv>/bin/pythonto populatesite-packages/. - Write
metadata.jsonwith the resolved set and the list of HPM packages using the venv. - For each installed HPM package, write a
<project>/.hpm/packages/{name}.jsonHoudini manifest that prepends the venv’ssite-packagesontoPYTHONPATH.
Resources
Registries
A registry is where HPM looks up package metadata — names, versions, download URLs, checksums, and dependency lists. HPM supports two flavors:
- API registries speak HTTP and serve JSON from a handful of endpoints.
- Git registries are Cargo-style indexes: a Git repository with one JSON-lines file per package.
This guide covers how to add and manage registries, how HPM resolves through them, and how to configure them per-user vs. per-project.
Table of contents
- Adding a registry
- Per-user vs per-project
- Targeting a specific registry from a dependency
- Refreshing and removing
- Auto-detection of registry type
- Searching
- Caching
Adding a registry
hpm registry add <URL> [--name <alias>] [--type api|git]
Examples:
# API registry
hpm registry add https://api.3db.dk/v1/registry --name houdinihub
# Git-index registry (explicit)
hpm registry add https://github.com/studio/hpm-packages.git --name studio --type git
# No --name: HPM infers an alias from the URL
hpm registry add https://api.studio.com/registry
# → added as "registry"
This writes to ~/.hpm/config.toml:
[[registries]]
name = "houdinihub"
url = "https://api.3db.dk/v1/registry"
type = "api"
[[registries]]
name = "studio"
url = "https://github.com/studio/hpm-packages.git"
type = "git"
Listing
hpm registry list
Per-user vs per-project
Registries can be declared in two places:
- Per-user —
~/.hpm/config.toml. Managed byhpm registry add/remove/list. Applies to every project you work on. - Per-project —
hpm.tomlunder[[registries]]. Applies only to that project. Additive to per-user registries.
Per-project registries are useful when a studio wants each project to pin the registries it resolves against:
# hpm.toml
[[registries]]
name = "houdinihub"
url = "https://api.3db.dk/v1/registry"
type = "api"
[[registries]]
name = "studio"
url = "https://packages.studio.com/v1/registry"
type = "api"
[dependencies]
"studio/internal-tool" = { version = "1.0.0", registry = "studio" }
Every team member who clones the project gets the same registry set, without
needing to run hpm registry add themselves.
Targeting a specific registry from a dependency
By default, HPM resolves a dependency by querying every configured registry in order and taking the first match. To pin a dependency to one registry, use the detailed dependency form:
[dependencies]
"studio/internal-tool" = { version = "1.0.0", registry = "studio" }
This is useful when:
- A package exists under the same name in multiple registries and you want to be unambiguous.
- A private registry should always win over a public one for specific packages.
- You want the lockfile to record which registry resolved the dependency, so audits can answer “where did this come from”.
Refreshing and removing
hpm registry update # refresh every configured registry's cache
hpm registry remove studio # drop a registry from the config
hpm registry update does the right thing for each type:
- API registries: invalidate the metadata cache under
~/.hpm/registry/<name>/. - Git registries:
git pullthe index repository to pick up new packages and versions.
Run hpm registry update when a new version has been published and you want
to pick it up without waiting for cache expiry.
Auto-detection of registry type
If you don’t pass --type, hpm registry add infers it from the URL:
| URL pattern | Inferred type |
|---|---|
Ends with .git | git |
Contains github.com | git |
Contains gitea | git |
| Anything else | api |
Override with --type api or --type git when the heuristic gets it wrong.
Searching
hpm search <query>
hpm search queries every configured registry in parallel. If no registries
are configured, HPM prints a hint to run hpm registry add and exits cleanly.
With --output json, results are emitted as a JSON array suitable for
piping into other tooling:
hpm search geometry --output json | jq '.packages[].name'
Each entry includes the package name, version, optional description, and
optional Houdini compatibility string. A yanked: true entry signals that
the maintainer pulled that version; HPM still shows it in search results
but hpm install will refuse to use it.
Caching
HPM caches registry metadata under ~/.hpm/registry/<name>/. The cache is
per-registry, not per-project, so multiple projects share the same cache.
- API cache: response bodies for the endpoints HPM hits during resolution. Cleared by
hpm registry updateor by deleting the directory. - Git cache: a local clone of the index repository. Updated by
hpm registry update.
The cache is advisory — if it’s corrupted or deleted, HPM re-fetches on the next operation. Never edit it by hand.
Security Guide
This guide covers HPM’s security features, the threat model, package signing, and operational best practices.
Table of contents
- Integrity and reproducibility
- Package signing
hpm audit- Transport security
- Storage layout
- Best practices
- Threat model
- Reporting vulnerabilities
Integrity and reproducibility
HPM protects against tampering and drift through three layers.
SHA-256 checksums
Every package HPM installs is hashed end-to-end, and the hash is recorded in
hpm.lock:
# hpm.lock (excerpt)
version = 1
[package]
name = "my-studio/my-tools"
version = "1.0.0"
[dependencies."studio/utility-nodes"]
version = "1.2.0"
checksum = "sha256:a3f2b8c9..."
[dependencies."studio/utility-nodes".source]
type = "registry"
registry = "houdinihub"
[metadata]
generated_at = "2026-03-14T10:30:00Z"
hpm_version = "0.7.2"
platform = "linux-x86_64"
On every hpm install, HPM verifies each cached package in ~/.hpm/packages/
against the checksum in hpm.lock before using it. A mismatch aborts the
install with a clear error — corrupted or tampered packages never silently
reach Houdini.
Lock file pinning
hpm.lock pins exact resolved versions of every HPM and Python dependency.
Commit it to version control. In CI, use --frozen-lockfile to fail the
build if the lock would need to change:
hpm install --frozen-lockfile
This catches two failure modes:
- The lock file is missing entirely.
- A dependency’s constraint or a registry’s contents have drifted since the lock was written.
Staleness detection
Lock files include a generated_at timestamp. HPM warns on install and
audit if the lock is older than 90 days — a heuristic for “you probably
haven’t checked for security fixes in a while”. The warning is advisory; it
does not block the install.
Package signing
hpm pack can sign the archive it produces with an Ed25519 key. Consumers
(registries, internal pipelines, end users) can verify that signature
before trusting the package.
Wire format
| Property | Value |
|---|---|
| Signature algorithm | Ed25519 (RFC 8032). |
| Private key format | PKCS#8 PEM (-----BEGIN PRIVATE KEY-----). Generated by openssl genpkey -algorithm ed25519. |
| Public key format | SubjectPublicKeyInfo PEM (-----BEGIN PUBLIC KEY-----). Extracted with openssl pkey -pubout. |
| Signature encoding | Standard base64 (RFC 4648, alphabet A–Z a–z 0–9 + /, = padding). Emitted as fileSignature. Verifiers must use the standard alphabet, not base64url. |
| Key identifier | First 8 bytes of the Ed25519 public key, hex-encoded (16 characters). Emitted as keyId. Lets registries index multiple keys per creator without transmitting the full public key on every artifact. |
| Signed payload | The raw bytes of the produced .zip archive. |
Generating a key pair
openssl genpkey -algorithm ed25519 -out signing.pem
openssl pkey -in signing.pem -pubout -out signing.pub.pem
Keep signing.pem private. Publish signing.pub.pem through your registry
or creator dashboard so consumers know what to verify against.
Signing a package
Three equivalent ways, tried in this order:
# 1. Explicit flag
hpm pack --key ~/.hpm/signing.pem
# 2. Environment variable. Either a path, or inline PEM content
# (detected by a leading "-----BEGIN" marker). The inline form is meant
# for CI systems that inject secrets as plain strings.
export HPM_SIGNING_KEY="$(cat signing.pem)"
hpm pack
# 3. Configured fallback in ~/.hpm/config.toml
# [signing]
# key_path = "/Users/me/.hpm/signing.pem"
hpm pack
The resolution order is flag → env → config. The first that’s set wins.
Output
With --json, hpm pack emits a machine-readable record suitable for CI
pipelines and registry upload tooling:
{
"archive": "dist/my-tools-1.0.0.zip",
"sha256": "a3f2b8c9...",
"signature": "base64-standard-encoded-ed25519-signature",
"key_id": "7a1c3e5f9b2d4860",
"platform": "linux-x86_64"
}
signature and key_id are present only when a signing key was supplied.
platform is present only when the manifest declares [compat].platforms.
Operational guidance
- Store the private PEM in a secret manager (Vault, Infisical, GitHub Actions secrets) rather than on disk in CI runners.
- Treat
keyIdas public — it leaks nothing about the private key, but anchors verification to a specific key version. - Rotate by generating a new key pair, publishing the new
keyId, and re-signing future releases. Consumers should accept both the old and newkeyIdduring the overlap window. - A
keyIdmismatch at verification time means either the wrong public key is configured, or the signer rotated without updating the registry.
hpm audit
hpm audit
Audit runs against the current project and reports:
| Check | Emits |
|---|---|
HTTP URLs in [dependencies] (only the url = … form) | WARN per offending dependency. |
hpm.lock presence | PASS or WARN (No lock file found). |
hpm.lock staleness | PASS (recent) or WARN (Lock file is N days old) when N > 90. |
| Package checksum verification | PASS or WARN (Checksum verification failed: ...). |
Every offender is a warning, not an error — hpm audit never fails the
shell. Wire it into pre-release checklists and CI as an advisory step.
Example output:
HPM Security Audit
========================================
PASS All URLs use HTTPS
PASS Lock file exists (hpm.lock)
PASS Lock file is recent
PASS Package checksums verified
No security issues found.
Transport security
- HPM uses rustls for all HTTPS, so TLS comes from a pure-Rust stack — no OpenSSL, no system-trust-store drift between platforms.
- Registry URLs can be
http://orhttps://;hpm auditwarns about HTTP URLs in theurl = …form of dependencies. If your registry itself is reachable only over HTTP, treat that as a misconfiguration — plain-HTTP registries leak package names and contents, and are trivially tamperable in transit. - Downloads from registries stream through reqwest with the checksum path ending at the SHA-256 verification in
hpm install. MITM tampering doesn’t survive checksum verification.
Storage layout
HPM stores everything under ~/.hpm/ on every supported platform:
| Path | Contents |
|---|---|
~/.hpm/config.toml | Global configuration (including [[registries]], [signing].key_path). |
~/.hpm/packages/ | Canonical CAS, keyed by <slug>@<version>/. Path-installed packages live under the _dev/ subtree, never substituted for a registry hit. |
~/.hpm/fetch/ | ArchiveFetcher staging. Archives are extracted here before being copied into the canonical CAS. |
~/.hpm/venvs/ | Content-addressable Python venvs, keyed by resolved-set hash. |
~/.hpm/cache/ | Download archive cache. |
~/.hpm/registry/ | Per-registry index caches. |
~/.hpm/uv-cache/ | Isolated uv cache — never shared with system uv. |
Per-project:
| Path | Contents |
|---|---|
<project>/hpm.toml | Manifest. |
<project>/hpm.lock | Pinned versions + checksums. Commit this. |
<project>/.hpm/packages/{name}.json | Per-dependency Houdini manifest. Auto-generated. |
<project>/.hpm/config.toml | Optional project-level configuration override. |
The defaults live under $HOME on every platform. To relocate them (e.g., a
shared cache on a fast SSD), override [storage].home_dir (or individual
subdirectories) in ~/.hpm/config.toml. Pick a path that only your user
account can write — HPM does not attempt to sandbox itself against local
privilege escalation on a shared machine.
Best practices
Use HTTPS registries
# Good
[[registries]]
name = "studio"
url = "https://packages.studio.com/v1/registry"
type = "api"
# Bad — plaintext leaks names and lets attackers swap archives in flight
[[registries]]
name = "studio"
url = "http://packages.studio.com/v1/registry"
type = "api"
Commit hpm.lock
Treat hpm.lock like Cargo.lock or package-lock.json — check it in,
review diffs, and merge conflicts carefully.
Use --frozen-lockfile in CI
- name: Install HPM dependencies
run: hpm install --frozen-lockfile
Fails fast if the lock is missing or stale. Catches unintended resolver drift before it reaches production.
Run hpm audit in pre-release checks
Cheap, fast, and flags the easy mistakes (HTTP deps, missing or stale lock, checksum drift). Add it to your release workflow.
Review new dependencies
Before adding a dependency:
- Verify the package is from the creator you expect.
- Check the repository’s recent activity — is it maintained?
- Skim the manifest for scripts, native binaries, and
[runtime]entries that do more than you want.
HPM gives you reproducible, verifiable installs of whatever you asked for. It cannot tell you whether what you asked for is trustworthy.
Rotate signing keys periodically
Ed25519 keys don’t expire, but a key that leaked a year ago is still a leak.
Rotate at studio-appropriate cadence and publish the new keyId.
Threat model
Mitigated
| Threat | Attack vector | Mitigation |
|---|---|---|
| Cache tampering | Attacker modifies a package under ~/.hpm/packages/. | SHA-256 verification against hpm.lock before use. |
| Man-in-the-middle | Attacker intercepts a download. | TLS + checksum verification. |
| Lockfile poisoning | Attacker rewrites hpm.lock checksums. | Detected at install when cached bytes mismatch. Review lockfile diffs in code review. |
| Dependency drift | Same project produces different installs over time. | Exact version + checksum pinning; --frozen-lockfile. |
| Stale dependencies | Old versions with known CVEs. | 90-day staleness warnings; hpm update surfaces newer versions. |
| Replay of a vulnerable version | Registry serves an older artifact than expected. | Version is pinned in the lockfile; the registry cannot “silently” downgrade. |
| Unknown signer | Unknown party uploads an archive claiming to be from a creator. | hpm pack --key + Ed25519 signature + keyId. Consumers verify against the publisher’s known keyId. |
Not addressed
HPM does not protect you against:
- Malicious code in a legitimate package. If the author intends to do harm, HPM will faithfully distribute that harm.
- Compromised upstream sources. If the registry or Git server itself is compromised, HPM trusts what it serves (until a signature check fails, if signatures are in use).
- Zero-day vulnerabilities in dependencies. Use your organization’s security scanning.
- Supply-chain attacks at the package author. Sign your own releases, encourage consumers to verify, and rotate on suspicion.
- Local privilege escalation on shared machines.
~/.hpm/lives in your home directory; anyone with write access to that directory can tamper. Use per-user home directories.
For these threats, layer defenses: code review, dependency scanning
(cargo-audit for Rust deps, pip-audit/similar for Python deps), and
signature verification at your registry.
Reporting vulnerabilities
Do not open a public issue for security problems. Instead:
- Open a private security advisory on GitHub.
- Include a reproducer, affected versions, and the impact you observed.
- Allow a reasonable window for a fix before public disclosure.
See also: Security changelog.
Security changelog
| Version | Change |
|---|---|
| 0.7.0 | Houdini version mapping no longer silently falls back to Python 3.9. Unsupported Houdini majors are now install-time errors, preventing silent ABI mismatches. |
| 0.6.0 | Package signing wire format: PKCS#8 PEM keys, Ed25519 signatures, standard base64 encoding, keyId = first 8 bytes of public key hex. Breaking change from the earlier 32-byte-seed format — regenerate keys with openssl genpkey. HPM_SIGNING_KEY accepts inline PEM. |
| 0.5.2 | Generated per-dependency Houdini hpath points at the package root, so Houdini auto-discovers convention subdirs instead of loading only HDAs. |
| 0.3.0 | Per-platform native packages via [native] + hpm pack --platform. |
| 0.1.0 | SHA-256 checksum verification, HTTPS warnings, --frozen-lockfile, hpm audit, lock file staleness detection, project-level env overrides. |