Part 6 · Professional Python · Module 41

Packaging & Environments with Pixi

Code that runs only on your laptop isn't finished yet. This module covers how professionals pin down every dependency, reproduce an environment on any machine, test across Python versions, and ship a package other people can install. It uses this course's own pixi.toml and pyproject.toml as the worked example.

⏱ ~2.5 hoursPrerequisites: M08 Modules, M17 Testing, M40 Standard Library

The problem: dependency hell and "works on my machine"

Every non-trivial program depends on other code. Your script imports pandas, which imports numpy, which was compiled against a particular C library, which needs a particular operating system feature. These dependencies form a graph, and the graph changes every day as maintainers release new versions. Three things go wrong again and again:

  1. Conflicts. Package A needs lib<2 and package B needs lib>=2. Both can't be installed in one environment. This is the classic diamond dependency problem.
  2. Drift. You wrote requests without a version. Six months later a fresh install picks a new major version with a changed API, and your code breaks even though nobody changed it.
  3. Pollution. Installing everything into one global Python means project X's upgrade breaks project Y. You also can't tell which packages a project actually needs.

The professional answer has three parts. First, an isolated environment per project: a directory containing its own Python and its own packages. Second, a manifest: a human-written file that lists what you want, with version ranges. Third, a lockfile: a machine-written file that records the exact versions, builds, and checksums the solver picked for every platform. The manifest records your intent and the lockfile records exactly what was installed. Pixi manages all three.

your-app reports 3.1 plots 5.0 tables ?? tables <2 tables >=2
A diamond conflict: two direct dependencies disagree about a shared transitive dependency.

A solver (dependency resolver) searches for one version of every package so that every constraint holds at the same time. Real solvers use SAT techniques; Pixi uses resolvo for conda packages and uv's resolver for PyPI packages. The brute-force toy below shows the idea. Run it, then change plots' requirement so a solution exists.

Remember: isolation stops projects from interfering with each other, the manifest records your intent, and the lockfile makes installs reproducible.

conda-forge vs PyPI

There are two big public package ecosystems for Python, and Pixi speaks both.

conda-forge (a conda channel)

A community-run channel: a server hosting conda packages. A conda package can contain anything, including Python itself, C/C++ libraries (OpenSSL, SQLite, BLAS), compilers, and command-line tools like git-cliff and act. Every package declares its binary dependencies, so the solver can guarantee compatible native libraries.

PyPI (the Python Package Index)

The official index for Python distributions (wheels and sdists). Almost every Python library publishes here first. Wheels bundle compiled code, but they can't declare non-Python system libraries, so they ship their own copies or assume the OS provides them.

This course follows one rule: conda-forge first. Use pixi add numpy. Only when a package is missing from conda-forge, use pixi add --pypi some-package, which writes it under [pypi-dependencies]. Pixi solves the conda packages first, then resolves PyPI packages on top, treating the conda-installed Python packages as already satisfied. Mixing works, but every PyPI package is one whose native libraries the conda solver can't check.

NeedCommandWhere it lands
A library on conda-forgepixi add structlog[dependencies]
A version rangepixi add "pandas>=3,<4"[dependencies]
Only on PyPIpixi add --pypi tiny-lib[pypi-dependencies]
Your own package, editablehand edit[pypi-dependencies] with { path = ".", editable = true }
A dev-only toolpixi add --feature dev ruff[feature.dev.dependencies]

Remember: look on conda-forge first and fall back to --pypi. Whichever you use, every package goes through Pixi so it ends up in the lockfile.

Anatomy of a Pixi workspace

A workspace is a directory with a pixi.toml manifest. Create one with pixi init. Don't write it by hand, because the generated file uses the current schema, for example the [workspace] table that replaced the older [project]. Here is the real manifest of the repository you're using right now:

[workspace]
authors = ["Kevin Mills <millsks@gmail.com>"]
channels = ["conda-forge"]
name = "python-practicum"
platforms = ["osx-arm64", "osx-64", "linux-64", "win-64"]
version = "0.1.0"

[dependencies]
python = "3.14.*"
structlog = ">=26.1.0,<27"
numpy = ">=2.5.4,<3"
pandas = ">=3.0.6,<4"
# ... scipy, scikit-learn, matplotlib, polars, pyarrow, python-duckdb

