Skip to content

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. Markdown uses M/Markdown/. click uses c/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 file and mirrors fields in package.toml, the WORKDIR path in the Containerfile. 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

  • file is the local filename (prefix with py-)
  • mirrors is the upstream URL (uses the PyPI canonical name, without py- 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 the py- 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-extensions uses 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 with tar tzf if 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 (NOT pallet-cython)
  • Must COPY --from=stagex/pallet-python . / for the Python runtime
  • Uses python -m build --wheel --no-isolation instead of gpep517
  • 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-isolation flag tells the build backend to use pre-installed packages (from the COPY --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