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
- 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
- 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.
Where these docs are published
This directory is the single source for two sites:
- hpm.readthedocs.io builds it with mdBook
(
book.toml,.readthedocs.yaml), driven bySUMMARY.md. - docs.tumbletrove.com/hpm/ pulls it in
via the
tumbletrove-docsassembler, driven bymanifest.toml.
Adding or renaming a page means updating both SUMMARY.md and
manifest.toml, or it will go missing from one of the two sites.
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
├── .gitignore
├── package.json # Houdini-native package file
├── 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 — 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.tumbletrove.com/v1/registry --name tumbletrove
# 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 and generated files; create only hpm.toml. |
--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. Packages that declare native [compat].platforms (a DSO/HDK build, not universal) are installed as a copy even with --link: a linked native binary can’t be rebuilt in place while a Houdini session has it loaded (Windows LNK1104), and a mapped DSO needs a relaunch to reload anyway. |
-m, --manifest <path> | Path to the manifest to modify (hpm.toml or containing dir). Defaults to cwd. |
--optional | Mark all added dependencies as optional. |
--registry <name> | Resolve from this configured registry only, and record registry = "<name>" in hpm.toml so later installs and updates keep using it. Errors if the name is not configured. Not valid with --path. |
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 -m /path/to/project
hpm add studio/internal@1.0.0 --registry studio # pin to one registry
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 |
|---|---|
-m, --manifest <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/{creator}.{slug}.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 |
|---|---|
-m, --manifest <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 |
|---|---|
-m, --manifest <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]and[[operators]]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 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. - For entries with
package-env = true, runs inside the package’s full resolved environment instead: the merged venv across the project and its installed dependencies, with every package’spython/onPYTHONPATH. Requireshpm installto have been run. - 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
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. |
--profile <name> | Build profile. Defaults to release. Selects the matching [stage.profile.<name>] table (if any) and is exposed to prepack scripts as HPM_BUILD_PROFILE. |
--houdini-majors <list> | Houdini major versions this build targets, space-separated (e.g. --houdini-majors "21 22"). Forwarded verbatim to prepack scripts as HPM_HOUDINI_MAJORS. Omit to leave it unset. |
--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. |
Prepack environment. In addition to HPM_PACKAGE_ROOT, prepack scripts
(and any [scripts] they invoke) see these build-context variables:
-
HPM_BUILD_PROFILE— the selected--profile(defaultrelease). A single prepack script can branch on it, e.g.cmake --build --config $HPM_BUILD_PROFILE, instead of maintaining one script per build type. -
HPM_PLATFORM— the resolved target platform (e.g.macos-aarch64), set whenever the package declares[compat].platforms. -
HPM_HOUDINI_MAJORS— the set of Houdini major versions this build should target, space-separated (e.g.21 22, or just22). A package that builds one native artifact per Houdini major (a compiled USD resolver, say) reads it to restrict the matrix — build every declared major when it is unset or empty, otherwise only the listed ones. hpm forwards the value verbatim and does not interpret it.hpm buildsets it from--houdini-majors; CI passes the release matrix that way instead of exporting an ad-hoc, unprefixed variable. When the flag is omitted, anHPM_HOUDINI_MAJORSalready present in the environment passes through unchanged, so an out-of-process producer (e.g. a desktop launcher that discovers the machine’s installed majors) can set it and an explicit--houdini-majorsstill overrides. Unset in both = the package’s full declared matrix.
HPM_BUILD_PROFILE, HPM_PLATFORM, and HPM_HOUDINI_MAJORS are set only for
the hpm build prepack path; a standalone hpm run <script> does not carry
build-profile, platform, or Houdini-major context.
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. - Builds a searchable asset index from the manifest’s
[[operators]]declarations and includes it in--jsonoutput (and warns if a declaredsourcefile is missing from the archive).
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. |
--verify-assets | Fail the pack (and delete the archive) if any [[operators]] source is missing from the produced archive, instead of just warning. |
Asset index in --json output
--json adds an assets array alongside the existing checksum/signature
fields. Each entry describes one bundled operator; None fields are omitted.
HDA-vs-DSO is carried by kind (hda_operator / dso_operator).
{
"archive": "studio-rbd-tools-1.0.0.zip",
"sha256": "…",
"signature": null,
"key_id": null,
"platform": null,
"assets": [
{
"kind": "hda_operator",
"type_name": "studio::rbd_configure::2.0",
"label": "RBD Configure",
"category": "Sop",
"namespace": "studio",
"op_version": "2.0",
"tab_submenu": "Studio/Dynamics",
"icon": "SOP_rbd",
"source_file": "otls/rbd.hda"
},
{
"kind": "dso_operator",
"type_name": "studio::fast_scatter",
"label": "Fast Scatter",
"category": "Sop",
"namespace": "studio",
"source_file": "dso/scatter.so"
}
]
}
The index is built from declarations, not by parsing the bundled files —
see [[operators]] for why and how to declare them.
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 global
Install a package into Houdini’s user preferences, so it loads in every session of one Houdini version without a project or launcher wiring.
hpm global add <PACKAGE> --houdini <X.Y> [--registry <name>]
hpm global list --houdini <X.Y>
hpm global remove <PACKAGE> --houdini <X.Y>
<PACKAGE> is creator/slug or creator/slug@version; a bare name resolves
to the highest non-yanked version. Resolution goes through the configured
registries exactly like hpm add, including the --registry pin.
--houdini <X.Y> is required and is not guessed. hpm does not discover
Houdini installations, and the preferences directory is per-version, so a
default would risk writing where your Houdini never looks. A full build
string is accepted (21.0.729) and truncated to its major.minor line.
The manifest is written as hpm-<creator>.<slug>.json into:
| Platform | Directory |
|---|---|
| Linux | ~/houdiniX.Y/packages/ |
| macOS | ~/Library/Preferences/houdini/X.Y/packages/ |
| Windows | %USERPROFILE%\Documents\houdiniX.Y\packages\ |
HOUDINI_USER_PREF_DIR takes precedence when set, with __HVER__ expanded
to X.Y, matching Houdini’s own behaviour.
That directory is not hpm’s. It holds files from SideFX, other tools, and
you. Unlike a project install — which owns <project>/.hpm/packages/ and
sweeps anything unrecognized out of it — hpm global never scans that
directory to decide what to delete. Every removal is driven off the ledger at
~/.hpm/global/houdini-<X.Y>.json and touches only files hpm recorded
writing. Files you put there by hand are left alone.
A package whose [compat].houdini does not include the target version is
rejected before anything is written. Installing it anyway would emit a
manifest whose enable expression is false, and Houdini would ignore it in
silence.
hpm global remove deletes the manifest but leaves the package in the store,
matching hpm remove. Run hpm clean to reclaim the space.
There is no hpm global update yet: with no manifest to rewrite, updating is
hpm global add at the new version, which replaces the entry in place.
Examples
hpm global add studio/utility-nodes --houdini 21.0
hpm global add studio/utility-nodes@1.2.0 --houdini 21.0 --registry studio
hpm global list --houdini 21.0
hpm global remove studio/utility-nodes --houdini 21.0
Only public packages resolve today: the standalone CLI has nowhere to store credentials, so it always resolves anonymously. A private package fails as “not found”.
hpm clean
Remove orphaned packages, dev (path-dep) installs, and/or venvs that no active project depends on.
Globally installed packages are roots here too — they belong to no project,
so without the global ledger they would look unreferenced and be deleted out
from under a manifest Houdini still loads. If a ledger cannot be read,
hpm clean refuses to run rather than risk that.
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/<creator>/<slug>@<version>/: preserved if reachable from any active project’s dependency graph. - Dev installs in
~/.hpm/packages/_dev/<creator>/<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. A copy is content-addressed as_dev/<slug>@<version>/<source-hash>/, so each rebuild leaves a new directory behind;cleanreclaims the superseded ones for a still-used package, keeping only the current build. Run it with your Houdini sessions closed — a copy still in use is skipped rather than removed. - 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] [--if-not-exists]
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.
Pass --if-not-exists to make add idempotent: if a registry with the same
name already exists it is left unchanged and the command exits 0, instead of
erroring. Useful in provisioning scripts.
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. hpm init defaults the creator to local, producing local/<name> — change it before publishing. |
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 }
A registry = "<name>" key pins the dependency to one configured registry.
The pin is authoritative: resolution, install, and hpm update all consult
only that registry, and pinning a registry that is not configured is an error
rather than a silent fallback to the rest of the set. Without the key, a
dependency resolves across every configured registry in order, first match
wins. Add a pin from the command line with hpm add <package> --registry <name>.
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.
Note on the emitted form: in the generated Houdini package.json,
prepend and append are written as single-element JSON lists. The list
form matters for them — Houdini only honors method on a custom
variable when the variable’s first definition uses a list value; with a
flat string every later entry silently overwrites the variable regardless
of its declared method.
set is emitted as a bare flat string instead (Houdini’s package system
has no set method). This is deliberate: a path-registered variable that
Houdini seeds itself — OCIO, PYTHONPATH, HOUDINI_*_PATH — is always
merge-able, so a list-form replace appends onto Houdini’s seed (you’d
get builtin;project, which breaks OCIO) instead of replacing it. A flat
string overwrites the seed cleanly and load-order-independent, which is
exactly what set promises. (A genuinely conditional set value — a
{ when, set } list — can’t be a flat string, so it falls back to the
list replace form; gate the whole package on the condition, or set such
a path variable flat, if you need the overwrite there.)
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 every package contribution wholesale — only the project’s value survives. |
prepend / append | Extends them — each declaring package’s own entry stays in place, and the project’s value merges in once, after all of them. |
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 packages contribute.
Mechanically, project sync writes the project’s [runtime] entries into
a dedicated ~hpm-project-overrides.json in the project packages dir.
Houdini processes package files in byte-wise filename order and ~
sorts after every slug character, so the overrides file always applies
last — after every per-package file — and each override is applied
exactly once, no matter how many packages declare the same variable.
This also means a project [runtime] entry takes effect even when no
package declares the variable. Since a project-level entry has no owning
package, $HPM_PACKAGE_ROOT is undefined there (sync warns if a project
entry references it).
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:
the first matching branch wins. (Houdini itself applies every matching
element of a conditional value array, so hpm compiles each emitted branch
to exclude all earlier branches’ conditions — first-match is guaranteed by
the emitted expressions, and anything after an always-true branch is
dropped as unreachable.) 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 in the package’s own file, and the project’s override merges in after every package file via the project overrides manifest (see[runtime]above).
[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).
Build profiles — [stage.profile.<name>]
Build profiles are a second axis orthogonal to platform, for packages whose
native binaries can be built more than one way — typically an optimized
release (the default) and a symbol-bearing debug variant for attaching a
debugger to an HDK plugin.
hpm build --profile <name> always exposes the name to prepack scripts as
HPM_BUILD_PROFILE (see Prepack environment above). When a matching
[stage.profile.<name>] table is declared, its overrides layer onto the base
[stage] so the profile build is reproducible from the manifest:
[stage]
prepack = ["build-dso"]
[stage.profile.debug]
# Replaces the base prepack list when non-empty.
prepack = ["build-dso-debug"]
# Per-platform place rules here are appended to the base platform rules —
# e.g. ship Windows PDBs alongside the DSO only in the debug profile.
[stage.profile.debug.platform.windows-x86_64]
place = [{ from = "build/Debug/*.pdb", to = "dso/windows-x86_64/" }]
Merge semantics (base [stage] + selected profile):
prepack— the profile’s list replaces the base when non-empty; otherwise the baseprepackruns.include/exclude— profile entries are appended to the base globs.platform.<plat>.place— profile place rules are appended to the matching base platform entry (a new platform key is created if absent). Each[stage.profile.<name>.platform.<plat>]key must appear in[compat].platforms, same as the base[stage.platform.<plat>]tables.
The default profile is release; you only need a [stage.profile.release]
table if you want to customize the default. A --profile <name> that names no
declared table is allowed — the env var is still set — but hpm build prints a
warning so a typo is visible. hpm pack is unaffected by --profile; profile
selection is a build-time concern only.
[[registries]]
Per-project registries. Same shape as the global version in ~/.hpm/config.toml:
[[registries]]
name = "tumbletrove"
url = "https://api.tumbletrove.com/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/"
Display metadata (label and description)
The table form accepts two optional, purely informational fields for tools that present scripts to people (menus, buttons, tooltips):
[scripts.launch]
cmd = "python -m mytool.ui"
label = "Launch My Tool"
description = "Open the main UI"
label is a human-readable display name and description is a one-line
summary. HPM itself never acts on either — hpm run ignores them, and
hpm check only echoes them back so you can confirm they parsed. They are
consumer-agnostic metadata: any frontend that surfaces a package’s scripts
may use them (falling back to the entry key when label is absent), but
nothing in HPM treats a labelled script as special. Both are omitted from
round-tripped manifests when unset.
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.
Running inside the package environment
A per-script venv is intentionally minimal — it installs only the script’s
own requirements and does not expose the package’s
[python_dependencies] or make the package’s python/ modules importable.
That’s right for self-contained tooling, but the wrong shape for code that
ships in the package and needs to import it (a render-farm task, a CLI the
package exposes).
Set package-env = true to run the script inside the package’s full
resolved environment instead:
[scripts.farm]
cmd = "python -m tumblepipe.farm"
package-env = true
hpm run farm then builds the same environment hpm install materialises
for Houdini, but for this out-of-Houdini process:
- a merged uv venv resolved from
[python_dependencies]across the project and its installed hpm dependencies (so a dependency’s Python packages are present too); - every involved package’s
python/directory onPYTHONPATH— the running package first, then each dependency, then the venv’ssite-packages; - the interpreter pinned to the project’s Houdini-mapped CPython (a
per-script
pythonhere is ignored); anyrequirementson the entry are layered on top of the package environment.
This is read-only: the dependency set comes from the existing hpm.lock
plus the global package store, so it never fetches packages or rewrites
generated Houdini manifests. Because the venv is content-addressable, when
hpm install already built it the run reuses it without re-resolving.
Run hpm install first so the project is locked and its dependencies are
present; otherwise hpm run errors and points you there. Consumers like the
Deadline render-farm plugin can then delegate the whole venv/deps/PYTHONPATH
dance to hpm run <script> instead of reproducing it.
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.
[[operators]]
Declares the operators (node types) the package bundles, so hpm pack can
emit a searchable asset index that lets a registry offer node-level search
(e.g. “find the package that ships an rbd_configure SOP”) without ever
downloading or opening the archive.
[[operators]]
kind = "hda" # "hda" or "dso"
type_name = "studio::rbd_configure::2.0"
label = "RBD Configure"
category = "Sop"
tab_submenu = "Studio/Dynamics"
icon = "SOP_rbd"
source = "otls/rbd.hda"
[[operators]]
kind = "dso"
type_name = "studio::fast_scatter"
label = "Fast Scatter"
category = "Sop"
source = "dso/scatter.so"
| Field | Required | Description |
|---|---|---|
kind | yes | hda (defined by an HDA/OTL digital asset) or dso (registered by a compiled plugin shipped as a DSO, i.e. authored against the HDK). |
type_name | yes | Namespaced operator type name, e.g. studio::rbd_configure::2.0. hpm derives namespace and op_version from it where it can. |
category | yes | Operator table / network category: Sop, Object, Dop, Lop, Top, Vop, Rop, Driver, … |
label | no | TAB-menu display name. |
tab_submenu | no | TAB submenu path, e.g. Studio/Dynamics. |
icon | no | Icon identifier, e.g. SOP_rbd. |
source | no | Where the operator’s file lives in the produced package (after [stage] placement) — either a single archive-relative path or a per-platform table (see below). When set, hpm pack checks it against the produced archive. |
Per-platform source (multi-platform DSOs)
A single HDA ships at the same path in every archive, so one source string
works. But a compiled operator’s binary differs per platform
(.so / .dll / .dylib, often under dso/<platform>/), so its source is a
table keyed by platform — mirroring [stage.platform.*]. The keys must appear
in [compat].platforms:
[[operators]]
kind = "dso"
type_name = "studio::fast_scatter"
label = "Fast Scatter"
category = "Sop"
source = { linux-x86_64 = "dso/linux-x86_64/scatter.so", macos-aarch64 = "dso/macos-aarch64/scatter.dylib", windows-x86_64 = "dso/windows-x86_64/scatter.dll" }
Each per-platform hpm pack resolves source to the entry for the platform it
targets and emits that concrete path. An operator whose table does not list
the platform being packed is omitted from that platform’s index — its file
isn’t in that package. (Omit source entirely to index an operator by
name/category with no path and no check.)
Verifying sources at pack time
By default hpm pack warns when a declared source is missing from the
produced archive. Pass --verify-assets to make it a hard error instead (and
delete the invalid archive) — recommended in CI so a package never publishes an
index advertising a file it doesn’t ship. The check runs against the built
archive, so build compiled artifacts before packing.
Why declarations rather than reading the files? The HDA container format is
officially undocumented and can change between Houdini versions, and a
compiled HDK plugin (DSO) does not expose its operator names offline at all —
they are C++ constructor arguments that only materialise inside Houdini. A
declaration in hpm.toml is stable, version-proof, and a single source of
truth. The author already knows exactly what they ship.
Platform and Houdini-version filtering come for free from elsewhere in the
manifest: a DSO’s architecture is implied by the per-platform archive it ships
in ([compat].platforms + [stage]), and the supported Houdini range is
[compat].houdini. See hpm pack for the emitted index shape.
Global configuration
HPM reads ~/.hpm/config.toml if it exists, then <cwd>/.hpm/config.toml
(project override) if it exists. Layering is presence-aware: each file
overrides exactly the values it sets, so a project config that omits a
section (or a single key) leaves the user-level value in place, and
anything no file sets falls back to the built-in default. Setting only
storage.home_dir re-derives the cache/packages/registry directories
under it unless they are set explicitly. A malformed config file is a
hard error, not a silent fallback 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 = "tumbletrove"
url = "https://api.tumbletrove.com/v1/registry"
type = "api"
[signing]
key_path = "/Users/me/.hpm/signing.pem" # fallback for `hpm pack`
What each section controls
[install]
path,parallel_downloads— accepted but currently inert. Both deserialize and validate, but no code outsidehpm-configreads them. The per-project package directory is hardcoded to<project>/.hpm/packages(Config::project_paths), so overridingpathhas no effect. Left in the schema for compatibility; do not rely on either.
[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
├── global/ # `hpm global` ledgers, one per Houdini version
│ └── houdini-21.0.json # what hpm installed into Houdini's user prefs
├── 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
│ ├── studio.utility-nodes.json
│ └── studio.material-library.json
└── (your package sources)
Houdini integration
hpm install writes one Houdini package.json per dependency into
<project>/.hpm/packages/{creator}.{slug}.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/a1b2c3d4e5f60718/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/, …).
Project installs vs. global installs
The two differ only in where the manifest lands and what may be deleted around it:
hpm install (project) | hpm global add | |
|---|---|---|
| Manifest location | <project>/.hpm/packages/ | Houdini’s user prefs packages/ |
| Loaded when | Houdini is pointed at the project via HOUDINI_PACKAGE_PATH | Every session of that Houdini version |
| Version scoping | enable expression only | enable plus a per-version directory |
Reproducible via hpm.lock | Yes | No — global installs are user state, not project state |
| Unrecognized files in the target dir | Swept; hpm owns that directory | Left alone; hpm owns only files it recorded |
Use a project install for anything a team needs to reproduce. Use a global install for tools you want in your own everyday Houdini.
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. |
Structured output is supported by list, check, update, search, and
pack. Commands whose output is inherently interactive (init, add,
remove, install, build, audit, run, clean, registry, global,
completions)
reject a non-human --output with a clear error rather than silently
ignoring it.
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 # 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.
Dependency failed to resolve on install
hpm install reports the specific cause in the error chain. The two most
common forms:
Package error: Failed to sync project dependencies: Cannot install foo 1.2.3:
no package registries are configured. Run 'hpm registry add <url>' to add one.
Package error: Failed to sync project dependencies: Failed to resolve foo 1.2.3
from registry: Version '1.2.3' of package 'foo' not found in registry
The first means no registry is configured — add one with hpm registry add.
The second means the package or version isn’t in any configured registry:
confirm the name (registries index by creator/slug, not the bare slug) and
the version, then hpm registry update. Add -vv for the same detail plus a
usage hint.
Houdini version mapping failed
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.
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/{creator}.{slug}.jsonhas aPYTHONPATHentry for the offending package. - The venv directory it points to exists and contains
site-packages/. If it doesn’t, remove the venv directory and re-runhpm installto rebuild it — venvs are a content-addressed cache and are always safe to delete.
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.11 |
| 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 — the last line that supported them.
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), HPM installs them once and shares the venv:
Package A: numpy==1.24.0, requests==2.28.0 → hash a1b2c3d4e5f60718
Package B: numpy==1.24.0, requests==2.28.0 → hash a1b2c3d4e5f60718 (shared with A)
Package C: numpy==1.25.0, requests==2.28.0 → hash f6e5d4c3b2a19008 (different)
The hash is a SHA-256 over the sorted resolved set plus the Python version, truncated to 16 hex characters. Any change to the resolved set — a newer lockfile, an added or upgraded package, a different Python version — produces a new hash and therefore a new venv. Extras affect the hash only through the packages and versions they pull into the resolved set; they are not hashed themselves.
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/{creator}.{slug}.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,
as creator/slug@version entries, and hpm clean --python-only uses them to
detect orphans. The list accumulates: one venv is shared by every project
that resolves to the same dependency set, and each install only knows about
its own project’s packages.
A venv that records owners is orphaned when none of those owners is still
installed. A venv with no recorded owners has unknown provenance rather
than no users — that is what a per-script venv looks like, and what venvs
created by hpm 0.28.1 and earlier look like, since no earlier version
recorded owners. Those cannot be matched against the installed set at all,
so they are collected once they have been idle for 30 days (last_used in
metadata.json, refreshed every time the venv is used).
Collecting a venv is never destructive: it is a content-addressed cache and is rebuilt on demand. The idle window only trades rebuild time against disk.
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.
A per-script venv is deliberately isolated — it carries only the script’s
own requirements. When a script ships in the package and needs to import
it (a render-farm task, a packaged CLI), set package-env = true instead;
hpm run then executes it inside the package’s full resolved environment:
the merged venv across the project and its installed dependencies, with
every package’s python/ on PYTHONPATH — the same environment this guide
describes for Houdini, reused for an out-of-Houdini process. See
Running inside the package environment.
Houdini integration
Once hpm install has produced the venv, it writes a Houdini manifest per
dependency into <project>/.hpm/packages/{creator}.{slug}.json:
{
"hpath": ["/Users/me/.hpm/packages/studio/geometry-tools@1.0.0"],
"env": [
{
"PYTHONPATH": {
"method": "prepend",
"value": "/Users/me/.hpm/venvs/a1b2c3d4e5f60718/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/{creator}.{slug}.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, delete the venv directory and re-runhpm install— venvs are a content-addressed cache and are always safe to rebuild.
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>/ # 16-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
impl ResolvedDependencySet {
/// Generate a unique hash for this dependency set
pub fn hash(&self) -> String {
let mut hasher = Sha256::new();
hasher.update(format!("python:{}", self.python_version));
for (name, version) in &self.packages {
hasher.update(format!("{}:{}", name, version));
}
hasher
.finalize()
.iter()
.map(|b| format!("{:02x}", b))
.collect::<String>()[..16]
.to_string()
}
}
}
packages is a BTreeMap<String, String> — name to resolved version — so
iteration is already in sorted order and no explicit sort is needed. Note
that only the name and version feed the hash; extras are not part of the
resolved set and do not affect it.
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 16-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/{creator}.{slug}.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] [--if-not-exists]
Examples:
# API registry
hpm registry add https://api.tumbletrove.com/v1/registry --name tumbletrove
# 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"
# Idempotent add: succeed silently if a registry with this name already
# exists, instead of erroring. Useful in provisioning scripts.
hpm registry add https://api.tumbletrove.com/v1/registry --name tumbletrove --if-not-exists
By default, adding a registry whose name already exists is an error. Pass
--if-not-exists to treat that as a no-op (exit 0) so automated setup can
re-run safely.
This writes to ~/.hpm/config.toml:
[[registries]]
name = "tumbletrove"
url = "https://api.tumbletrove.com/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 = "tumbletrove"
url = "https://api.tumbletrove.com/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" }
Or from the command line, which records the pin in hpm.toml for you:
hpm add studio/internal-tool@1.0.0 --registry studio
The pin is authoritative. Resolution, install, and hpm update consult
only the named registry — there is no fallback to the rest of the set, since
falling back is exactly what a pin exists to prevent. Naming a registry that
is not configured is an error rather than a silent search elsewhere.
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 a dependency’s source to survive someone else adding a registry that happens to carry the same name.
Note the lock file does not record which registry a dependency came from —
it pins the version, checksum, and resolved URL. The registry key in
hpm.toml is what makes the source reproducible.
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: nothing to do — API registries are always queried live,
so there is no cache to invalidate. HPM reports
OK (live). - Git registries:
git pullthe index repository to pick up new packages and versions.
Run hpm registry update after a new version is published to a Git
registry. For API registries a newly published version is visible
immediately, with no refresh step.
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 each configured registry in turn, in the order they
appear in the config. If no registries are configured, HPM prints a hint to
run hpm registry add and exits cleanly (emitting [] under
--output json).
With --output json, results are emitted as a JSON array suitable for
piping into other tooling:
hpm search geometry --output json | jq '.[].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.
How a yank affects resolution depends on how you asked for the version:
- A range requirement (
^1,*,>=2, <3) skips yanked versions when picking the highest match. - An exact pin (
1.2.0) still resolves, yanked or not. This is deliberate, so that a lockfile pinning a version that was later yanked keeps installing rather than breaking.
Caching
HPM caches Git registry indexes under ~/.hpm/registry/<name>/. The cache
is per-registry, not per-project, so multiple projects share the same clone.
- Git cache: a local clone of the index repository. Updated by
hpm registry update. - API registries are not cached. Every resolution and search hits the
API live, so there is nothing under
~/.hpm/registry/for them.
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)
[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 = "url"
url = "https://api.tumbletrove.com/v1/registry/packages/studio/utility-nodes/1.2.0/download"
version = "1.2.0"
[metadata]
generated_at = "2026-03-14T10:30:00Z"
hpm_version = "0.29.2"
platform = "linux-x86_64"
Verification happens at two points:
- On download: the archive bytes are hashed and compared against the
registry entry’s
cksumbefore anything is extracted. A mismatch removes the corrupt cached archive and aborts the install. Registry entries without a checksum install with a warning. - On every
hpm install: each cached package in~/.hpm/packages/is verified against the checksum inhpm.lockbefore use.
A mismatch at either point aborts 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.
hpm signs, but does not verify. As of 0.29.2 signing is a produce-only feature.
hpmemits a signature andkeyId, and the registry entry carriessig/kidfields, but no hpm install path reads or checks them — the only integrity check on download is the SHA-256 checksum. Verifying a signature is currently the responsibility of whoever consumes the artifact (your registry, your CI, or a manualopensslcheck). Everything in this section describes the wire format such an external verifier needs; none of it describes a check hpm performs on your behalf.
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. Your verifier 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. Note that this check happens in your verifier — hpm does not perform it (see the note under Package signing). - TumbleTrove publishes creator public keys at
https://api.tumbletrove.com/v1/signing/public-keys, which is the key source an external verifier should resolvekeyIdagainst.
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/{creator}.{slug}.json | Per-dependency Houdini manifest. Auto-generated. |
<project>/.hpm/config.toml | Optional project-level configuration override. |
Outside both trees, hpm global writes into Houdini’s own per-user
preferences directory:
| Path | Contents |
|---|---|
<houdini-user-prefs>/packages/hpm-{creator}.{slug}.json | Houdini manifest for a globally installed package. |
~/.hpm/global/houdini-{X.Y}.json | Ledger of what hpm global installed for that Houdini version. |
This is the only directory hpm writes to that it does not own — it is shared
with SideFX’s own files, other tools, and anything you put there by hand. hpm
therefore never scans it to decide what to delete: writes are confined to the
hpm- prefixed filename for the package being installed, and deletions are
confined to the exact filename recorded in the ledger. A global install is
per-user state and is not captured by hpm.lock; prefer project installs for
anything that has to be reproducible.
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. |
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.
- Unknown signer. An unknown party uploading an archive that claims to be from a creator is not something hpm detects.
hpm pack --keyproduces an Ed25519 signature andkeyId, but hpm never verifies them on install — mitigation depends entirely on a verifier your registry or CI runs. See Package signing. - Compromised upstream sources. If the registry or Git server itself is compromised, HPM trusts what it serves. Checksums pin the bytes to what the lockfile recorded, but a first-time resolve takes the registry at its word, and there is no signature check to fall back on.
- 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.
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. |
Architecture
Technical overview of HPM — system design, dependency resolution, cleanup, storage, and Python integration. Intended for contributors and integrators.
Table of contents
- System overview
- Crate layout
- Core types
- Dependency resolution
- Install flow
- Project-aware cleanup
- Storage architecture
- Python integration
- Security and performance
- Extension points
System overview
HPM is a modular monolith. A single binary (hpm-cli) drives a handful of
focused library crates. Responsibilities are split along stable seams:
manifest parsing, configuration, resolution, storage, Python, and errors all
live in their own crates so they can be reused (e.g., a desktop app can
depend on the library crates and skip the CLI entirely).
┌────────────────────────────────────────────────────────────────────────┐
│ CLI (hpm-cli) │
│ • subcommands: init, add, remove, install, update, list, search, │
│ run, check, build, pack, audit, clean, registry, global, │
│ completions │
│ • output formats: human, json, json-lines, json-compact │
│ • shell completions: bash, zsh, fish, powershell, elvish │
└───────────────────────────────────┬────────────────────────────────────┘
│
┌───────────────────────────────────▼────────────────────────────────────┐
│ Core orchestration (hpm-core) │
│ • StorageManager: global package install/remove/list │
│ • ProjectDiscovery: scan configured paths for active projects │
│ • LockFile / LockedDependency / PackageSource │
│ • Registry trait + ApiRegistry, GitRegistry │
│ • ArchiveFetcher, packer │
└─────────┬──────────────────┬──────────────────┬─────────────────────────┘
│ │ │
┌─────────▼────────┐ ┌───────────────┐ ┌───────────────┐
│ hpm-package │ │ hpm-assets │ │ hpm-config │
│ PackageManifest│ │ Asset │ │ Config │
│ DependencySpec │ │ AssetKind │ │ Storage/ │
│ StageConfig │ └───────────────┘ │ Projects/ │
│ Platform │ │ Signing │
│ HoudiniPackage │ └───────────────┘
└──────────────────┘
Python tooling — bundled uv, content-addressable venvs, the
Houdini→Python ABI mapping, and per-script venv resolution — lives in
hpm_core::python. It was a separate hpm-python crate through 0.16
but folded into hpm-core since it had no consumers other than
hpm-core and hpm-cli.
hpm-package and hpm-assets are the workspace leaves — neither depends on
another HPM crate. hpm-config depends on hpm-package (it reuses the
manifest’s registry and dependency types), so it is not a leaf despite
sitting alongside them in the diagram above.
Each crate defines its own error type (e.g. StorageError, ConfigError)
via thiserror. Errors surface to the user through CliError in hpm-cli,
which converts them to exit codes and help hints.
Key non-functional properties:
- Language: Rust, edition 2024, MSRV 1.85.
- Async runtime: Tokio, multi-thread.
- Configuration: TOML throughout (manifests, lock file, global config).
- Testing: unit, integration, and property-based (proptest) tests.
Crate layout
| Crate | Responsibility |
|---|---|
hpm-cli | Command-line frontend (clap). Turns command-line invocations into calls on the library crates. |
hpm-core | Storage, project discovery, lock file, registry trait + two implementations, archive fetching/packing, Python tooling (python submodule: bundled uv, venvs, cleanup). |
hpm-package | hpm.toml parsing and validation, dependency/Python dependency types, Houdini package.json generation, platform enum. |
hpm-assets | Operator asset-index model (Asset, AssetKind) emitted by hpm pack from [[operators]] declarations. |
hpm-config | Global and project configuration loading and merging. |
Dependencies flow downward: hpm-cli depends on everything else, hpm-core
depends on package, assets, and config, and so on. No crate depends upward.
Domain errors (StorageError, ConfigError, …) live in the crate that
produces them.
Core types
Selected public types. See cargo doc --workspace --no-deps --open for the
full list.
hpm-package
#![allow(unused)]
fn main() {
pub struct PackageManifest {
pub package: PackageInfo,
pub compat: CompatConfig,
pub stage: StageConfig,
pub registries: Vec<RegistryConfig>,
pub dependencies: IndexMap<String, DependencySpec>,
pub python_dependencies: IndexMap<String, PythonDependencySpec>,
pub runtime: IndexMap<String, ManifestEnvEntry>,
pub scripts: PackageScripts,
pub operators: Vec<OperatorDecl>,
}
// All collections and section structs use `#[serde(default,
// skip_serializing_if = ...)]` so an absent TOML section round-trips to
// the same in-memory representation as an empty one. `Option` was
// removed in 0.17 — see CHANGELOG.
pub struct PackageScripts {
pub commands: IndexMap<String, ScriptEntry>, // flat entries under [scripts]
}
// Untagged: serialises as either a bare string ("cargo build") or a table
// form { cmd, python?, requirements?, label?, description?, package-env? }.
// The table form opts the script into
// a uv-managed venv resolved on demand by `hpm run`, and supports
// conditional cmd values via the same `when` grammar `[runtime]` uses.
pub enum ScriptEntry {
Plain(String),
WithEnv(ScriptEnv),
}
pub struct ScriptEnv {
pub cmd: EnvValue, // flat string or [{ when = { os }, set }, ...]
pub python: Option<String>, // e.g. "3.11"
pub requirements: Vec<String>, // e.g. ["PySide6>=6.6"]
pub label: Option<String>, // display name for UIs; hpm never acts on it
pub description: Option<String>, // one-line description for tooltips/help
pub package_env: bool, // run inside the package's full resolved env
}
pub struct PackageInfo {
pub path: PackagePath, // "creator/slug", validated kebab-case
pub name: String, // freeform display name
pub version: String, // semver
pub description: Option<String>,
pub authors: Vec<String>, // empty vec encodes "no authors declared"
pub license: Option<String>,
pub readme: Option<String>,
pub homepage: Option<String>,
pub repository: Option<String>,
pub documentation: Option<String>,
pub keywords: Vec<String>,
pub categories: Vec<String>,
}
pub enum DependencySpec {
// The bare-string shorthand `pkg = "1.0.0"` (de)serializes as
// Registry { registry: None, optional: false }.
Registry { version: String, registry: Option<String>, optional: bool },
Url { url: String, version: String, optional: bool },
Path { path: String, optional: bool, link: bool }, // link=true → symlink/junction install
}
pub enum Platform {
LinuxX86_64, // "linux-x86_64"
LinuxAarch64, // "linux-aarch64"
MacosX86_64, // "macos-x86_64"
MacosAarch64, // "macos-aarch64"
WindowsX86_64, // "windows-x86_64"
WindowsAarch64, // "windows-aarch64"
Universal, // "universal" — OS-agnostic (pure-Python / data)
}
}
hpm-core
#![allow(unused)]
fn main() {
pub struct LockFile {
pub package: LockPackageInfo,
pub dependencies: BTreeMap<String, LockedDependency>,
pub python_dependencies: BTreeMap<String, LockedPythonDependency>,
pub metadata: Option<LockMetadata>,
}
pub struct LockedDependency {
pub version: String,
pub checksum: Option<String>, // "sha256:<hex>"
pub source: LockedSource, // Url { url, version } | Path { path }
}
#[async_trait]
pub trait Registry: Send + Sync {
async fn search(&self, query: &str) -> Result<SearchResults, RegistryError>;
async fn get_versions(&self, name: &str) -> Result<Vec<RegistryEntry>, RegistryError>;
async fn get_version(&self, name: &str, version: &str) -> Result<RegistryEntry, RegistryError>;
async fn refresh(&self) -> Result<(), RegistryError>;
fn name(&self) -> &str;
}
}
hpm-core::python
#![allow(unused)]
fn main() {
pub struct PythonVersion {
pub major: u8,
pub minor: u8,
pub patch: Option<u8>,
}
pub struct ResolvedDependencySet {
pub python_version: PythonVersion,
pub packages: BTreeMap<String, String>, // name → exact version
}
pub struct VenvManager { /* … */ }
impl VenvManager {
pub async fn ensure_virtual_environment(
&self,
resolved: &ResolvedDependencySet
) -> Result<PathBuf, PythonError>;
}
}
hpm-config
#![allow(unused)]
fn main() {
pub struct Config {
pub install: InstallConfig,
pub storage: StorageConfig,
pub projects: ProjectsConfig,
pub registries: Vec<RegistrySourceConfig>,
pub signing: SigningConfig,
}
pub struct StorageConfig {
pub home_dir: PathBuf, // default: $HOME/.hpm
pub cache_dir: PathBuf,
pub packages_dir: PathBuf,
pub registry_cache_dir: PathBuf,
}
}
Configuration is layered: defaults → ~/.hpm/config.toml →
<cwd>/.hpm/config.toml. Files are parsed as presence-aware overlays
(ConfigOverlay), so each layer overrides exactly the values it sets — a
file that omits a section leaves the lower layers untouched.
Dependency resolution
HPM currently does naive per-package version selection: for each declared
registry dependency, query the registry, filter non-yanked versions, and
pick the highest that matches the spec’s VersionReq. There is no
transitive constraint solving — every dependency is treated as a direct
dependency, and registry packages don’t declare their own deps in a way
the install path consumes. A proper constraint solver (PubGrub-style or
otherwise) will be reintroduced if/when transitive resolution becomes a
real requirement.
Version requirement grammar
HPM manifest values accept the usual semver constraint operators:
| Form | Meaning |
|---|---|
=1.2.3, 1.2.3 | Exact. |
^1.2.3 | Compatible with the given major (>=1.2.3, <2.0.0). |
~1.2.3 | Patch updates allowed (>=1.2.3, <1.3.0). |
>=1.0.0, <2.0.0 | Explicit range. |
Install flow
The install command itself is a thin shell — it loads the manifest +
lockfile, builds a ProjectManager, and calls sync_dependencies().
The desktop client uses the same ProjectManager entry point, so both
clients run the same flow.
┌──────────────────────────────────────────────────────────────────────┐
│ hpm install (CLI) ── ProjectManager::sync_dependencies ── │
├──────────────────────────────────────────────────────────────────────┤
│ 1. Load hpm.toml │
│ 2. If hpm.lock exists: │
│ verify cached packages against stored checksums │
│ warn if metadata.generated_at > 90 days │
│ 3. Fetch + install in parallel (one task per dep, JoinSet): │
│ Registry → query registry, exact-version lookup │
│ → ArchiveFetcher → ~/.hpm/fetch/ │
│ → install_into_cas → ~/.hpm/packages/<slug>@<v>/ │
│ Url → ArchiveFetcher (no registry query) │
│ → install_into_cas │
│ Path → install_as_dev_copy (or install_as_dev_link │
│ if { link = true }) │
│ → ~/.hpm/packages/_dev/<slug>@<v>/ │
│ (real dir for copy, symlink/junction for │
│ link mode — both honor _dev/ CAS isolation) │
│ Already-in-CAS deps short-circuit (avoids the install_into_cas │
│ remove-and-recopy that breaks on Windows when Houdini is open). │
│ 4. Merge [python_dependencies] from root + every dep manifest │
│ Python ABI = root manifest's [compat].houdini lower bound │
│ 5. Ensure managed CPython installed under ~/.hpm/uv-python/ │
│ (uv python install <ver>) — auto-downloads on clean machines │
│ 6. Resolve with bundled uv, hash the resolved set, pick or │
│ rebuild ~/.hpm/venvs/<hash>/ │
│ 7. uv pip install --python <venv>/bin/python │
│ 8. Write <project>/.hpm/packages/{creator}.{slug}.json per dep │
│ 9. Sweep stale <project>/.hpm/packages/ entries │
│ 10. CLI step: build new lockfile from sync's InstallOutcomes │
│ (backfilled from the prior lockfile for short-circuited entries) │
│ and write hpm.lock │
└──────────────────────────────────────────────────────────────────────┘
Note: registry resolution currently uses each dep spec’s version string
as an exact lookup against the registry. Range specs like ^1.0.0 in
[dependencies] won’t resolve through hpm install today — hpm update
is the path that re-resolves ranges (querying all versions, filtering by
VersionReq, picking the highest non-yanked) and rewrites the spec to
the resolved exact version.
--frozen-lockfile aborts if loading the existing lockfile fails, if any
cached checksum mismatches, or if the freshly-resolved set differs from
the prior lockfile.
Project-aware cleanup
hpm clean removes packages and venvs no active project depends on. The
identity of “active projects” comes from the [projects] config section.
I = installed packages in ~/.hpm/packages/
P = active projects discovered via ProjectDiscovery
D(p) = direct deps declared by project p
T(I) = transitive closure over the dependency graph
orphans = I \ T( ⋃ D(p) )
Implementation outline:
#![allow(unused)]
fn main() {
// crates/hpm-core/src/graph.rs
impl DependencyGraph {
/// BFS from each root, returning every `PackageId` reachable along
/// dependency edges. The roots themselves are included.
pub fn mark_reachable_from_roots(&self, roots: &[PackageId]) -> HashSet<PackageId> {
let mut reachable = HashSet::new();
for root in roots {
let Some(&start) = self.index_of.get(root) else {
continue;
};
let mut bfs = Bfs::new(&self.graph, start);
while let Some(idx) = bfs.next(&self.graph) {
reachable.insert(self.graph[idx].id.clone());
}
}
reachable
}
}
}
DependencyGraph wraps a petgraph graph; its fields are private and it
exposes no orphans() of its own. Taking the set difference against what is
actually installed on disk is StorageManager::find_orphaned_packages
(crates/hpm-core/src/storage/cleanup.rs), which calls
mark_reachable_from_roots and subtracts the result from the installed set.
Project discovery scans explicit_paths plus recursive walks of
search_roots, respecting max_search_depth and skipping directory names
that match ignore_patterns. Only directories containing an hpm.toml are
considered projects.
Python venv cleanup uses the same principle against the metadata.json files
in each venv, which list the HPM packages using that venv.
Storage architecture
HPM stores everything under ~/.hpm/ on every supported platform. The
layout is content-addressable where it helps:
~/.hpm/
├── config.toml
├── packages/ # canonical CAS — written by StorageManager
│ ├── slug@1.0.0/ # registry / URL installs
│ │ ├── hpm.toml
│ │ ├── (package sources)
│ │ └── … (Houdini convention subdirs)
│ └── _dev/ # path-installed (dev) packages
│ └── slug@1.0.0/ # never substituted for a registry hit
│ └── … # at the same (slug, version)
├── fetch/ # ArchiveFetcher staging — extracted
│ └── creator-slug-1.0.0/ # archives live here briefly before
│ # install_into_cas copies into packages/
├── venvs/
│ └── <12-char hash>/ # hash of resolved set + Python version
│ ├── pyvenv.cfg
│ ├── bin/ # or Scripts/ on Windows
│ ├── lib/pythonX.Y/site-packages/
│ └── metadata.json
├── cache/ # download archive cache
├── registry/ # one subdir per registry
│ └── <registry name>/
├── tools/ # bundled uv binary
├── uv-cache/ # isolated uv cache
├── uv-config/
├── uv-python/ # managed CPython installs (UV_PYTHON_INSTALL_DIR)
└── fetch/ # staging for downloaded archives
hpm install routes URL/registry deps through a two-step flow:
ArchiveFetcher downloads + extracts into ~/.hpm/fetch/,
then StorageManager::install_into_cas copies into the canonical CAS at
~/.hpm/packages/<slug>@<version>/. Path deps skip the fetcher entirely
and go straight to ~/.hpm/packages/_dev/<slug>@<version>/ via
install_as_dev_copy.
Per-project:
<project>/
├── hpm.toml
├── hpm.lock
└── .hpm/
├── config.toml # optional
└── packages/
├── utility-nodes.json # generated Houdini manifests, one per dep
└── material-library.json # absolute paths into the global CAS
Global packages are shared across projects; each project holds only the per-dependency Houdini manifest and a lockfile. This keeps disk usage sane across a studio’s worth of projects. The Houdini JSON manifests carry absolute CAS paths, so the project tree contains no symlinks pointing into the global storage.
Each per-package manifest is named <creator>.<slug>.json. The creator
segment is part of the filename because the slug alone is not unique: two
creators may publish the same slug, and keying the file on the slug let the
second install silently overwrite the first, so Houdini loaded only one of
them. . is the separator rather than - because both segments may
themselves contain - (a-b/c and a/b-c would collide), while . is not
a legal segment character.
sync_dependencies sweeps stale per-package manifests at the end of every
install run: any .json in <project>/.hpm/packages/ whose stem is no
longer in the resolved dependency set is removed. Without this, a manifest
written by a previous run (e.g. for a path dependency that has since been removed)
would keep loading the package on Houdini launch even though hpm.toml no
longer asks for it. Manifests written by an older hpm under the bare
<slug>.json name are not in the current scheme, so the same sweep clears
them on the next install — otherwise the package would load twice, once per
filename. Note this makes <project>/.hpm/packages/ an hpm-owned directory:
unrecognized .json files placed there by hand do not survive a sync.
Global installs
hpm global writes the same Houdini manifest into Houdini’s per-user
preferences directory (hpm_core::houdini_prefs resolves it per platform,
honouring HOUDINI_USER_PREF_DIR and its __HVER__ placeholder) instead of
into a project. It reuses the whole install path — the same RegistrySet,
the same CAS, the same shared venvs, and the same manifest builder
(build_houdini_package, a free function precisely so both callers produce
byte-identical output).
The one structural difference is directory ownership, and it inverts the
sweep described above. <project>/.hpm/packages/ belongs to hpm, so the
project installer may delete anything it does not recognise there. Houdini’s
user packages/ directory belongs to the user: it holds files written by
SideFX, by other tools, and by hand. Applying the sweep there would delete
them. So global installs are tracked in a ledger at
~/.hpm/global/houdini-<X.Y>.json, and every operation is driven off it —
list reads it, remove deletes exactly the filename it records, and
nothing ever scans the directory to decide what to delete.
The ledger is also a GC root. find_orphaned_packages marks reachability
from discovered projects, and a globally installed package belongs to no
project; without its ledger entry it is unreferenced by construction, so
cleanup would delete the store directory while the manifest in Houdini’s
preferences kept pointing at it. An unreadable ledger aborts cleanup rather
than being treated as empty — “no global installs” and “cannot tell” must not
look the same to a collector.
Compatibility is checked before anything is written. The emitted enable
expression is evaluated by Houdini at startup, so a package whose
[compat].houdini excludes the target version would install successfully and
then load nothing, silently. check_compatible turns that into an install-
time error.
Path dependencies install into ~/.hpm/packages/_dev/<slug>@<version>/
rather than the registry CAS at ~/.hpm/packages/<slug>@<version>/.
The _dev subtree is invisible to list_installed, so a dev install of
foo@1.0.0 cannot be served as the cached install for a registry
resolution at the same coordinate from a different project.
_dev/ entries are garbage-collected by a parallel cleanup pass driven
directly off project path-dependencies (since the CAS-orphan logic
deliberately can’t see them). For each discovered project,
find_orphaned_dev_installs parses the manifest, resolves every
{ path = "..." } dep’s source manifest to its (slug, version), and
unions those into the “needed” set. Anything in _dev/ whose dir-name
encoding falls outside that set is orphan. A project with an unreadable
path-dep source logs a warning and skips that dep — a broken project
doesn’t bypass cleanup of other dev installs; re-running hpm install
re-creates whatever the project still needs. Removal goes through the
same remove_install_entry primitive used by clear_existing_install
and remove_package, so link installs are unlinked without traversal.
Path deps come in two install styles, selected by the link field on
the manifest’s { path = "...", link = ? } spec:
- Copy (default,
link = false):install_as_dev_copysnapshot-copies the workspace into a content-addressed_dev/<slug>@<version>/<source-hash>/directory (see below). Subsequent working-tree edits don’t reach the install until the nexthpm install. - Link (
link = true):install_as_dev_linkcreates a symlink (Unix) or NTFS junction (Windows) at_dev/<slug>@<version>/pointing at the canonicalized workspace. Edits are live; Houdini’s HPATH resolution follows the link transparently. Junctions on Windows side-step the Developer Mode / admin requirement that NTFS directory symlinks carry, so the workflow is viable on a stock Houdini workstation.
Native-binary packages are the exception: when a link = true path dep
declares concrete [compat].platforms (anything other than universal),
install_inner downgrades it to a copy — see
CompatConfig::declares_native_platforms. A junction/symlink would make
the workspace’s build output the very DSO/DLL a running Houdini has
memory-mapped, so an in-place rebuild fails (Windows LNK1104 /
ERROR_SHARING_VIOLATION), and link-mode buys nothing for native code
anyway — a mapped DSO can’t hot-reload into a live session; it needs a
relaunch, which re-copies and re-runs prepack. Pure-data / pure-Python /
universal-only link installs are unaffected.
Dev copies are content-addressed so a re-sync never clears a directory a
running Houdini has mapped. source_hash fingerprints the workspace (relative
path + length + mtime of every file, stat-only) and that hash names the
install directory: the copy lands at _dev/<slug>@<version>/<source-hash>/, and
the generated Houdini manifest points hpath there. install_inner stages the
copy into a hidden .stage-<pid>-<n> directory and commits it with an atomic,
race-tolerant rename (a concurrent install that loses the race drops its stage,
since the content is identical by construction). If the target hash directory is
already present and complete, the existing install is reused untouched — the
copy-mode analogue of the “already installed” short-circuits the registry/URL
specs have in install_one_dep.
The payoff is that a rebuild produces a new hash directory rather than
overwriting the old one: a concurrently-running Houdini keeps mapping the
directory it was launched from, so install_inner never remove_dir_alls a live
copy. Before content addressing, an in-place clear-and-recopy of a package a
second session still had mapped failed on Windows with os error 5 /
StorageError::PackageInUse; that failure is now structurally impossible on all
platforms. At install time only a stale link left by a prior link = true
install is cleared (clear_container_link, link-safe); the directory of live
hashes is left in place.
Superseded hash directories accumulate across a dev-iteration loop and are
reclaimed by hpm clean: cleanup_unused_dev_installs removes whole orphan
containers (tolerant of in-use) and, for still-referenced installs, prunes every
hash directory except the one matching the current source
(prune_stale_dev_hashes). Reclamation is best-effort and carries the same “run
when Houdini sessions are closed” expectation as CAS package cleanup — a copy
still mapped by a live process is skipped (on Windows the OS lock fails the
removal) rather than force-removed.
Both install replacement (clear_existing_install) and orphan cleanup
(remove_package) are symlink-safe: each checks symlink_metadata (plus
junction::exists on Windows) before deciding between remove_dir_all
(real dir) and remove_file / junction::delete (link entry), via a
shared remove_install_entry primitive. Without this, a remove_dir_all
on a Windows junction would recurse into and delete the user’s workspace
on the next sync or orphan sweep.
Both styles set InstalledPackage::is_dev = true. That flag flows
through to create_houdini_package_with_python, which forwards it to
ManifestEnvEntry::lower(.., is_dev). The install_source axis on each
conditional [runtime] variant is filtered there: variants gated to
"dev" only survive for path-installed packages, variants gated to
"registry" only survive for registry/URL installs, and variants with no
axis fire in both contexts. A registry-fetched install whose every
matching variant is install_source = "dev" produces no entry for that
key in the generated package.json, so dev-only paths never leak to a
published consumer.
Emission targets Houdini’s actual env semantics, as verified with the
hconfig conformance harness (hpm-core/src/houdini_conformance_tests.rs)
and modeled executably in hpm-core/src/houdini_env_model.rs: prepend /
append values are single-element JSON lists (a flat-string first
definition makes a custom variable non-mergeable — every later entry
overwrites it, whatever its method), set is emitted as a bare flat
string (Houdini has no set method; a list-form replace appends onto
a path-registered variable Houdini seeded flat-first, e.g. OCIO, so only a
flat string overwrites it cleanly — a genuinely conditional set still
falls back to replace since it can’t be flat), and conditional branches
are compiled mutually exclusive because
Houdini applies every matching conditional-array element, not the first
match. Project-level [runtime] entries are written once to
~hpm-project-overrides.json in the project packages dir; Houdini
processes package files in byte-wise ascending filename order and ~
sorts after every slug character, so that file always applies last — an
override lands exactly once, after (or, for set, replacing) every
package contribution.
Python integration
The Python layer runs on four ideas, in descending order of importance:
- Content-addressable venvs. Hash the resolved dependency set, use it as the venv directory name. Same hash → same venv → shared across packages.
- Bundled uv. A copy of
uvships with HPM and lives at~/.hpm/tools/uv. Its cache (~/.hpm/uv-cache/), config (~/.hpm/uv-config/), and managed CPython installs (~/.hpm/uv-python/, pinned viaUV_PYTHON_INSTALL_DIR) are isolated from any systemuvso HPM never perturbs your other Python work. - Self-bootstrapping Python.
uv pip compileanduv venvneed an interpreter. Before invoking either, HPM runsuv python install <ver>to ensure a managed CPython matching the project’s Houdini ABI exists. This is what makes a clean Windows install (no system Python anywhere) Just Work — without itpip compileerrors withNo interpreter found in virtual environments, managed installations, search path, or registry. The ensure step is process-cached, so it costs one fast filesystem probe per resolution. - Houdini manifest generation. For each HPM package that declares Python dependencies, HPM writes
<project>/.hpm/packages/{creator}.{slug}.jsonwithPYTHONPATHprepended at the shared venv’ssite-packages.
Hash function
#![allow(unused)]
fn main() {
// crates/hpm-core/src/python/types.rs
impl ResolvedDependencySet {
pub fn hash(&self) -> String {
let mut hasher = Sha256::new();
hasher.update(format!("python:{}", self.python_version));
for (name, version) in &self.packages {
hasher.update(format!("{}:{}", name, version));
}
hasher
.finalize()
.iter()
.map(|b| format!("{:02x}", b))
.collect::<String>()[..16]
.to_string()
}
}
}
packages is a BTreeMap<String, String>, so it iterates in sorted order
without an explicit sort. Only names and versions are hashed — extras are
not part of the resolved set.
Houdini→Python mapping
| Houdini | Python |
|---|---|
| 20.5+ | 3.11 |
| 21.x | 3.11 |
| 22.x | 3.13 |
The mapping is sourced from the lower bound of the root manifest’s
[compat].houdini — the project’s Houdini build is what determines the
embedded CPython ABI that wheels in the venv must match. A dependency
package’s own [compat].houdini describes a compatibility floor (the
oldest Houdini it runs on) and is not consulted for ABI selection: a
[compat].houdini = ">=21.0" package consumed by a Houdini-22 project
still gets a 3.13 venv.
Unsupported versions return an error. No silent fallback — an ABI-mismatched venv would break C-extension imports at Houdini launch instead of surfacing the mapping gap at install time. Houdini 19.x (Python 3.7) and 20.0–20.4 (Python 3.9) are unsupported: their Python interpreters are past upstream EOL.
Generated Houdini manifest
{
"hpath": ["/Users/me/.hpm/packages/studio/my-tool@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 Windows (;) and Unix (:) without embedding an
OS-specific joiner. The generator is
hpm_core::project::houdini_emit::build_houdini_package, a free function
shared by the project installer and hpm global so both emit identical
manifests.
Security and performance
Security
- Transport: TLS through rustls (pure Rust, no OpenSSL). Certificate verification against the platform trust store.
- Integrity: SHA-256 over every installed archive, recorded in
hpm.lock, verified before every install. - Signing:
hpm pack --keysigns archives with Ed25519 over PKCS#8 PEM private keys.keyId= first 8 bytes of public key, hex-encoded. See Security for the wire format. - Isolation: bundled
uvruns out-of-process with its own cache and config, never touching system state.
Performance
- Concurrent downloads: registry-fronted installs run in parallel, bounded by
[install].parallel_downloads(default 8). - Content-addressable dedup: both HPM packages (keyed by
slug@versionin the global CAS) and Python venvs (keyed by resolved-set hash) are deduplicated globally. - Cache hits:
uv’s cache eliminates wheel re-downloads across projects; HPM’s venv cache eliminates install work when a matching set is found. - Atomic filesystem ops: packages are extracted to a temp directory and renamed into place to avoid partial states.
Extension points
Programmatic configuration
The library crates can be used without the CLI. Embedders (desktop apps,
pipeline tools) build a Config in-memory by starting from
Config::default() and setting the fields they need:
#![allow(unused)]
fn main() {
use hpm_config::{Config, RegistrySourceConfig, RegistryType};
let mut config = Config::default();
config.add_registry(RegistrySourceConfig {
name: "tumbletrove".into(),
url: "https://api.tumbletrove.com/v1/registry".into(),
registry_type: RegistryType::Api,
});
config.install.path = "packages/hpm".into();
}
Registry trait
Any type that implements the Registry async trait can be plugged into a
RegistrySet and used for resolution. The built-in ApiRegistry and
GitRegistry are reference implementations.
For visibility-gated API registries (e.g. private packages an
authenticated user is entitled to see), embedders can pass a bearer
token through RegistrySet::from_configs_with_auth — the token is
attached as Authorization: Bearer <token> on every API-registry
request and marked sensitive so reqwest won’t log it. The token is
baked into the HTTP client at construction, so callers tracking a
refreshing token rebuild the RegistrySet per operation rather than
mutating one in place. Git registries currently ignore the token.
ProjectManager mirrors that contract via
ProjectManager::new_with_auth(.., Option<String>). The token is
stashed on the manager and forwarded to every RegistrySet it builds
internally — sync_dependencies for registry-form deps and
add_dependency’s registry-resolved path both go through
from_configs_with_auth with the stashed token. ProjectManager::new
delegates with None, so anonymous use is unchanged. As with the
RegistrySet variant, refreshing tokens are handled by rebuilding the
ProjectManager per operation.
Plugin system
Not implemented. The CLI surface is currently fixed at compile time. A dynamic plugin system is on the roadmap but not prioritized — most studios are better served by wrapping HPM in a higher-level pipeline tool.
API Overview
This document maps HPM’s crate layout to its key public types. For full API docs with signatures and examples, generate rustdoc locally:
cargo doc --workspace --no-deps --open
Crate graph
hpm-cli binary; clap dispatch, output formatting, exit codes
├── hpm-core storage, discovery, lock, registry, fetch, pack, python
│ ├── hpm-config
│ ├── hpm-package
│ └── hpm-assets
├── hpm-config layered config loading and merging
│ └── hpm-package
├── hpm-package manifest parsing, Houdini integration, deps (leaf)
└── hpm-assets operator asset-index model for `hpm pack` (leaf)
Python tooling (bundled uv, content-addressable venvs, Houdini→Python
mapping) lives in the hpm_core::python submodule. It was a separate
crate through 0.16 but collapsed into hpm-core since it had no
external consumers.
Leaves are hpm-package and hpm-assets — neither depends on anything in
the workspace, so they can be embedded by external tools without dragging in
the rest of HPM. hpm-config is not a leaf: it depends on hpm-package for
the shared registry and dependency types.
Each crate defines its own error type via thiserror (e.g. StorageError,
ConfigError). hpm-cli converts these into a single CliError with exit
codes and help hints.
Key types by crate
hpm-core
| Type | Purpose |
|---|---|
StorageManager | Global package install/remove/list. Backed by ~/.hpm/packages/. |
ProjectDiscovery | Scans [projects] paths to enumerate active HPM projects. |
DependencyGraph | Dependency graph over PackageIds; mark_reachable_from_roots powers orphan detection for hpm clean (the set difference itself lives in StorageManager::find_orphaned_packages). |
LockFile, LockedDependency, LockedPythonDependency, LockMetadata | hpm.lock types. |
LockedSource | Origin recorded in the lockfile for each dep — Url { url, version } or Path { path }. |
LockError | Read/Parse/Write/Serialize plus ChecksumMismatch and PackageMissing { package, expected_dir }. |
PackageSource | URL-only struct { url, version } — what ArchiveFetcher downloads. Path deps bypass the fetcher and use LockedSource::Path in the lockfile. |
cas_install_dir(packages_dir, name, version) | Canonical install path <packages_dir>/<slug>@<version> for a lockfile dep name. Used by LockFile::verify_checksums and any consumer that needs to find an installed package off the lockfile alone. |
fetcher_install_dir(packages_dir, name, version) | Staging path <packages_dir>/<safe_name>-<version> used by ArchiveFetcher while extracting; the result is then copied into the canonical CAS via install_into_cas. |
Registry (async trait) | Registry abstraction. ApiRegistry and GitRegistry implement it. |
RegistrySet | Composite that fans requests out to every configured registry. *_in variants (resolve_in, get_version_in, get_versions_in) restrict the lookup to one named registry, honouring a dependency’s registry pin; an unconfigured name is UnknownRegistry rather than a fallback to the rest of the set. |
global, global::ledger | hpm global installs into Houdini’s user preferences. build_houdini_package is shared with the project emitter; Ledger records what was installed per Houdini version and doubles as a hpm clean GC root. |
houdini_prefs | Per-platform Houdini user-preferences directory resolution, including HOUDINI_USER_PREF_DIR and its __HVER__ placeholder. The only hpm module that knows a Houdini installation’s conventions. |
ArchiveFetcher | Downloads and extracts registry-hosted archives. |
fetch_manifest | Free function: returns the parsed PackageManifest for (name, version) without project context. CAS hit reads from disk; CAS miss resolves+fetches+installs. Pass "" or "latest" to pick the highest semver. |
PackageRunEnv | Resolved runtime environment for a package-env script, produced by ProjectManager::resolve_package_env(extra_requirements): the merged venv (venv_bin, virtual_env) plus python_paths (each package’s python/ then the venv site-packages) for PYTHONPATH. Read-only — built from hpm.lock + the global store, mirroring what sync_dependencies resolves for Houdini. hpm run applies it for package-env = true scripts. |
script_run | The shared [scripts] runner — the single place the script-env contract (HPM_PACKAGE_ROOT, caller extra_env, and per-script-venv or package-env PATH/VIRTUAL_ENV/PYTHONPATH) is composed. Embedders implement ScriptSink (spawn + diagnostics) and drive run_prepack(manifest, names, package_root, extra_env, sink) (a [stage].prepack sequence, fail-fast) or run_script(manifest, name, package_root, extra_args, extra_env, sink) (one entry, returns the exit code). prepare_script(...) exposes just the env+command-line composition as a PreparedScript { command_line, working_dir, env } for embedders that own their spawn loop. hpm run and hpm build’s prepack both route through here, so a manifest feature picked up by one is picked up by all — no per-embedder drift. Prefer this over re-composing prepare_script_env by hand: the lower-level handle is easy to half-wire (e.g. forgetting to put the venv interpreter on PATH). |
packer | Produces signed/unsigned .zip archives for hpm pack. Public helpers: pack, create_archive, compute_archive_checksum/compute_bytes_checksum (SHA-256), sign_archive/sign_bytes (Ed25519, returns (base64_signature, hex_key_id)), load_signing_key / load_signing_key_from_pem. The byte-based variants are for tooling that mutates archive bytes after pack (e.g. third-party hosting that requires reshaping) and needs to recompute the hash + re-sign without round-tripping through disk. SigningKey is re-exported so callers don’t need a direct ed25519-dalek dep. |
collect_assets, AssetIndex, AssetIndexError | Builds the hpm pack operator asset index from a manifest’s [[operators]] declarations for a target platform (asset_index module). Maps each declaration to an hpm_assets::Asset (deriving namespace/version from the type name), resolving per-platform source tables to the target platform and dropping operators not shipped there. Verifies each resolved source exists in the produced archive — any that don’t are returned in AssetIndex::missing_sources for the caller to warn on or, with hpm pack --verify-assets, treat as fatal. |
hpm-package
| Type | Purpose |
|---|---|
PackageManifest | Parsed hpm.toml (every section). PackageManifest::from_path reads + parses, returning ManifestLoadError with the offending path. validate() returns the first structural error; validate_with(ValidationLevel) returns a ValidationReport { errors, warnings } separating hard errors from publish-quality advisories. |
ValidationLevel, ValidationReport | Strict runs structural checks only; Publish adds advisory warnings on missing description / authors / keywords / [compat].houdini. hpm check consumes the report; a future hpm publish can promote warnings to errors. |
ManifestLoadError | NotFound { path } / Read { path, source } / Parse { path, source }. Re-exported by hpm-core’s StorageError, ProjectError, DiscoveryError, and FetchManifestError. |
IoOp | Shared IO-error shape { op: &'static str, path: PathBuf, source: io::Error } used as the single Io(IoOp) variant in StorageError, ProjectError, and DiscoveryError. Construct via IoOp::wrap("read directory", &path, e) at call sites. |
PackagePath, PackagePathError | Validated creator/slug newtype. Kebab-case enforced at deserialization, so creator() and slug() return &str — no Option. |
PackageInfo | Contents of [package]. path: PackagePath is the canonical identifier; name, version, etc. are user-facing metadata. |
CompatConfig | Contents of [compat]. houdini: Option<HoudiniRange> parses and validates at deserialize time; houdini_min() extracts its lower bound for Python ABI selection. platforms: Vec<Platform> declares the native platforms this package supports — unknown identifiers fail at TOML parse via Platform::TryFrom<String>. Must reference platforms used in [stage.platform.*] entries. |
DependencySpec | Enum: Registry {..} | Url {..} | Path {..}. The bare-string shorthand pkg = "1.0.0" round-trips as Registry { registry: None, optional: false }. |
PythonDependencySpec | Untagged enum: Simple(String) | Detailed {..}. |
ManifestEnvEntry, EnvMethod | [runtime] entries (method, optional value, required flag) and methods (set/prepend/append). value is Option<EnvValue> — flat string or ordered variant list. lower(substitutions, is_dev) is the single emit path; install-source-gated variants are filtered before lowering. |
EnvValue, EnvValueBranch, Condition | Conditional [runtime] value support. Variants compile to Houdini’s expression-object array via compile_condition / lower_conditional. Axes: houdini (Cargo-style req), os, python (all runtime-evaluated by Houdini), plus install_source (typed InstallSource: dev / registry, filtered by hpm at install time). os is a typed OsKey; unknown axis values fail at manifest load. |
PackageScripts | [scripts] table. commands: IndexMap<String, ScriptEntry> — one entry per script, with per-host variation expressed inside ScriptEntry::cmd via the same when-grammar that [runtime] uses. |
ScriptEntry, ScriptEnv | Untagged enum: Plain(String) for the shorthand form, WithEnv { cmd: EnvValue, python?, requirements?, label?, description?, package_env } for the table form. resolve_cmd(host_os) picks the matching variant when cmd is conditional. Accessors python(), requirements(), label(), description(), needs_venv(), uses_package_env() work on both arms. package_env (package-env in TOML) opts the script into the project’s full resolved environment — see ProjectManager::resolve_package_env. label/description are optional, consumer-agnostic display metadata that HPM itself never acts on. |
StageConfig, PlatformStaging, StagePlatformRules, PlaceRule, ProfileStaging, StageProfileRules | [stage] table. output_dir (default "dist"), prepack script list, workspace include / exclude globs, and per-platform place = [{ from, to }] rules. from is a workspace-relative glob; to is either a directory (ends with /) or a literal archive path. [stage.profile.<name>] tables (StageProfileRules) layer per-profile prepack/include/exclude/place overrides onto the base; StageConfig::resolved_for_profile(name) returns the merged config a build uses. |
Platform | Canonical platform enum, mirroring the TumbleTrove API: LinuxX86_64, LinuxAarch64, MacosX86_64, MacosAarch64, WindowsX86_64, WindowsAarch64, Universal. os_key() returns Option<&str> (None for Universal). |
RegistryConfig, RegistryType | [[registries]] entries in manifests. |
OperatorDecl, OperatorKind, OperatorSource, SourceResolution | [[operators]] entries — the operators (node types) a package bundles, declared by the author so hpm pack can emit a searchable asset index. kind (Hda/Dso), type_name, and category are required; label, tab_submenu, icon, and source are optional. OperatorSource is either a single archive path or a per-platform table; OperatorDecl::resolved_source(platform) resolves it to a SourceResolution (Path / Unspecified / NotForPlatform). |
PackageTemplate | Scaffolding for hpm init (standard and --bare). |
HoudiniPackage, HoudiniNativePackage, HoudiniEnvValue | Houdini package.json output types. |
hpm-assets
| Type | Purpose |
|---|---|
Asset, AssetKind | The wire model hpm pack --json emits in its assets array. One flat object per bundled operator with a kind discriminator (hda_operator/dso_operator); None fields are omitted. Built from [[operators]] declarations by hpm_core::collect_assets. |
split_type_name(type_name) | Best-effort split of a namespaced operator type name into (namespace, base, op_version) following Houdini’s namespace::name::version grammar (version detected as digits-and-dots). Used to populate an Asset’s namespace/op_version from the declared type_name. |
hpm-core::python
| Type | Purpose |
|---|---|
VenvManager | Creates and reuses content-addressable venvs. |
PythonVersion | Houdini-to-Python version value. |
ResolvedDependencySet | UV-resolved dependency set (BTreeMap<name, version> plus the Python version) with a hash() content hash. |
PythonDependencies, PythonDependency, VersionSpec | Declared (pre-resolution) Python dependency types. |
VenvMetadata, OrphanedVenv | Per-venv metadata.json model and the orphan-analysis result type. |
PythonCleanupAnalyzer | Detects orphan venvs for hpm clean --python-only. A venv that records owners is orphaned when none is still installed; one with no recorded owners (a [scripts] venv, or one predating owner recording) is collected only after 30 days idle. Owners are recorded by VenvManager::ensure_virtual_environment_for. |
prepare_script_env(entry) | Per-script-venv building block: bootstraps bundled uv, materializes the venv if needed, and returns a ScriptEnvHandle carrying the env-var mutations to apply before spawning. Plain string entries return a default (no-op) handle. Covers only the python/requirements venv path — not package-env, HPM_PACKAGE_ROOT, or the command line. Most embedders should reach for hpm_core::script_run (above), which wires this plus the rest of the contract; use prepare_script_env directly only when you specifically need just the venv handle. |
ScriptEnvHandle | Spawn-strategy-agnostic env-var bundle (path_prepend, env). apply_to(&mut HashMap<String,String>) folds it into a caller-staged env map ready for Command::envs or any other spawn primitive. |
ensure_script_venv(python, requirements) | Lower-level free function: resolves raw PEP-508 requirement strings via uv pip compile and defers to VenvManager, returning the venv root. Prefer prepare_script_env for the full flow. |
venv_bin_dir(path) | Returns the executable directory inside a uv-created venv (bin/ on Unix, Scripts/ on Windows). prepare_script_env already prepends this to PATH; use directly only when bypassing the handle. |
DEFAULT_PYTHON_VERSION | "3.11". The Python version ensure_script_venv uses when a script omits python. |
hpm-config
| Type | Purpose |
|---|---|
Config | Top-level config (defaults < ~/.hpm/config.toml < project config < env < flags). |
StorageConfig | home_dir, cache_dir, packages_dir, registry_cache_dir. |
InstallConfig | path, parallel_downloads. |
ProjectsConfig | explicit_paths, search_roots, max_search_depth, ignore_patterns. |
RegistrySourceConfig, RegistryType | Registry entries from [[registries]]. |
SigningConfig | key_path fallback for hpm pack. |
ProjectPaths | Per-project paths (.hpm/packages/, hpm.lock, hpm.toml). |
hpm-cli
| Type | Purpose |
|---|---|
CliError | CLI-facing error enum with categorised variants (Config, Package, Network, Io). Carries optional help text. |
ExitStatus | Process-exit code abstraction; converts &CliError -> ExitStatus -> ExitCode. |
Exit codes used by hpm-cli:
| Code | Meaning |
|---|---|
| 0 | Success. |
| 1 | Any hpm-originated failure — bad configuration, bad input, missing manifest, network or I/O error. |
| N | Pass-through exit code from an invoked external tool (ExitStatus::External). |
Design principles
- Minimal coupling. Each crate owns one concern; no upward dependencies.
- Async by default. Everything I/O-bound runs on Tokio.
- Content-addressable storage. Both HPM packages (
<slug>@<version>under~/.hpm/packages/, with path-installed packages isolated in_dev/) and Python venvs (SHA-256 over the resolved set) deduplicate globally. - Safety by construction. Cleanup never removes a package an active project needs; lock file checksums are verified before every install; signing is opt-in but standard when used.
- TOML-first configuration. Every persistent config — manifest, lock file, global config — is TOML and hand-editable.
Testing Guide
HPM uses several layers of tests: traditional unit tests, integration tests, property-based tests with proptest, and a Houdini conformance test that checks generated package files against a real Houdini. This guide covers how to run them, how to write new ones, and how to debug failures.
Table of contents
Test layers
| Layer | Tools | Purpose |
|---|---|---|
| Unit | built-in #[test] | Exercise a specific function or edge case. |
| Integration | #[tokio::test] + tempfile | End-to-end workflows: CLI command invocation, filesystem layout, manifest roundtrips. |
| Property | proptest | Generate inputs at random and assert invariants. Catches edge cases a human wouldn’t think of. |
| Doc | rustdoc examples | Keep public API snippets compiling. |
| Houdini conformance | hconfig from a real Houdini install | Assert the values Houdini actually resolves from generated package files — the only layer that can catch wrong assumptions about Houdini’s semantics. |
Houdini conformance test
hpm-core/src/houdini_conformance_tests.rs writes package files through
the real emission path, runs HOUDINI_PACKAGE_VERBOSE=1 $HFS/bin/hconfig
(license-free), and asserts the merged env values from the verbose
package log. It exists because the emission layer was once validated only
against its own JSON output while Houdini silently ignored method on
flat-string custom variables. Two scenarios are covered:
-
houdini_resolves_generated_packages_per_method— packages sharing a variable plus project overrides in every method (set/prepend/append) and conditional (version/OS-gated) values. -
houdini_set_overwrites_path_registered_ocio_seed— the OCIO crash on H22.0.367: a projectseton a path-registered variable must OVERWRITE Houdini’s own$HFS/packages/ocio.jsonseed, not append onto it. It reads the real seed via a baselinehconfigrun (skipping with a note on builds that don’t seed OCIO), then assertssetreplaces it wholesale rather than resolving to the two-pathbuiltin;projectvalue that crashesOCIO.Config.CreateFromFile. The path-registered-append behavior is invisible to the executable model below (it starts from an empty variable set), so only this real-Houdini layer can catch it. -
The Houdini install is auto-discovered:
$HFSfirst, then the platform-standard locations (/opt/hfs*,/Applications/Houdini/...,C:\Houdini *). -
Without an install the test skips (passing, with a
SKIPPEDnote). SetHPM_REQUIRE_HOUDINI=1to turn the skip into a failure — the CI check pipeline does, since the workers all have Houdini.
The semantics the emission layer targets are also captured as an
executable model (hpm-core/src/houdini_env_model.rs); a property test
(houdini_emission_model_tests.rs) runs randomized packages and project
overrides through the real emission code and the model, asserting that
package values survive overrides, overrides apply exactly once, and
nothing emitted uses a method or value shape Houdini rejects. When a new
question about Houdini’s package semantics comes up, extend the
conformance test to settle it empirically, then encode the answer in the
model.
Property test distribution
Property tests are concentrated in the crates with the most value-shaped
logic: manifest parsing, Python version handling, storage types. Exact
counts shift over time — run
grep -rh "fn prop_" crates/*/src crates/*/tests | wc -l for the
current number (the manifest strategies live in
crates/hpm-package/tests/properties.rs and the CLI strategies in
crates/hpm-cli/tests/cli_validation.rs).
| Crate | Focus |
|---|---|
hpm-cli | Argument parsing, output format round-trips (in tests/cli_validation.rs). |
hpm-core | Storage types, package specs, lockfile round-trips, env merge contracts, and the Houdini env emission model (houdini_emission_model_tests.rs). The python submodule covers Python versions, dependency resolution, content hashing. |
hpm-package | Manifest validation, TOML round-trips, native configs (in tests/properties.rs). |
Running tests
# everything: cli (single-threaded), rest of workspace, then doctests
just test
# doctests only — public-API examples in //! and /// blocks
just test-doc
# slow / external-dependency tests gated behind `#[ignore]`
# (currently: real-uv venv smoke tests in hpm-core::python)
just test-ignored
# raw cargo equivalents
cargo test --workspace
cargo test --workspace --doc
# one crate
cargo test -p hpm-core
# one test by name
cargo test prop_version_req_roundtrip
# property tests only
cargo test prop_
# sequential execution (required when tests touch shared filesystem paths;
# `just test` already does this for hpm-cli via the CwdGuard)
cargo test --workspace -- --test-threads=1
# more proptest cases (default 256)
PROPTEST_CASES=1000 cargo test prop_ --workspace
Configuration
Proptest environment variables
| Variable | Default | Purpose |
|---|---|---|
PROPTEST_CASES | 256 | Number of cases per property test. |
PROPTEST_MAX_SHRINK_ITERS | 1024 | Shrinking budget on failure. |
PROPTEST_TIMEOUT | — | Per-case timeout (ms). |
PROPTEST_VERBOSE | 0 | Dump generated inputs as they run. |
CI
There is no proptest-specific CI matrix — CI runs the suite at proptest’s
default case count and never sets PROPTEST_CASES.
The only test pipeline is .woodpecker/check.yml, which is tag-gated
(event: tag, ref: refs/tags/v*) and so does not run on push or pull
request. It runs:
cargo fmt --check
cargo clippy --workspace --all-targets -- -D warnings
cargo test --workspace
with HPM_REQUIRE_HOUDINI=1, since the build workers have Houdini installed
and the hconfig conformance test must run rather than silently skip.
Raising the case count is therefore a local operation:
PROPTEST_CASES=2000 cargo test --workspace
Writing property tests
A property test defines a strategy for generating inputs and an invariant that must hold for every generated input.
Custom strategies
Constrain generators to the valid input space. Unconstrained any::<String>()
mostly generates nonsense and rejects it, which wastes cycles and produces
misleading shrinks.
#![allow(unused)]
fn main() {
use proptest::prelude::*;
// Good — produces valid package slugs by construction
fn slug_strategy() -> impl Strategy<Value = String> {
"[a-z][a-z0-9-]{1,20}"
.prop_map(|s| s.trim_end_matches('-').to_string())
.prop_filter("non-empty", |s| !s.is_empty())
}
fn version_strategy() -> impl Strategy<Value = String> {
(0u32..100, 0u32..100, 0u32..100)
.prop_map(|(maj, min, pat)| format!("{maj}.{min}.{pat}"))
}
}
Invariants worth testing
Roundtrip — serialize and deserialize and compare:
#![allow(unused)]
fn main() {
proptest! {
#[test]
fn prop_manifest_toml_roundtrip(manifest in manifest_strategy()) {
let toml_str = toml::to_string(&manifest).unwrap();
let parsed: PackageManifest = toml::from_str(&toml_str).unwrap();
prop_assert_eq!(manifest.package.path, parsed.package.path);
prop_assert_eq!(manifest.package.version, parsed.package.version);
}
}
}
Validation always holds — every value the strategy produces should pass validation:
#![allow(unused)]
fn main() {
proptest! {
#[test]
fn prop_valid_manifests_validate(manifest in valid_manifest_strategy()) {
prop_assert!(manifest.validate().is_ok());
}
}
}
Determinism — running the same operation twice should produce the same result:
#![allow(unused)]
fn main() {
proptest! {
#[test]
fn prop_content_hash_is_deterministic(set in resolved_set_strategy()) {
prop_assert_eq!(set.hash(), set.hash());
}
}
}
Real bug caught by property tests
VersionReq::new(" ") (whitespace-only) was incorrectly accepted as valid.
A property test that fed r"\s*" into the constructor surfaced it; the fix
was to trim before the empty check. Every such bug gets a regression file
that fails until the fix stays in place.
Regression files
Proptest persists failing cases to crates/<crate>/proptest-regressions/.
These are source-of-truth regression tests — commit them alongside the fix.
crates/hpm-core/proptest-regressions/
├── houdini_emission_model_tests.txt # Each line is a minimized failing input.
├── project.txt
└── storage/types.txt
hpm-core is currently the only crate with recorded regressions.
Never delete these by hand unless you’re certain the bug class is gone and the input is no longer meaningful.
Debugging failures
Get a minimal failing input
Proptest automatically shrinks to the smallest input that still fails. To see the shrink trace:
PROPTEST_VERBOSE=1 cargo test failing_prop_test -- --nocapture
Reduce the case count while iterating
PROPTEST_CASES=10 cargo test failing_prop_test -- --nocapture
Inspect the regression file
cat crates/hpm-core/proptest-regressions/project.txt
Each line is a serialized form of the seed that reproduced the bug. Proptest replays these on every subsequent run, so the same minimal case is checked deterministically until you fix it.
Move/borrow issues in assertions
prop_assert_eq! moves its operands. If you want to assert on a field and
then on a predicate, clone:
#![allow(unused)]
fn main() {
prop_assert_eq!(value.field.clone(), expected);
prop_assert!(value.is_valid());
}
Running coverage
cargo install cargo-tarpaulin
PROPTEST_CASES=500 cargo tarpaulin --workspace --out html