[pypi-dependencies]
python-practicum = { path = ".", editable = true }

[feature.dev.dependencies]
pytest = "*"
pytest-cov = "*"
mypy = "*"
ruff = "*"
pre-commit = "*"
git-cliff = "*"
python-build = "*"
hatchling = "*"
act = "*"

[environments]
default = { features = ["dev"], solve-group = "default" }

[tasks]
fmt = "ruff format ."
lint = "ruff check ."
check = "mypy src/"
test = "pytest tests/unit/"
cov = "pytest tests/ --cov=src --cov-report=term-missing --cov-fail-under=90"
build = "python -m build --no-isolation"
pre-commit-all = "pre-commit run --all-files"
ci = { depends-on = ["pre-commit-all", "build", "check", "lint", "cov"] }
start = { cmd = "practicum start {{ project }}", args = ["project"] }

Read it table by table:

TOML is just data, so you can explore a manifest with the standard library's tomllib (read-only TOML parsing). Run this on a trimmed copy of the manifest:

The everyday commands:

pixi init textstats            # new workspace (writes pixi.toml, .gitattributes, .gitignore)
cd textstats
pixi add python=3.14 structlog # conda-forge packages
pixi add --feature dev pytest ruff mypy
pixi add --pypi tiny-lib       # only if conda-forge lacks it
pixi install                   # solve, write pixi.lock, create .pixi/envs/default
pixi run python -c "import structlog; print(structlog.__version__)"
pixi shell                     # an activated subshell; type exit to leave
pixi remove tiny-lib

Remember: [workspace] sets where packages come from and which platforms to solve for, the dependency tables say what to install, features and environments group those dependencies, and tasks name the commands you run.

Features, environments, and solve-groups

A real project needs more than one environment. CI wants the test tools. A lint job wants only ruff, so it installs in seconds. Production wants the runtime dependencies and nothing else. Pixi handles this with two ideas:

[dependencies]                # the "default" feature
python = ">=3.12"
structlog = ">=26.1,<27"

[feature.test.dependencies]
pytest = "*"
pytest-cov = "*"

[feature.lint.dependencies]
ruff = "*"
mypy = "*"

[feature.py312.dependencies]
python = "3.12.*"
[feature.py313.dependencies]
python = "3.13.*"
[feature.py314.dependencies]
python = "3.14.*"

[environments]
default = { features = ["test", "lint", "py314"], solve-group = "main" }
prod    = { features = ["py314"], solve-group = "main" }
lint    = { features = ["lint"], no-default-feature = true }
py312   = ["py312", "test"]
py313   = ["py313", "test"]
py314   = ["py314", "test"]
flowchart LR
  D[default feature
python, structlog] --> E1[default env] T[test] --> E1 L[lint] --> E1 P4[py314] --> E1 D --> E2[prod env] P4 --> E2 L --> E3[lint env
no-default-feature] D --> E4[py312 env] P2[py312] --> E4 T --> E4

Three details matter:

  1. The default feature is implicit. Every environment includes the top-level [dependencies] unless it says no-default-feature = true. That's why the lint environment above contains only ruff and mypy.
  2. Keep the default spec broad when features pin. The base says python = ">=3.12", and each pyXYZ feature narrows it. A base of "3.14.*" would contradict a py312 feature.
  3. Solve-groups share versions. Environments in the same solve-group are solved together, so any package they share gets the same version. prod is a subset of default, and because they share the main group, production runs the exact structlog build the tests used. The py312 environment can't join that group because it needs a different Python.
pixi run -e py312 pytest          # run in a specific environment
pixi run -e lint ruff check .
pixi shell -e py313
pixi install --all                # install every environment
pixi info                         # lists environments, their features, solve groups, and tasks

The exercise in Practice asks you to compute an environment's merged dependencies. Here is the core of the idea, simplified. A later feature's spec replaces an earlier one, which is how py312 narrows Python:

Remember: environments are made of features. Every environment gets the default feature unless you opt out, and environments in the same solve-group get identical versions of shared packages.

Tasks: depends-on, args, env, cwd, and caching

