Add a New Package to StageX
Target audience: Maintainers Goal: Create package definitions for new dependencies and add them to the StageX build system.
This guide walks through adding py-zensical (our static site generator) and all its dependencies to StageX. Zensical is a real-world example with 8 runtime dependencies, one of which required an upgrade, and one of which is a Rust+Python hybrid (maturin-built).
The full process — research, bottom-up packaging, and build — mirrors what you'll do for any new package.
Prerequisites
- StageX repo cloned, on a branch based on
staging:git clone https://codeberg.org/stagex/stagex.git git checkout staging git checkout -b my/package-<name> - Familiarity with the StageX build system: see the Makefile Targets Reference and Package.toml Format
- A working StageX build environment (see Build Locally)
1. Map Your Dependency Tree
Before writing any files, understand what you're adding. Every package has:
- Leaf packages — zero dependencies beyond Python stdlib
- Intermediate packages — depend on leaf packages
- Root package — the one you actually want to install
For Zensical (v0.0.41), the dependency tree looks like this:
zensical (maturin-built Rust+Python)
├── click (leaf — BSD-3-Clause)
├── deepmerge (leaf — MIT)
├── jinja2 (already in StageX ✓)
├── markdown (leaf — BSD-3-Clause)
├── pygments (EXISTS — needs upgrade: 2.18.0 → 2.20.0)
├── pymdown-extensions (depends on markdown + yaml)
├── pyyaml (already in StageX ✓)
└── tomli (leaf — MIT)
Research each package using the PyPI JSON API:
curl -s https://pypi.org/pypi/<name>/<version>/json | python3 -m json.tool
Extract the source tarball SHA256 hash, license, and dependency list:
import json, urllib.request
data = json.load(urllib.request.urlopen("https://pypi.org/pypi/tomli/2.4.1/json"))
for u in data['urls']:
if u['packagetype'] == 'sdist':
print(f"Hash: {u['digests']['sha256']}")
print(f"URL: {u['url']}")
Tip: The first letter of the package name in the PyPI URL path is case-sensitive.
MarkdownusesM/Markdown/.clickusesc/click/.
What You Need Per Package
| Item | Source |
|---|---|
| Version | PyPI or upstream |
| Source hash | PyPI JSON API (sha256 of sdist) |
| License | info.license or Trove classifiers |
| Dependencies | info.requires_dist |
| Build system | info.requires_python (tells you Python version ceiling) |
2. Add Leaf Packages (Zero Dependencies)
Start with the packages that have no dependencies. Create a directory and two files per package.
Example: py-tomli
mkdir -p packages/user/py-tomli
packages/user/py-tomli/package.toml:
[package]
name = "py-tomli"
version = "2.4.1"
description = "A lil' TOML parser"
license = "MIT"
website = "https://github.com/hukkin/tomli"
[sources.py-tomli]
hash = "7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"
format = "tar.gz"
file = "py-tomli-{version}.{format}"
mirrors = [ "https://files.pythonhosted.org/packages/source/t/tomli/tomli-{version}.{format}",]
packages/user/py-tomli/Containerfile:
FROM stagex/pallet-cython AS build
ARG VERSION
ADD fetch/py-tomli-${VERSION}.tar.gz .
WORKDIR /tomli-${VERSION}
RUN gpep517 build-wheel --wheel-dir .dist --output-fd 3 3>&1 >&2
RUN --network=none <<-EOF
set -eu
python -m installer -d /rootfs .dist/*.whl
find /rootfs | grep -E "(/__pycache__$|\.pyc$|\.pyo$)" | xargs rm -rf
EOF
FROM stagex/core-filesystem AS package
COPY --from=build /rootfs /
What to change for your package: the
fileandmirrorsfields inpackage.toml, theWORKDIRpath in theContainerfile. The directory inside the sdist tarball is typically<lowercase-name>-{version}(e.g.,tomli-2.4.1/,click-8.1.8/,deepmerge-2.0/).
Key Rules for package.toml
fileis the local filename (prefix withpy-)mirrorsis the upstream URL (uses the PyPI canonical name, withoutpy-prefix){version}and{format}are template variables substituted by the build system- The
[sources.<name>]section name must match the package name in the URL, not necessarily thepy-prefixed name
Repeat for Each Leaf
We added four leaf packages for Zensical:
| Package | Version | Hash |
|---|---|---|
| py-click | 8.1.8 | ed53c9d8990d83c2a27deae68e4ee337473f6330c040a31d4225c9574d16096a |
| py-deepmerge | 2.0 | 5c3d86081fbebd04dd5de03626a0607b809a98fb6ccba5770b62466fe940ff20 |
| py-markdown | 3.7 | 2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2 |
| py-tomli | 2.4.1 | 7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f |
All use the same Containerfile pattern above (pallet-cython, gpep517, installer).
3. Upgrade an Existing Package
Sometimes your target requires a newer version of an existing package. Zensical needs pygments >= 2.20, but StageX has v2.18.0.
Research the upgrade: check the py-pygments upgrade bot PR for reference.
Edit packages/user/py-pygments/package.toml:
-version = "2.18.0"
+version = "2.20.0"
-hash = "786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"
+hash = "6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f"
The Containerfile uses ${VERSION}, so it adapts automatically — no changes needed.
Watch for: Python version bumps. pygments 2.20.0 requires
>= 3.9(was>= 3.8). StageX ships Python 3.13+, so this is fine — but always verify when upgrading.
4. Add Packages with Dependencies
When a package needs other StageX packages at runtime, add COPY --from= lines to the Containerfile.
Example: py-pymdown-extensions
Depends on: py-markdown, py-yaml (both in StageX).
packages/user/py-pymdown-extensions/Containerfile:
FROM stagex/pallet-cython AS build
COPY --from=stagex/user-py-markdown . /
COPY --from=stagex/user-py-yaml . /
ARG VERSION
ADD fetch/py-pymdown_extensions-${VERSION}.tar.gz .
WORKDIR /pymdown_extensions-${VERSION}
RUN gpep517 build-wheel --wheel-dir .dist --output-fd 3 3>&1 >&2
RUN --network=none <<-EOF
set -eu
python -m installer -d /rootfs .dist/*.whl
find /rootfs | grep -E "(/__pycache__$|\.pyc$|\.pyo$)" | xargs rm -rf
EOF
FROM stagex/core-filesystem AS package
COPY --from=build /rootfs /
Note: The sdist filename for
pymdown-extensionsuses underscores (pymdown_extensions-10.21.2.tar.gz), not hyphens. This is PyPI's filename normalization. The WORKDIR must match the actual directory inside the tarball — check withtar tzfif unsure.
5. Add a Rust+Python (Maturin) Package
Zensical is a maturin-built package — Rust core with Python bindings. This means:
- Build base:
stagex/pallet-rust(NOTpallet-cython) - Must
COPY --from=stagex/pallet-python . /for the Python runtime - Uses
python -m build --wheel --no-isolationinstead ofgpep517 - Uses
--destdir=/rootfs(not-d /rootfs) in the installer
Example: py-zensical
packages/user/py-zensical/Containerfile:
FROM stagex/pallet-rust AS build
COPY --from=stagex/pallet-python . /
COPY --from=stagex/user-py-click . /
COPY --from=stagex/user-py-deepmerge . /
COPY --from=stagex/user-py-jinja2 . /
COPY --from=stagex/user-py-markdown . /
COPY --from=stagex/user-py-pygments . /
COPY --from=stagex/user-py-pymdown-extensions . /
COPY --from=stagex/user-py-yaml . /
COPY --from=stagex/user-py-tomli . /
ARG VERSION
ADD fetch/py-zensical-${VERSION}.tar.gz .
WORKDIR /zensical-${VERSION}
RUN python -m build --wheel --no-isolation
RUN --network=none <<-EOF
set -eu
python -m installer --destdir=/rootfs dist/*.whl
find /rootfs | grep -E "(/__pycache__$|\.pyc$|\.pyo$)" | xargs rm -rf
EOF
FROM stagex/core-filesystem AS package
COPY --from=build /rootfs /
Why
--no-isolation? StageX builds don't have network access during the build step (--network=none). The--no-isolationflag tells the build backend to use pre-installed packages (from theCOPY --from=lines) instead of downloading fresh ones from PyPI.
6. Fetch, Build, Verify
For each new or upgraded package, run these commands:
# 1. Fetch the source tarball
python3 src/fetch.py py-<name>
# 2. Build the package
make user-py-<name>
# 3. Verify the digest was recorded
tail -1 digests/user.txt
Build order matters. Packages must be built bottom-up:
py-tomli → py-markdown → py-click → py-deepmerge
→ py-pygments (upgrade)
→ py-pymdown-extensions (needs py-markdown + py-yaml built)
→ py-zensical (needs everything + pallet-rust + pallet-python)
For maturin packages you'll also need pallet-rust and pallet-python images:
make pallet-rust pallet-python
Troubleshooting Builds
| Symptom | Likely Cause | Fix |
|---|---|---|
tar: Can't open |
Wrong WORKDIR path | Extract the sdist locally: tar tzf fetch/py-*.tar.gz \| head -1 to see the real directory name |
ModuleNotFoundError |
Missing dependency | Add COPY --from=stagex/user-py-<dep> . / to the Containerfile |
gpep517: not found |
Wrong base image | Use pallet-cython for Python packages, pallet-rust + pallet-python for maturin |
error: invalid command 'bdist_wheel' |
Package uses setuptools | May need COPY --from=stagex/core-py-setuptools . / |
7. Commit Your Work
StageX uses conventional commits:
git add packages/user/py-<name>/
git commit -m "feat(user): add py-<name> v<version>"
For upgrades:
git add packages/user/py-<name>/package.toml
git commit -m "feat(user): upgrade py-<name> v<old> -> v<new>"
Push your branch and open a pull request against staging:
git push origin my/package-<name>
What We Added (Zensical Dependency Chain)
| Commit | Package | Type |
|---|---|---|
| 1 | py-tomli, py-markdown, py-click, py-deepmerge | Leaf (zero deps) |
| 2 | py-pygments 2.18.0 → 2.20.0 | Upgrade (security fix) |
| 3 | py-pymdown-extensions | Depends on markdown + yaml |
| 4 | py-zensical | Maturin (Rust + Python) |
See Also
- Upgrade a Package — detailed upgrade workflow
- Package.toml Format — full reference for the config format
- Makefile Targets & Environment — build targets reference
- Build Locally — setting up a StageX build environment
- Maintainer's Handbook — contribution workflow