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.
- Explain dependency conflicts and why reproducible environments need a lockfile
- Choose between conda-forge and PyPI packages, and declare each correctly in
pixi.toml - Structure a Pixi workspace with features, environments, solve-groups, and multi-version test environments
- Write tasks with
depends-on,args,env,cwd, and input/output caching - Read and write a
pyproject.toml: build system, metadata, entry points, and optional dependencies - Build sdists and wheels, pick version numbers with SemVer, and outline trusted publishing to PyPI
- Recognise when a conda recipe (rattler-build) is the right way to distribute
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:
- Conflicts. Package A needs
lib<2and package B needslib>=2. Both can't be installed in one environment. This is the classic diamond dependency problem. - Drift. You wrote
requestswithout 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. - 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.
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.
| Need | Command | Where it lands |
|---|---|---|
| A library on conda-forge | pixi add structlog | [dependencies] |
| A version range | pixi add "pandas>=3,<4" | [dependencies] |
| Only on PyPI | pixi add --pypi tiny-lib | [pypi-dependencies] |
| Your own package, editable | hand edit | [pypi-dependencies] with { path = ".", editable = true } |
| A dev-only tool | pixi 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:
[workspace]: identity (name, version, authors), the channels to search in priority order, and the platforms the lockfile must cover. Pixi solves for all four platforms even on a Mac, so a Windows teammate gets a lockfile that already works for them.[dependencies]: conda packages for the default feature, which every environment includes unless told otherwise. Python itself is just another package here.[pypi-dependencies]: PyPI packages. The editable path dependency installs this repository's ownsrc/practicumpackage so thatimport practicumand thepracticumcommand work, and so edits take effect without reinstalling.[feature.dev.dependencies]: a named feature, a bundle of extra dependencies (and optionally tasks, channels, or platforms).[environments]: which features each environment combines.[tasks]: named commands that run inside the environment.
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:
- A feature is a named set of dependencies, tasks, and settings.
- An environment is a list of features. Pixi solves each environment separately and records every one in the same lockfile.
[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:
- The default feature is implicit. Every environment includes the top-level
[dependencies]unless it saysno-default-feature = true. That's why thelintenvironment above contains only ruff and mypy. - Keep the default spec broad when features pin. The base says
python = ">=3.12", and eachpyXYZfeature narrows it. A base of"3.14.*"would contradict apy312feature. - Solve-groups share versions. Environments in the same
solve-groupare solved together, so any package they share gets the same version.prodis a subset ofdefault, and because they share themaingroup, production runs the exactstructlogbuild the tests used. Thepy312environment 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.
| Command | What it does |
|---|---|
pixi install | Make the environment match the manifest. It re-solves only if the manifest changed, and otherwise installs from the lock. |
pixi install --locked | Fail if pixi.lock is out of date with pixi.toml. Use this in CI. |
pixi install --frozen | Install exactly what the lock says, without checking the manifest. |
pixi lock | Solve 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 list | Every 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 info | System, 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 buildreads this table, installsrequires, and calls the backend. [project](PEP 621)- Standard metadata every tool understands.
requires-pythonis the oldest Python you support. Note that it's>=3.12while development uses 3.14, and that mypy'spython_version = "3.12"tracks that floor so 3.13+ APIs get flagged.dependencieslists runtime requirements as loose ranges. This repo leaves it empty and lets Pixi providestructlog. 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
practicumexecutable that importspracticum.cliand callsmain(). That's whypixi run practicum check-siteworks. Related tables are[project.gui-scripts]and[project.entry-points."group"]for plugins. [project.optional-dependencies]- "Extras" that users opt into, for example
pip-stylemypkg[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:
- An sdist (source distribution,
.tar.gz) is your source plus metadata. The installer must build it, which may need a compiler. - A wheel (
.whl, a zip file) is pre-built and ready to unpack intosite-packages. It installs fast and runs no code at install time.
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:
- PATCH (
1.4.2 → 1.4.3): bug fixes only, so everyone can upgrade safely. - MINOR (
1.4.2 → 1.5.0): new features that are backward compatible. Patch resets to 0. - MAJOR (
1.4.2 → 2.0.0): breaking changes. Minor and patch reset. - Before
1.0.0, anything may change. Many projects treat0.MINORas the "breaking" number.
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
- Bump the version in
pyproject.toml, updateCHANGELOG.md(pixi run changelog), and merge. - Tag the release with
git tag v0.3.0andgit push origin v0.3.0. - 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
cmdmaps to thecmdvalue; - a table with only
depends-onmaps 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 setsno-default-feature = true. - Apply each listed feature's
dependenciesin 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 raisesKeyError.
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
Quiz
Recap
- Reproducibility needs isolation (one environment per project), a manifest (what you want), and a lockfile (what was solved, with hashes, for every platform).
- conda-forge first. Use
--pypionly when a package is missing there, and never callpipin a workspace. - Features bundle dependencies and environments combine features.
no-default-featuregives you lean environments, and solve-groups give environments identical shared versions. - Tasks document workflows.
depends-onforms a DAG run in topological order, andargs,env,cwd, andinputs/outputsrefine them. pixi updatestays inside your specs andpixi upgraderewrites them.tree,list, andinfoshow what's installed.pyproject.tomlholds the build system, standard metadata, entry points, extras, and tool configuration.- A wheel is a pre-built zip and an sdist is source. Version with SemVer and publish through trusted publishing. Use conda recipes for native dependencies.
Going further
- Pixi documentation: workspaces, environments, tasks, and global tools
- Python Packaging User Guide
- pyproject.toml specification
- tomllib and importlib.metadata
- Semantic Versioning 2.0.0 and PEP 440
- PyPI trusted publishers
- rattler-build documentation
- Pixi cheat sheet