A task is a named command that Pixi runs inside an environment. It's cross-platform, because Pixi uses its own shell (deno_task_shell) and simple commands work the same on Windows. Tasks make the project self-documenting: a newcomer runs pixi task list and sees every workflow.

[tasks]
# A plain string is the command.
lint = "ruff check ."

# A table unlocks options.
test = { cmd = "pytest tests/unit/", description = "Fast unit tests" }

# depends-on: run these first (in order, each once).
ci = { depends-on = ["lint", "check", "test"] }

# args: named, optional defaults, substituted with {{ name }} (MiniJinja templates).
grade = { cmd = "practicum grade {{ project }}", args = ["project"] }
serve = { cmd = "python -m http.server {{ port }}", args = [{ arg = "port", default = "8000" }], cwd = "docs" }

# env: environment variables for this task.
debug-test = { cmd = "pytest -x", env = { PYTHONFAULTHANDLER = "1", STRUCTLOG_JSON = "0" } }

# inputs/outputs: skip the task if nothing changed since the last successful run.
build = { cmd = "python -m build --no-isolation", inputs = ["src/**/*.py", "pyproject.toml"], outputs = ["dist/*"] }

# Run a task in a specific environment as a dependency.
test-all = { depends-on = [
  { task = "test", environment = "py312" },
  { task = "test", environment = "py313" },
  { task = "test", environment = "py314" },
] }
pixi run ci                # runs lint, check, test, then ci itself
pixi run grade p01         # {{ project }} becomes p01
pixi run serve 9000        # overrides the default port
pixi run build             # second time: "skipping, no changes" if inputs/outputs unchanged
pixi task list
pixi task add fmt "ruff format ."

Pixi turns depends-on into a directed acyclic graph (DAG) and runs it in topological order, so every task runs after the tasks it depends on. This is the same algorithm you met in M31. The standard library has it built in, as graphlib.TopologicalSorter:

Here is depth-first search producing the same order, the approach you'll implement in exercise 3. Step through it and watch done grow:

How input/output caching works. When a task declares inputs and outputs, Pixi hashes the matching files plus the command and environment after a successful run. Next time, if every hash still matches, it skips the task. This toy version uses hashlib:

Remember: tasks are the project's documented workflows. depends-on builds a DAG, args parameterises a command, env and cwd set its context, and inputs/outputs let Pixi skip work that's already done.

The lockfile, updating, and inspecting

pixi.lock is a YAML file that Pixi writes. For every environment and every platform it records the exact URL, checksum (sha256), and dependencies of each package. Here is a real excerpt from this repository:

version: 7
environments:
  default:
    channels:
    - url: https://conda.anaconda.org/conda-forge/
    indexes:
    - https://pypi.org/simple
    packages:
      linux-64:
      - conda: https://conda.anaconda.org/conda-forge/linux-64/bzip2-1.0.8-hda65f42_10.conda
      ...
- conda: https://conda.anaconda.org/conda-forge/noarch/structlog-26.1.0-pyhcf101f3_0.conda
  sha256: c0de1e3bf15ca975300a647bbffdc1b5ee5e98a55122510a240643ca8383eb65
  depends:
  - python >=3.10
  - typing-extensions
  license: Apache-2.0 OR MIT

Commit the lockfile. It's what makes "it works on my machine" true on every machine. CI, your teammates, and future-you six months from now all get the exact same bytes. The checksums also protect you from a tampered download. Pixi writes a .gitattributes entry marking pixi.lock as generated, so code review tools collapse its diff.

CommandWhat it does
pixi installMake the environment match the manifest. It re-solves only if the manifest changed, and otherwise installs from the lock.
pixi install --lockedFail if pixi.lock is out of date with pixi.toml. Use this in CI.
pixi install --frozenInstall exactly what the lock says, without checking the manifest.
pixi lockSolve and write the lockfile without installing.
pixi update [pkg]Re-solve to the newest versions allowed by the current specs. The manifest is unchanged.
pixi upgrade [pkg]Raise the version specs in the manifest to the latest releases (for example <27 to <28), then update the lock.
pixi listEvery installed package with version, build, size, kind (conda/pypi), and source.
pixi tree [pkg]The dependency tree. --invert pkg answers "who pulled this in?"
pixi infoSystem, workspace, environments, features, solve groups, and tasks.
$ pixi tree --invert python-discovery
python-discovery 1.6.2
└── virtualenv 21.14.7
    └── pre-commit 4.7.0

