Package pipeline code as a wheel¶
Your generated pipeline is a folder of Python modules — the flowgroup module here, a reusable transform library there. You can sync every one of those files into the workspace on each deploy, and copy the shared ones by hand into the next project that needs them. Or you package the whole pipeline as a single versioned wheel: one artifact you build once, a pipeline installs, and you reuse the way you reuse any Python distribution.
That is the choice on this page: version and reuse pipeline code as a proper
artifact, not copy-pasted modules. Packaging is opt-in per pipeline and changes
nothing about the generated logic — the code inside the wheel is byte-identical
to what source mode writes to disk. It pays off on large projects (on the
order of 5,000 generated files or more), where the per-file write in
lhp generate and the file-by-file upload in databricks bundle deploy
both grow long and collapsing each pipeline to one uploaded artifact recovers
that time. Below that scale, source mode is simpler.
Let’s package a silver_orders pipeline — whose Python transform is exactly
the kind of reusable library worth versioning — as a wheel, and read every file
LHP writes to wire it up.
Before you start¶
Wheel packaging builds on Declarative Automation Bundle integration, so you need
a bundle project: a databricks.yml at the project root. You also need a
Unity Catalog volume your deploy identity can write to. Serverless compute
installs custom wheels only from a /Volumes/... path, so the built wheel has
to land on a volume — not the workspace file tree.
One structural rule follows from packaging: in wheel mode each flowgroup is
packed under a sanitized import package, so a top-level Python import of one
flowgroup module from another will not resolve. Relate flowgroups through the
Lakeflow Spark Declarative Pipelines dataset graph, not Python imports.
Point the artifact volume¶
Declare the volume built wheels upload to in a top-level wheel block in
lhp.yaml. The value is substitution-token-aware — LHP resolves ${...}
tokens per environment exactly as it does any other value — so one entry serves
every environment:
# Lakehouse Plumber project configuration
name: guide_ship_wheel
version: "1.0"
description: "Guide: package a pipeline's generated code as a versioned Python wheel."
# Wheel packaging uploads each built wheel to a Unity Catalog volume — serverless
# compute installs custom wheels only from a /Volumes/... path. The value is
# substitution-token-aware: ${catalog} and ${artifact_schema} resolve per
# environment from substitutions/<env>.yaml, so this one entry serves them all.
wheel:
artifact_volume: "/Volumes/${catalog}/${artifact_schema}/bundle_artifacts"
Define the tokens per environment in substitutions/<env>.yaml so the resolved
path is environment-specific:
# Development environment tokens.
# Referenced from flowgroups, lhp.yaml, and pipeline_config.yaml with ${token}.
# The target name in databricks.yml (dev) must match this file's stem.
dev:
catalog: dev_catalog
silver_schema: silver
artifact_schema: lhp_artifacts
landing_path: /Volumes/dev_catalog/landing/files
The resolved value must start with /Volumes/. If a pipeline is set to
wheel mode and wheel.artifact_volume is missing, empty, or resolves to a
non-/Volumes/ path, lhp generate stops with LHP-CFG-061. A malformed
wheel block — artifact_volume set to something other than a string —
fails with LHP-CFG-060.
Turn on wheel packaging¶
The packaging toggle lives in the per-environment pipeline config file you pass
with -pc. It resolves lowest-to-highest: the built-in default source,
then project_defaults.packaging (the environment-wide default), then a
per-pipeline packaging block (which wins for that pipeline). The only
accepted values are source and wheel; anything else fails with
LHP-VAL-062.
Here only silver_orders is packaged, leaving every other pipeline in source
mode:
# Bundle pipeline configuration.
#
# On a bundle project `lhp generate` REQUIRES this file via -pc: it supplies the
# catalog and schema LHP embeds into each generated bundle resource, and it is
# where the packaging toggle lives.
#
# packaging resolves lowest-to-highest: built-in default `source` ->
# project_defaults.packaging -> a per-pipeline `packaging`. Here only
# silver_orders is set to wheel; every other pipeline would stay source.
project_defaults:
catalog: "${catalog}"
schema: "${silver_schema}"
serverless: true
---
pipeline: silver_orders
packaging: wheel
Because the toggle lives in the per-environment config, a pipeline can be
source in dev and wheel in prod with no change to its logic. To package
every pipeline instead, set packaging: wheel under project_defaults and
opt individual pipelines back out.
Generate the wheel¶
Run lhp generate for the target environment. Source-mode and wheel-mode
pipelines mix freely in one run; LHP builds each wheel once, in process:
$ lhp generate --env dev -pc config/pipeline_config.yaml
✓ discover (0.01s)
✓ preflight (0.00s)
✓ silver_orders 1 file
✓ generate (0.36s)
✓ format (0.04s)
✓ monitoring (0.00s)
✓ bundle_sync (0.01s)
1 pipeline generated · 1 file · 0.4s
Note the 1 file: in wheel mode LHP writes no loose flowgroup modules to the
workspace tree. The one file it syncs is a runner; the flowgroup code goes into
the wheel.
Read what LHP wrote¶
The runner. generated/dev/silver_orders/silver_orders_runner.py is the
only file synced to the workspace. This is the whole thing — nothing is hidden
behind a runtime:
# Generated by Lakehouse Plumber — wheel-mode pipeline runner.
#
# This is the ONLY source file synced for this pipeline. It is NOT part of the
# wheel. The Databricks runtime injects the ambient pipeline globals into this
# module's namespace; the runner republishes them into ``builtins`` so the
# wheel's flowgroup modules resolve bare ``spark`` / ``dbutils`` references the
# same way they do in source mode, then imports the flowgroup package so each
# module's dataset-registering decorators execute.
#
# The runner references ONLY the import-package name — never flowgroup names,
# the content hash, or the wheel version — so its bytes are stable across
# pipeline content changes (WHEEL_PACKAGING_SPEC R9, runner contract).
import builtins
import importlib
import pkgutil
# Ambient globals injected by the Databricks runtime into this runner's own
# namespace. Publish them into ``builtins`` BEFORE importing any wheel module
# so import-time and runtime references resolve via the builtins fallback.
_AMBIENT_GLOBALS = (
"spark",
"dbutils",
)
for _name in _AMBIENT_GLOBALS:
if _name in globals():
setattr(builtins, _name, globals()[_name])
# Import the flowgroup package, then import every submodule so each flowgroup
# module is executed and its decorators register their datasets.
_pkg = importlib.import_module("silver_orders")
for _module_info in pkgutil.iter_modules(_pkg.__path__, _pkg.__name__ + "."):
importlib.import_module(_module_info.name)
The runner republishes the ambient spark and dbutils globals into
builtins and then imports the wheel’s flowgroup package, so each module’s
dataset-registering decorators execute the same way they do in source mode. It
references only the import-package name — never a flowgroup name, the content
hash, or the version — so its bytes stay stable across pipeline content changes.
The pipeline resource. Alongside the runner, LHP wrote one bundle resource
per pipeline under resources/lhp/. Your catalog and schema are
resolved and embedded; the last environment.dependencies entry is the
prebuilt wheel:
# Generated by LakehousePlumber - Bundle Resource for silver_orders
resources:
pipelines:
silver_orders_pipeline:
name: silver_orders_pipeline
# Catalog and Schema for the pipeline
catalog: dev_catalog
schema: silver
serverless: true
libraries:
- glob:
include: ${workspace.file_path}/generated/${bundle.target}/silver_orders/**
root_path: ${workspace.file_path}/generated/${bundle.target}/silver_orders
configuration:
bundle.sourcePath: ${workspace.file_path}/generated/${bundle.target}
channel: CURRENT
environment:
dependencies:
- ../../generated/dev/_wheels/silver_orders/dist/lhp_silver_orders_dev_9b1ed495cd12-0.9.1-py3-none-any.whl
The wheel reference is a file-relative local path
(../../generated/dev/_wheels/...whl), not a /Volumes/... path.
Declarative Automation Bundles classifies a local reference as a prebuilt wheel:
on deploy it uploads the file to <artifact_path>/.internal/ and rewrites the
dependency to that uploaded location itself. LHP never composes the remote path,
and this file never carries the packaging key — the toggle is consumed by
LHP, not by Databricks. Below the lines shown, LHP appends a commented catalogue
of every other pipeline option. This folder is wiped and regenerated on every
run, so put custom resources in the top-level resources/ instead.
The wheel wiring. resources/lhp/_wheels.bundle.yml is LHP-owned and
regenerated on every run:
# Generated by LakehousePlumber - Wheel packaging bundle wiring
# Regenerated on every `lhp generate`. Do not edit.
#
# Points the bundle's artifact upload location at the UC volume and excludes the
# `_wheels/` staging dir from file sync. No `artifacts:` block: each wheel is a
# prebuilt local library reference in environment.dependencies, which DAB uploads
# to <artifact_path>/.internal/ and rewrites itself (declaring a whl artifact with
# no files would make DAB try to REBUILD the prebuilt wheel).
targets:
dev:
workspace:
artifact_path: /Volumes/dev_catalog/lhp_artifacts/bundle_artifacts
sync:
exclude:
- "../../generated/${bundle.target}/_wheels/**"
It sets workspace.artifact_path to the resolved artifact volume and excludes
the _wheels/ staging directory from the bundle file sync. It carries no
artifacts block on purpose: each wheel is already a prebuilt local dependency
that Declarative Automation Bundles uploads and rewrites, so declaring a wheel
artifact with no source files would make the deploy try to rebuild the
already-built wheel. LHP does not edit your databricks.yml.
Look inside the wheel¶
Wheel mode leaves no loose .py files on disk, so to see what a build packaged
use lhp inspect-wheel. Identify the wheel by pipeline name — which
resolves the content-hashed filename for you, and so needs --env — or by an
explicit path to the .whl:
$ lhp inspect-wheel silver_orders --env dev
Modules in lhp_silver_orders_dev_9b1ed495cd12-0.9.1-py3-none-any.whl (pipeline=silver_orders, env=dev)
Module Size (bytes)
custom_python_functions/order_transforms.py 2029
silver_orders/__init__.py 52
silver_orders/orders_normalize.py 1960
Total modules: 3
Three modules ship in one wheel: the flowgroup package (silver_orders/) and
custom_python_functions/order_transforms.py — the reusable transform library,
packed as a top-level import root. That library travels with the pipeline as a
versioned artifact instead of being copied into the next project by hand.
To read or diff the packaged code in an editor, extract the .py members with
--extract; LHP preserves their in-wheel directory structure and never
modifies the wheel:
$ lhp inspect-wheel silver_orders --env dev --extract /tmp/silver_orders
Extraction is read-only. Editing the extracted files does not change the deployed
wheel — that is rebuilt from your YAML on the next lhp generate. The command
exits non-zero when the wheel is missing (LHP-IO-022), is not a wheel file
(LHP-IO-023), or is corrupt (LHP-IO-024); when a name resolves to zero or
several wheels (LHP-GEN-001); or when a pipeline name is given without
--env.
Ship it¶
The built wheel is deterministic and content-addressed. Its filename encodes the
pipeline’s identity — lhp_<pipeline>_<env>_<hash>-<lhp-version>-py3-none-any.whl,
where <hash> is a 12-character SHA-256 digest over the packaged .py bytes.
An unchanged pipeline rebuilds a byte-identical wheel with an identical filename,
so its deploy is a no-op; any change to the generated logic changes the hash, and
with it the filename. The same commit regenerates an identical wheel on any
machine, so rollback is a redeploy of a prior commit.
Generation is where LHP’s job ends and the Databricks CLI takes over:
$ databricks bundle validate --target dev
$ databricks bundle deploy --target dev
On deploy, Declarative Automation Bundles uploads each prebuilt wheel to
<artifact_path>/.internal/ under the artifact volume and rewrites the
pipeline’s dependency to that uploaded location; it does not trigger a second
build. When the pipeline runs, it installs the wheel and loads the runner, which
imports your packaged flowgroup modules.
Note
Keep dev and test in source mode, even when production runs on wheels.
Source mode writes clear-text .py files you can open and edit in place in
the workspace for a quick iteration — a wheel must be rebuilt and reinstalled
to change, and lhp inspect-wheel gives you read-only inspection, not that
edit loop. Because the toggle is per environment, one pipeline can be
source in dev and wheel in prod. One more thing to check before you
switch a pipeline: inside a wheel, __file__ resolves into the installed
package, and only .py files are packaged, so a flowgroup or helper that
reads a sidecar data file by a __file__-relative path will not find it in
wheel mode. Keep such a pipeline in source mode.
What you just did¶
You turned a pipeline’s Python — a flowgroup module and its reusable transform
library, three modules in all — into a single versioned wheel, and the workspace
now syncs one 32-line runner instead of those loose files. The transform library
is a real artifact you version and reuse, not code copy-pasted between projects.
The wheel is content-addressed: rebuild it from the same commit anywhere and you
get the same bytes and the same filename, which makes rollback a redeploy and
makes lhp validate and dependency analysis — which read YAML — indifferent to
the packaging mode.
What’s next¶
Promote the same commit across environments. A pipeline packaged as a wheel in prod stays source in dev; the same commit regenerates an identical wheel, so CI/CD promotion and rollback are redeploys of the same commit.
Inspect any built wheel.
lhp inspect-wheellists or extracts a wheel by pipeline name or by path — reach for it whenever you need to see exactly what code a build packaged.Deploy the bundle. The full deploy path — targets,
databricks bundle deploy, and running the pipeline — is covered in the bundles guide.