Packaging Pulumi Components for Reuse
Packaging a Pulumi component for reuse turns a ComponentResource class into an installable, versioned Python package that many stacks can pip install and import instead of copying source. This task sits under Pulumi Component Resources within Pulumi Patterns & Provider Management, and it covers project layout, semantic versioning, building a wheel, and publishing to a private package index so teams consume one canonical version.
Context
A component is only reusable in practice if other projects can depend on it by name and version. Copying the source file into each project recreates the drift problem components were meant to solve. Packaging gives you a single artifact with a version number, a declared provider dependency, and a changelog — the same rigor you apply to structuring stacks per environment, now applied to the shared building blocks themselves.
What makes this harder than shipping an ordinary Python library is that a ComponentResource has a public surface Python cannot see. When a consumer runs pulumi up, every child resource your component creates gets a URN built from the component's type token, its logical name, and the name you passed to the child constructor — for example urn:pulumi:prod::web::myorg:network:Vpc$aws:ec2/subnet:Subnet::app-private-0. Those strings are the identity Pulumi uses to match state to code. A refactor that leaves the Python signature untouched can still change a URN, and a changed URN means delete-and-recreate in a stack you do not own.
There is a second distribution route worth naming so you can rule it out. Pulumi also supports multi-language components, where you write a schema, run pulumi package gen-sdk, and publish generated SDKs for Python, TypeScript, Go, and C# from one implementation. That is the right answer when consumers work in several languages, but it adds a schema file, a plugin binary, and a registry to maintain. If every consumer is a Python program, a plain wheel on a private index is the smaller, more debuggable option — the component runs in the consumer's own Python process rather than over a gRPC boundary, so tracebacks stay readable.
Prerequisites
- Python 3.9+ and a build frontend:
pip install build twine(python -m build --version). - The component code from Building a Reusable VPC Component in Pulumi (Python) or equivalent.
- A private package index (CodeArtifact, GCP Artifact Registry, GitLab/GitHub Packages, or a self-hosted devpi) and credentials to publish to it.
- A pinned
pulumi-awsversion that consumers will inherit as a dependency.
Implementation
1. Lay out the package
Use a src/ layout so the import package is unambiguous and tests do not accidentally import from the working directory.
pulumi-myorg-network/
├── pyproject.toml
├── README.md
└── src/
└── myorg_network/
├── __init__.py
└── vpc.py # VpcComponent + VpcArgs
Add one more file that is easy to forget: an empty src/myorg_network/py.typed marker. Without it, mypy in a consuming repository silently treats every import from your package as Any, so the typed VpcArgs you carefully wrote buys the consumer nothing. The marker is what makes PEP 561 inline type information visible across a distribution boundary.
Re-export the public surface from __init__.py so consumers import from the package root:
# src/myorg_network/__init__.py
# CLI: import myorg_network in a consuming Pulumi program
from .vpc import VpcComponent, VpcArgs
__all__ = ["VpcComponent", "VpcArgs"]
__version__ = "0.1.0"
Keep __all__ honest. Anything importable from the package root is something a consumer will import, and something you then cannot remove without a major version. Helper functions, the internal subnet-CIDR calculator, and the module that wraps pulumi.Config all stay private with a leading underscore. A component package with three public names is far easier to keep compatible than one with thirty.
The type token itself is decided inside the component, not in packaging, but it belongs to the package's contract:
# src/myorg_network/vpc.py (excerpt)
# CLI: pulumi preview --stack dev
from __future__ import annotations
from dataclasses import dataclass
from typing import Optional
import pulumi
import pulumi_aws as aws
@dataclass(frozen=True)
class VpcArgs:
cidr_block: str
az_count: int = 2
enable_nat: bool = True
class VpcComponent(pulumi.ComponentResource):
def __init__(
self, name: str, args: VpcArgs, opts: Optional[pulumi.ResourceOptions] = None
) -> None:
# State implication: this token is embedded in every child URN. Treat a
# change to the string as a major version — it replaces consumer resources.
super().__init__("myorg:network:Vpc", name, None, opts)
vpc = aws.ec2.Vpc(
f"{name}-vpc",
cidr_block=args.cidr_block,
enable_dns_hostnames=True,
opts=pulumi.ResourceOptions(parent=self),
)
self.vpc_id = vpc.id
# Registered outputs are the read-side contract; keys here are public API.
self.register_outputs({"vpc_id": vpc.id})
2. Declare metadata and the provider dependency
The pyproject.toml pins the Pulumi provider so every consumer resolves a compatible SDK, and it sets the version that drives reuse.
# pyproject.toml
# CLI: python -m build
[build-system]
requires = ["setuptools>=61"]
build-backend = "setuptools.build_meta"
[project]
name = "pulumi-myorg-network"
version = "0.1.0"
requires-python = ">=3.9"
dependencies = [
"pulumi>=3.0,<4.0",
"pulumi-aws>=6.0,<7.0", # Provider note: consumers inherit this constraint.
]
[project.optional-dependencies]
dev = ["pytest>=8.0", "mypy>=1.8", "build", "twine"]
[tool.setuptools.packages.find]
where = ["src"]
[tool.setuptools.package-data]
myorg_network = ["py.typed"] # PEP 561: ship the inline types to consumers
The dependency range is the single most consequential line in this file. pulumi-aws is a generated SDK whose Python classes mirror the provider schema, and a major bump renames or removes arguments — aws.ec2.Vpc alone changed several defaults between v5 and v6. An unbounded >=6.0 lets a consumer's resolver install v7 alongside your v6-era code, and the failure arrives as TypeError: Vpc.__init__() got an unexpected keyword argument during pulumi preview, long after pip install reported success. Bound the range, and widen it in a deliberate release after testing against the new major.
Note also what is not pinned: an exact == on pulumi-aws would make your package uninstallable alongside any other component package with a different exact pin. Libraries declare ranges; applications declare locks. The consuming stack is the application, and that is where a lockfile belongs.
3. Build the distribution artifacts
# CLI: produces a wheel and sdist under dist/
python -m build
# State implication: none — building an artifact does not touch any Pulumi state.
ls dist/ # pulumi_myorg_network-0.1.0-py3-none-any.whl pulumi_myorg_network-0.1.0.tar.gz
4. Publish to a private index and consume it
Upload the artifacts, then depend on the package by version from any stack.
# CLI: publish to your private index (URL/credentials from env or ~/.pypirc)
twine upload --repository-url "$PRIVATE_INDEX_URL" dist/*
# In a consuming project:
pip install pulumi-myorg-network==0.1.0 --index-url "$PRIVATE_INDEX_URL"
# consumer/__main__.py
# CLI: pulumi up --stack dev
import pulumi
from myorg_network import VpcComponent, VpcArgs
# State implication: the component is recorded in the consumer's state like any resource.
network = VpcComponent("app", VpcArgs(cidr_block="10.0.0.0/16", az_count=2))
pulumi.export("vpc_id", network.vpc_id)
5. Release from CI, never from a laptop
A release built on a developer machine picks up whatever is in that machine's virtual environment and whatever uncommitted edits are in the tree. Drive it from a tag instead, so the artifact is reproducible from a commit hash and the index credential never lives on a workstation.
# .github/workflows/release.yml
# CLI: git tag v0.2.0 && git push origin v0.2.0
name: release
on:
push:
tags: ["v*"]
permissions:
id-token: write # OIDC exchange for the index credential; no static secret
contents: read
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- run: pip install build twine mypy pytest
- run: mypy --strict src/
- run: pytest -q
- run: python -m build
- run: twine check dist/*
- run: twine upload --repository-url "$PRIVATE_INDEX_URL" dist/*
env:
PRIVATE_INDEX_URL: $
twine check is the cheap step people skip. It parses the built metadata and rejects a long description that will not render, which is otherwise discovered only after the artifact is immutable on the index. Most indexes — PyPI, CodeArtifact, Artifact Registry — refuse to accept the same version twice, so a bad 0.2.0 is spent permanently and the fix is 0.2.1.
Have the tag drive the version rather than editing two places. With setuptools-scm in [build-system] requires, the version comes from the git tag itself and __version__ reads it back through importlib.metadata.version("pulumi-myorg-network"), which removes the class of bug where the wheel says 0.2.0 and __init__.py still says 0.1.0.
Verification
Confirm the artifact installs and imports cleanly, ideally in a throwaway virtual environment:
# CLI: prove the published package is consumable
python -m venv /tmp/verify && /tmp/verify/bin/pip install \
pulumi-myorg-network==0.1.0 --index-url "$PRIVATE_INDEX_URL"
/tmp/verify/bin/python -c "import myorg_network; print(myorg_network.__version__)"
A test in the package repo asserts the public surface is importable and the version is exposed:
# tests/test_packaging.py
# CLI: pytest tests/test_packaging.py -q
import myorg_network
def test_public_surface() -> None:
assert hasattr(myorg_network, "VpcComponent")
assert hasattr(myorg_network, "VpcArgs")
assert myorg_network.__version__ == "0.1.0"
Importing is necessary but not sufficient — it proves the wheel is installable, not that the component still produces the same URNs. Add a mock-backed test that asserts the type token and the child names, because those are the parts that break consumers silently:
# tests/test_urns.py
# CLI: pytest tests/test_urns.py -q
from __future__ import annotations
from typing import Any
import pulumi
import pytest
RECORDED: list[tuple[str, str]] = []
class _Mocks(pulumi.runtime.Mocks):
def new_resource(
self, args: pulumi.runtime.MockResourceArgs
) -> tuple[str, dict[str, Any]]:
RECORDED.append((args.typ, args.name))
return (f"{args.name}-id", dict(args.inputs))
def call(self, args: pulumi.runtime.MockCallArgs) -> dict[str, Any]:
return {}
@pytest.fixture(autouse=True)
def _mocks() -> None:
RECORDED.clear()
pulumi.runtime.set_mocks(_Mocks(), preview=False)
def test_type_token_and_child_names_are_stable() -> None:
"""Guard the strings that end up in a consumer's URNs."""
from myorg_network import VpcArgs, VpcComponent
VpcComponent("app", VpcArgs(cidr_block="10.0.0.0/16"))
# State implication: renaming either string replaces resources downstream.
assert ("myorg:network:Vpc", "app") in RECORDED
assert ("aws:ec2/vpc:Vpc", "app-vpc") in RECORDED
Run that test on every pull request to the package repository. It turns "we renamed a variable" into a red build rather than a surprise replacement in production three weeks later.
Finally, prove the metadata itself is intact before publishing:
# CLI: inspect the artifact rather than trusting the build log
python -m twine check dist/*
unzip -l dist/pulumi_myorg_network-0.1.0-py3-none-any.whl | grep py.typed
python -m zipfile -e dist/pulumi_myorg_network-0.1.0-py3-none-any.whl /tmp/whl
grep -E "^Requires-Dist" /tmp/whl/pulumi_myorg_network-0.1.0.dist-info/METADATA
Gotchas & Edge Cases
Version drift between the package and its declared type token. A component's type token (myorg:network:Vpc) becomes part of consumers' resource URNs. Changing the token in a new package version forces resource replacement on upgrade. Keep the token stable across minor versions and treat a token change as a major version bump.
Loose provider constraints cause silent SDK mismatches. If pyproject.toml pins pulumi-aws>=6.0 with no upper bound, a consumer may resolve a v7 provider with breaking schema changes. Use a bounded range (>=6.0,<7.0) and bump it deliberately, mirroring the reproducible-install discipline applied across Python IaC dependencies.
src/ layout means editable installs need pip install -e .. Running tests against the working tree without an editable install will import a stale copy or fail outright. Install the package (editable in development, pinned in CI) before importing it.
The component reads pulumi.Config and inherits the consumer's namespace. A component that calls pulumi.Config("network").require("cidr") forces every consuming stack to define network:cidr in its own Pulumi.<stack>.yaml, and the failure is Missing required configuration variable 'network:cidr' raised from inside your package, where the consumer cannot see why. Components should take configuration as constructor arguments and leave config reading to the program at the top of the stack.
A missing py.typed marker silently disables type checking downstream. mypy in the consuming repository reports Skipping analyzing "myorg_network": module is installed, but missing library stubs or py.typed marker — and then, if the consumer has not enabled --strict, carries on treating every call as valid. The component's typed arguments only protect anyone if the marker ships in the wheel.
Provider plugin versions are resolved separately from the Python SDK. pip install pulumi-aws==6.40.0 fetches the Python bindings; the matching provider plugin is downloaded by the Pulumi CLI at runtime into ~/.pulumi/plugins. On an air-gapped or locked-down runner the install succeeds and the deploy fails with no resource plugin 'aws' found in the workspace or on your $PATH. Ship the expected plugin version in your README and pre-install it in CI images with pulumi plugin install resource aws 6.40.0.
Yanking is not deleting. If a release turns out to be broken, twine-based indexes let you yank it so new resolutions skip it, but any stack with that version already in a lockfile keeps using it. Follow a yank with a patch release and a note in the changelog rather than assuming the bad artifact is gone.
Operational Notes
Packaging turns a component from shared code into a supported product: consumers pin a version, read a changelog, and upgrade on their own schedule. That contract only holds if you treat resource names and output shapes as the public API — renaming a child resource is a breaking change because it can force replacement in every consuming stack, regardless of the Python signature.
Ship the package with its own tests so a release is provably synthesizable in isolation, and declare narrow version ranges for pulumi and the provider bindings so a consumer's resolver cannot pull an incompatible generated API. For private distribution, publish to CodeArtifact or a self-hosted index and document the index URL, exactly as you would for an application library managed with Poetry or pip-tools.
Deciding the version bump
Semantic versioning for a component package is not read off the Python diff. Read it off what the change does to consumer URNs, because that is what decides whether an upgrade is a no-op preview or a replacement plan.
Renaming without replacing
When a rename is genuinely necessary, pulumi.Alias lets consumers upgrade without a teardown. Ship the alias in the release that performs the rename, keep it for one major version, then remove it:
# src/myorg_network/vpc.py (during a rename)
# CLI: pulumi preview --stack prod # expect "no changes", not a replacement
import pulumi
import pulumi_aws as aws
subnet = aws.ec2.Subnet(
f"{name}-private-{i}",
vpc_id=vpc.id,
cidr_block=block,
# State implication: the alias maps the old URN onto the new name so Pulumi
# updates in place instead of destroying and recreating the subnet.
opts=pulumi.ResourceOptions(
parent=self, aliases=[pulumi.Alias(name=f"{name}-subnet-{i}")]
),
)
Document the alias in the changelog with the version in which it will be dropped, and tell consumers to run pulumi preview before pulumi up on any component upgrade. That one habit catches every replacement this page warns about.
Deprecating a field
Removing a VpcArgs field outright breaks the build in every consuming repository at once. Deprecate over two releases instead: in the first, keep the field, emit pulumi.log.warn when it is set, and ignore it; in the second, remove it and bump the major. pulumi.log.warn surfaces in the consumer's own pulumi up output, which is the only place their operators are actually looking.
FAQ
What is the difference between a component and a package?
A ComponentResource is the code abstraction; a package is how you distribute it. You package a component so other stacks can pip install and depend on it. The component defines behaviour and a type token; the package defines a version, a dependency range, and an artifact someone can install.
How do consumers pin a component version?
Publish semantic versions and have consumers pin a range in their dependency file, exactly as with any Python library. The stack repository should additionally commit a lockfile — requirements.txt compiled by pip-tools, or poetry.lock — so the same wheel resolves in CI as on a developer machine.
Can one package hold several components?
Yes — group related components (a networking bundle) so they version and upgrade together, and keep unrelated domains in separate packages. The test is whether a change to one component would plausibly require a coordinated change to the other; if not, they should version independently.
Should I publish a Pulumi multi-language component instead of a Python wheel?
Only if consumers write in more than one language. A multi-language component requires a schema, a plugin binary published where the CLI can fetch it, and pulumi package gen-sdk in the release pipeline. For an all-Python organisation that machinery buys nothing and costs you readable stack traces.
How do I stop an upgrade from replacing resources in production?
Make pulumi preview mandatory before pulumi up, and read the plan for ++ replace markers rather than the resource count. On the publishing side, add the URN-stability test shown in Verification so a rename fails CI in the package repository before it can reach anyone's stack.
Where should the package live — the same repository as the stacks that use it?
Separate repositories, once more than one team consumes it. A shared repository makes it too easy to change the component and a consuming stack in one commit, which hides the compatibility question the version number exists to answer. Keep the package in its own repository with its own tags, and let consumers upgrade on their own schedule.
Related
- Building a Reusable VPC Component in Pulumi (Python) — the component this package distributes.
- Pulumi Component Resources — the parent guide on
ComponentResource, parenting, andregister_outputs. - Structuring Pulumi Stacks per Environment — consume the packaged component from per-environment stacks.