$ pixi list
Name           Version   Build          Size       Kind   Source
act            0.2.89    h75b854d_0     12.98 MiB  conda  https://conda.anaconda.org/conda-forge
...

$ pixi info
Environments
------------
        Environment: default
           Features: dev, default
        Solve group: default
   Target platforms: win-64, linux-64, osx-64, osx-arm64

Version specs are where many surprises start. Predict what this naive comparison prints:

Remember: commit pixi.lock and install with --locked in CI. update moves versions inside your existing specs, upgrade rewrites the specs themselves, and tree and list show you what's actually installed.

pixi global and pixi exec

Some tools belong to you, not to a project: a favourite editor helper, gh, a linter you run on random files. pixi global installs each tool into its own isolated environment and puts only its executables on your PATH (in ~/.pixi/bin). Two global tools can never break each other.

pixi global install ruff gh       # each gets its own environment
pixi global list
pixi global update ruff
pixi global uninstall gh
cat ~/.pixi/manifests/pixi-global.toml   # global tools are declared in a manifest too

pixi exec (alias pixi x) goes one step further. It creates a temporary, cached environment, runs one command, and leaves nothing installed. Use it for one-offs and for trying new Python versions:

pixi exec cowpy "hello"                       # package and command share a name
pixi exec --spec "python=3.15" python demo.py # try the 3.15 preview without touching any project
pixi exec --spec "python=3.12" --spec pytest pytest tests/

Remember: global is for your personal toolbox, exec is for one-off commands, and the project manifest is for anything the project needs.

pyproject.toml anatomy

pixi.toml describes the development environment. pyproject.toml describes the Python package you're building: what it's called, what it needs at runtime, how to build it, and how every tool is configured. The two cooperate. Here is this repository's file, trimmed:

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "python-practicum"
version = "0.1.0"
description = "Python Practicum: an interactive novice-to-pro curriculum ..."
readme = "README.md"
license = "MIT"
authors = [{ name = "Kevin Mills", email = "millsks@gmail.com" }]
requires-python = ">=3.12"
dependencies = []

[project.scripts]
practicum = "practicum.cli:main"

[tool.hatch.build.targets.wheel]
packages = ["src/practicum"]

[tool.ruff]
line-length = 120
[tool.mypy]
python_version = "3.12"
strict = true
[build-system] (PEP 517/518)
Names the build backend, the library that turns your source tree into distributable files. Hatchling is a modern, fast choice. Others are setuptools, flit-core, pdm-backend, and maturin (for Rust extensions). A build frontend such as python -m build reads this table, installs requires, and calls the backend.
[project] (PEP 621)
Standard metadata every tool understands. requires-python is the oldest Python you support. Note that it's >=3.12 while development uses 3.14, and that mypy's python_version = "3.12" tracks that floor so 3.13+ APIs get flagged. dependencies lists runtime requirements as loose ranges. This repo leaves it empty and lets Pixi provide structlog. A library published to PyPI would list "structlog>=26.1" here.
[project.scripts]
A console-script entry point. When the package is installed, the installer writes a small practicum executable that imports practicum.cli and calls main(). That's why pixi run practicum check-site works. Related tables are [project.gui-scripts] and [project.entry-points."group"] for plugins.
[project.optional-dependencies]
"Extras" that users opt into, for example pip-style mypkg[plot]. Use them for optional runtime features, not for dev tools.
[dependency-groups] (PEP 735)
Named groups for development (test, docs) that are not published as part of the package. In a Pixi workspace, features usually play this role.
[tool.*]
Each tool's configuration: ruff, mypy, pytest, coverage, git-cliff. One file, no dotfile sprawl.
[project]
dependencies = ["structlog>=26.1"]

[project.optional-dependencies]
plot = ["matplotlib>=3.11"]
fast = ["polars>=2"]

[project.entry-points."textstats.formatters"]
json = "textstats.formatters:JsonFormatter"

Entry points are the same module:attribute strings everywhere. The standard library can load one, which is exactly what a console script does:

Remember: [build-system] says how to build, [project] says what you're shipping, and [tool.*] holds every tool's settings. Entry points are module:attr strings.

Building sdists and wheels

A distribution is a file you upload and users install. There are two kinds:

A wheel's filename encodes where it can be installed: {name}-{version}-{python tag}-{abi tag}-{platform tag}.whl. Pure-Python packages produce one universal wheel, py3-none-any. Compiled extensions produce one wheel per Python and platform, such as cp314-cp314-manylinux_2_28_x86_64.

This repository builds through a task: build = "python -m build --no-isolation". By default python -m build creates a temporary isolated environment and downloads hatchling from PyPI into it. --no-isolation tells it to use the backend that's already in the Pixi environment instead (that's why hatchling and python-build are in the dev feature). The build is then offline-friendly, uses locked versions, and stays reproducible.

$ pixi run build
* Building sdist...
* Building wheel from sdist
Successfully built python_practicum-0.1.0.tar.gz and python_practicum-0.1.0-py3-none-any.whl

$ pixi run python -m zipfile -l dist/python_practicum-0.1.0-py3-none-any.whl
practicum/__init__.py
practicum/cli.py
...
python_practicum-0.1.0.dist-info/METADATA
python_practicum-0.1.0.dist-info/entry_points.txt
python_practicum-0.1.0.dist-info/RECORD

The .dist-info directory is the installed metadata. METADATA holds the [project] fields, entry_points.txt holds your scripts, and RECORD lists every file with a hash. The cell below builds a miniature wheel-shaped zip in memory, so you can see it's just a zip archive:

Remember: an sdist is source that the installer still has to build, and a wheel is a pre-built zip. Build both with python -m build through a Pixi task, and test the wheel you built.

Versioning and publishing to PyPI

Semantic Versioning (SemVer) gives a version MAJOR.MINOR.PATCH a meaning:

That meaning is what makes the specs in this repo's pixi.toml work: structlog = ">=26.1.0,<27" says "any 26.x from 26.1 on, but not the next major". Python's own version rules (PEP 440) add pre-releases like 2.0.0rc1, post-releases, and dev releases. Python itself follows a calendar-based schedule rather than SemVer.

Publishing. Uploading to PyPI used to mean storing a long-lived API token as a CI secret. If it leaked, attackers could publish malware under your name. Trusted publishing replaces the token with OpenID Connect (OIDC). You tell PyPI "releases of textstats may come from GitHub repo me/textstats, workflow release.yml, environment pypi". At release time GitHub issues a short-lived identity token, PyPI checks it and swaps it for an upload token that expires in minutes, and no secret is stored anywhere.

name: Release
on:
  push:
    tags: ["v*"]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: prefix-dev/setup-pixi@v0.8.1
      - run: pixi run build
      - uses: actions/upload-artifact@v4
        with: { name: dist, path: dist/ }

  publish:
    needs: build
    runs-on: ubuntu-latest
    environment: pypi            # matches the trusted publisher configured on PyPI
    permissions:
      id-token: write            # lets the job request an OIDC token; no secrets needed
    steps:
      - uses: actions/download-artifact@v4
        with: { name: dist, path: dist/ }
      - uses: pypa/gh-action-pypi-publish@release/v1
  1. Bump the version in pyproject.toml, update CHANGELOG.md (pixi run changelog), and merge.
  2. Tag the release with git tag v0.3.0 and git push origin v0.3.0.
  3. CI builds the sdist and wheel once and publishes those same files. Try the whole flow first with TestPyPI.

Remember: the version number tells users what kind of change to expect. Publish from CI with trusted publishing, so no long-lived token is stored anywhere.

Conda packages with rattler-build

PyPI is the right home for most Python libraries. A conda package is better when your project needs non-Python pieces (a C library, a CLI written in another language) or when your users are Pixi or conda users who want a single solver for everything. conda-forge packages are built from recipes by rattler-build, the same Rust-based package toolkit (rattler) that Pixi is built on.

context:
  version: "0.3.0"

package:
  name: textstats
  version: ${{ version }}

source:
  path: ..

build:
  noarch: python
  script: python -m pip install . --no-deps --no-build-isolation -vv
  python:
    entry_points:
      - textstats = textstats.cli:main

requirements:
  host:
    - python >=3.12
    - hatchling
    - pip
  run:
    - python >=3.12
    - structlog >=26.1

tests:
  - python:
      imports: [textstats]
  - script: textstats --help

The pip inside a recipe runs in rattler-build's throwaway build environment to copy your package into the conda package. It's an implementation detail of the recipe, not something you run in your workspace. Build locally with pixi exec rattler-build build --recipe recipe/recipe.yaml. To publish to conda-forge, you submit the recipe to conda-forge/staged-recipes. After review, a "feedstock" repository rebuilds the package automatically for every platform and new release.

Remember: publish wheels to PyPI for Python users. Add a conda recipe when you need native dependencies or want first-class Pixi and conda installs.

Practice

These exercises use the same data structures Pixi and packaging tools use. They're graded in the browser against real Python 3.14.

Write bump(version, part). version is "MAJOR.MINOR.PATCH" and part is "major", "minor", or "patch". Return the new version string, resetting the lower numbers to zero as SemVer requires. Raise ValueError for any other part.

Hint

Split on ".", convert with int, then build the result with an f-string for each case.

Show solution
def bump(version: str, part: str) -> str:
    major, minor, patch = (int(p) for p in version.split("."))
    if part == "major":
        return f"{major + 1}.0.0"
    if part == "minor":
        return f"{major}.{minor + 1}.0"
    if part == "patch":
        return f"{major}.{minor}.{patch + 1}"
    raise ValueError(f"unknown part: {part!r}")

Write describe_tasks(manifest_text). It parses a pixi.toml string with tomllib and returns a dict mapping each task name to a one-line description:

  • a string task maps to the command itself;
  • a table with cmd maps to the cmd value;
  • a table with only depends-on maps to "depends-on: a, b" (names joined by ", ", in order).

A manifest with no [tasks] table returns {}.

Hint

tomllib.loads(text).get("tasks", {}) gives a dict. Check isinstance(task, str) first, then look for "cmd".

Show solution
import tomllib

def describe_tasks(manifest_text: str) -> dict[str, str]:
    tasks = tomllib.loads(manifest_text).get("tasks", {})
    result: dict[str, str] = {}
    for name, task in tasks.items():
        if isinstance(task, str):
            result[name] = task
        elif "cmd" in task:
            result[name] = task["cmd"]
        else:
            result[name] = "depends-on: " + ", ".join(task.get("depends-on", []))
    return result

Write task_order(manifest_text, target) that returns the list of task names Pixi would run for pixi run target: every dependency before the task that needs it, each task once, dependencies visited in their listed order, and target last. String tasks have no dependencies. Raise ValueError if a task is unknown or the dependencies contain a cycle.

Hint

Depth-first search with two sets: done and visiting. Meeting a task that is still in visiting means you've found a cycle.

Show solution
import tomllib

def task_order(manifest_text: str, target: str) -> list[str]:
    tasks = tomllib.loads(manifest_text).get("tasks", {})
    order: list[str] = []
    done: set[str] = set()
    visiting: set[str] = set()

    def deps_of(name: str) -> list[str]:
        task = tasks[name]
        return [] if isinstance(task, str) else list(task.get("depends-on", []))

    def visit(name: str) -> None:
        if name not in tasks:
            raise ValueError(f"unknown task: {name}")
        if name in done:
            return
        if name in visiting:
            raise ValueError(f"cycle through {name}")
        visiting.add(name)
        for dep in deps_of(name):
            visit(dep)
        visiting.discard(name)
        done.add(name)
        order.append(name)

    visit(target)
    return order

Write satisfies(version, spec) for a simplified version-spec language. version looks like "3.14.2". spec is one or more comma-separated clauses, all of which must hold:

  • "*" matches anything;
  • "3.14.*" matches versions starting with those components;
  • >=, <=, >, <, == followed by a version compare numerically. Pad the shorter version with zeros, so "3.12" equals "3.12.0".

Spaces around clauses are allowed.

Hint

Check two-character operators before one-character ones. Convert versions to tuples of ints and pad both to the same length before comparing.

Show solution
import operator

OPS = [(">=", operator.ge), ("<=", operator.le), ("==", operator.eq), (">", operator.gt), ("<", operator.lt)]

def _parts(v: str) -> list[int]:
    return [int(p) for p in v.split(".")]

def _clause(version: str, clause: str) -> bool:
    clause = clause.strip()
    if clause == "*":
        return True
    if clause.endswith(".*"):
        prefix = _parts(clause[:-2])
        return _parts(version)[: len(prefix)] == prefix
    for symbol, op in OPS:
        if clause.startswith(symbol):
            a, b = _parts(version), _parts(clause[len(symbol):].strip())
            width = max(len(a), len(b))
            a += [0] * (width - len(a))
            b += [0] * (width - len(b))
            return op(a, b)
    return _parts(version) == _parts(clause)

def satisfies(version: str, spec: str) -> bool:
    return all(_clause(version, c) for c in spec.split(","))

Write environment_deps(manifest_text, env) that returns the merged conda dependency dict for environment env:

  • Start from top-level [dependencies], unless the environment sets no-default-feature = true.
  • Apply each listed feature's dependencies in order. Later specs replace earlier ones for the same package.
  • An environment may be a plain list of features or a table with features.
  • "default" with no [environments] entry means just the top-level dependencies. Any other unknown environment raises KeyError.
Hint

Normalise the environment entry first: if it is a list, turn it into {"features": that_list}.

Show solution
import tomllib

def environment_deps(manifest_text: str, env: str) -> dict[str, str]:
    data = tomllib.loads(manifest_text)
    envs = data.get("environments", {})
    if env in envs:
        entry = envs[env]
    elif env == "default":
        entry = {"features": []}
    else:
        raise KeyError(env)
    if isinstance(entry, list):
        entry = {"features": entry}
    deps: dict[str, str] = {}
    if not entry.get("no-default-feature", False):
        deps.update(data.get("dependencies", {}))
    for name in entry.get("features", []):
        deps.update(data["feature"][name].get("dependencies", {}))
    return deps

Mini-project: package a CLI tool

Mini-projecttextstats: a packaged, multi-version CLI

Build textstats, a command-line tool that prints word, line, and character counts and the top N words of a text file. The point isn't the counting. It's producing a project that any teammate can clone and run with one command, on any OS and any supported Python.

Steps

  1. pixi init textstats, then create the src layout: src/textstats/__init__.py, src/textstats/core.py (pure functions), src/textstats/cli.py (an argparse main() that returns an exit code).
  2. Write pyproject.toml with hatchling, [project] metadata, requires-python = ">=3.12", and [project.scripts] textstats = "textstats.cli:main".
  3. In pixi.toml, add textstats = { path = ".", editable = true } under [pypi-dependencies]. Set the base python = ">=3.12". Add features test (pytest, pytest-cov), lint (ruff, mypy), build (python-build, hatchling), and py312/py313/py314.
  4. Define environments: default (test, lint, build, py314), lint with no-default-feature, and py312/py313/py314 with test.
  5. Add tasks: test, lint, check, and build with inputs/outputs. Add stats with an args entry for the file path, test-all using depends-on with an environment per Python version, and ci chaining everything.
  6. Write tests in tests/unit/test_core.py and run pixi run -e py312 test.
  7. pixi run build and list the wheel's contents with python -m zipfile -l.
  8. Commit pixi.toml, pixi.lock, and pyproject.toml. Clone into a new directory and check that pixi run ci passes there.
  • pixi run textstats README.md prints counts (the console script works)
  • pixi run test-all passes on Python 3.12, 3.13, and 3.14
  • pixi run -e lint lint installs only ruff and mypy (check with pixi list -e lint)
  • Running pixi run build twice skips the second build
  • The wheel is named textstats-0.1.0-py3-none-any.whl and contains entry_points.txt
  • pixi.lock is committed and a fresh clone passes pixi install --locked

Stretch goals

  • Add [project.optional-dependencies] fast = ["polars>=2"] and use polars when it's importable.
  • Write a release.yml for trusted publishing to TestPyPI.
  • Write a rattler-build recipe.yaml and build a conda package locally.
  • Try your CLI on the 3.15 preview with pixi exec --spec "python=3.15", using lazy import for the heavy imports in cli.py.

Quiz

Recap

Going further