uv-stack
Reusable package sets for shared Python environments and uv projects — powered by micromamba and uv.
You define what you want in a few small files — profiles (named package
groups) and bundles (groups of profiles). Then you install those
definitions somewhere: a shared environment you activate (one stack.txt per
environment), or a uv project that carries its own .venv. The stack
command renders, locks, and installs everything else, splitting the work
between two tools:
| Tool | Owns |
|---|---|
micromamba |
The Python interpreter and conda-level binary packages (from conda-forge) |
uv |
Every pip-level package — compiled to a lock file, then synced exactly |
micromamba builds the room (the interpreter and any conda-level binaries);
uv furnishes it (fast, fully locked pip packages). The same profiles and
bundles feed both destinations. The box on the left is what you author —
inside [tool.uv-stack] that means the stack key, since the rest of that
table is uv-stack's own bookkeeping. requirements.in and environment.yml
are generated and carry a # Generated by uv-stack — do not edit. header,
and uv compiles requirements.lock.txt from requirements.in. On the
project path stack writes the resolved packages into your own
pyproject.toml and records what it applied back into [tool.uv-stack],
leaving the lock and .venv to uv.
Why you might want this:
- Define packages once, reuse everywhere. The same profile feeds your shared notebook environment and your uv projects.
- Shared environments are rebuildable. A shared environment is a pure
function of its source files. Delete it,
stack create env NAME, and it comes back. - Real locks. Every shared environment gets a fully pinned
requirements.lock.txt, compiled and installed withuv(fast), and verified withuv pip check.
- Installation
- Quickstart
- What you define
- Where it goes
- Everyday commands
- Configuration reference
- Tips and gotchas
Installation
Prerequisites (both must be installed and reachable):
uvmicromamba— runmicromamba shell initafter installing so$MAMBA_EXEis set
Then install the CLI as a standalone tool (requires Python 3.12+):
uv tool install uv-stack
(pip install uv-stack works too.) This gives you one command: stack.
Quickstart
Five minutes from nothing to a working, locked environment.
# One guided command sets up everything with prompts — a config tree, a
# commented 'starter' profile, and a 'main' environment (--yes accepts defaults):
stack init
# Prefer explicit steps? An equivalent setup by hand, with your own profile:
stack config init
stack create profile ds numpy pandas --description "Core data-science stack" --tag data
stack create env main ds --python 3.12
# Use it (stack prints this hint after every successful create env):
micromamba activate main
# ...or run things in it without activating:
micromamba run -n main python
What just happened: stack resolved the ds token to your profile, generated
requirements.in and environment.yml inside envs/main/, had micromamba
create the main environment (Python 3.12 by default), compiled a pinned
requirements.lock.txt with uv pip compile, installed it exactly with
uv pip sync, and verified consistency with uv pip check.
From now on, changing what's installed is the same two steps: edit a source file (a profile, a bundle, or stack.txt), then run stack upgrade main. For a freshly cloned config root, use stack sync to build the environments first. Check what needs rebuilding at any time with stack status.
What you define
Profiles
A profile is a named list of pip packages, stored as
profiles/<name>.yaml in the config root:
description: Core data-science stack # optional
tags: [data, core] # optional, used by `stack list --tag`
includes: # required: literal pip requirements
- numpy>=2
- pandas
includes entries may reference a variable the config root declares, written
${NAME}. This is what lets one set of profiles describe editable checkouts
that live in different places on different machines:
includes:
- numpy>=2
- -e ${DEV}/mytool
Three rules govern where a reference may appear:
- Every entry is a single line. One requirement per line, always.
- The name must be declared in
variables.txt. A name the environment happens to export but the root does not declare is an error, because the declaration is what makes the reference part of the root's portable contract. - A reference may appear only in an editable (
-e ${DEV}/pkg), in another option line (--find-links ${WHEELS}), or in an entry whose first token is a path (${DEV}/pkg) — and never in a-r/--requirementor-c/--constraintinclude.
Rule 3 is not stylistic. A reference in the distribution-name position
(${PKG}>=2) would make the same entry mean different packages on different
machines, which corrupts the dependency ownership a tracked project records in
its pyproject.toml. A reference inside an included file would pull in
dependencies the config root cannot see. stack doctor reports every entry
that breaks one of these rules, naming the file, the entry, and the rule.
Bundles
A bundle is a recipe that composes profiles, other bundles, and literal
packages, stored as bundles/<name>.yaml. Same schema, but includes entries
are stack tokens:
description: Everything for daily work
includes:
- ds # a profile
- chem # another profile
- utils
Bundles can nest other bundles; duplicates are removed automatically.
Stack tokens
Tokens are how you reference things in stack.txt, bundle includes, and on
the stack create project / stack resolve command line:
| Token | Meaning |
|---|---|
ds (bare name) |
Profile ds if it exists, else bundle ds, else a literal package |
profile:ds |
Profile ds (error if missing) |
@standard or bundle:standard |
Bundle standard (error if missing) |
pkg:numpy or package:numpy |
Always a literal package — the escape hatch when a name collides with a profile or bundle |
numpy>=2, -e ~/src/mytool, archive paths |
Literal pip requirement, passed through |
Use stack resolve to see how tokens are classified, and
stack resolve --full to expand them into a flat package list.
Where it goes
A definition is inert until you install it somewhere, and there are two
destinations. The choice is not "an environment or a project" — either way
you end up with an interpreter and a set of installed packages, and a project
carries its own .venv for exactly that. The real difference is where the
packages live: in one central, named place you activate from anywhere, or
inside a single directory alongside the code that uses them.
| Shared environment | Project | |
|---|---|---|
| Create with | stack create env NAME TOKENS... |
stack create project TOKENS... |
| Lives in | envs/<name>/ in your config root |
the current directory — pyproject.toml, uv.lock, .venv/ |
| Enter it with | micromamba activate <name> |
uv run ... |
| Update with | stack upgrade <name> |
stack refresh |
| Inspect with | stack show env <name>, stack status |
stack show project |
| Remove with | stack delete env <name> |
stack delete project |
| Reach for it when | notebooks, ad-hoc work, tooling spanning many repos | one codebase, dependencies versioned and shipped with the code |
Shared environments are registered centrally under the config root, so
stack list env and stack status can enumerate them all. A project is
identified by the pyproject.toml in the directory you are standing in: there
is no registry to enumerate, and no parent-directory search — run
stack refresh and stack show project from the project root. That is also
why there is deliberately no stack list project.
Shared environments
A named shared environment lives in envs/<name>/ under the config root. It
exists as soon as stack.txt does.
Files you write:
| File | Purpose |
|---|---|
stack.txt |
Required. One stack token per line; # comments allowed |
python.txt |
Interpreter version (defaults to 3.12); applied at create and --recreate only |
micromamba.txt |
Extra conda packages (e.g. graphviz) |
channels.txt |
Extra conda channels (conda-forge is always first) |
requirements.local.in |
Machine-local pip additions, kept out of profiles |
Files stack generates (never edit these):
| File | Purpose |
|---|---|
environment.yml |
micromamba spec: python=<version>, pip, conda packages |
requirements.in |
Expanded, unpinned pip requirements |
requirements.lock.txt |
Fully pinned lock, produced by uv pip compile |
Projects
For per-project work, stack can seed a standard uv project from the same
profiles and bundles:
mkdir myproject && cd myproject
stack create project @standard pkg:httpx
This resolves the tokens to a flat package list, then runs uv init --bare,
uv add, and uv sync — leaving you a normal uv project with a
pyproject.toml, uv.lock, and .venv/.
Choosing the project's interpreter with --python:
- A version (
3.12), a path, or a spec likecpython@3.12is passed straight touv. - Anything else is treated as a micromamba environment name — the project
uses that environment's interpreter.
stack create project ds --python mainbuilds the project onmain's Python. - A botched version —
3.12.*,3.12.x— is not a version either, so it lands in the case above. It is still refused, but the message names the typo rather than telling you to create an environment called3.12.x.stack edit projectwarns about one in the tracking table when the editor exits. (Note that3.12.*is legal in an environment'spython.txt, which conda reads, not uv.) - With no
--python, the default comes from$UV_STACK_PROJECT_PYTHON, then<config-root>/project-python.txt, then3.12.
New projects are tracked by default: stack create project records
your tokens in a [tool.uv-stack] table inside pyproject.toml
(--no-track opts out for one-shot scaffolds). When your profiles or
bundles change later, re-resolve the project in place:
stack refresh # apply profile/bundle changes to this project
stack refresh --dry-run # see the add/remove delta first
To add tokens as well, use stack sync project TOKENS... (see Upgrading); it
extends the recorded stack rather than replacing it.
Refresh removes only packages recorded in the table's applied list — the
ones uv-stack itself added. Dependencies whose names uv-stack never applied
are never touched. One caveat: ownership is by package name, so if you
re-pin a stack-applied package yourself (say uv add 'numpy<2' after a
profile applied numpy), uv-stack still owns that name and a later refresh
may rewrite or remove it. After an interrupted run, recovery adopts leftover pending names that are still installed but no longer in the stack — it warns first and the next successful stack refresh removes them (the warning tells you how to keep one). Day to day, the project is still a normal uv
project — uv add, uv sync, and uv run all work as usual.
Everyday commands
| Command | What it does |
|---|---|
stack init |
Guided first-run setup (config tree, starter profile, first env) |
stack create env NAME [TOKENS]... |
Scaffold (optional) and build a shared environment (--recreate wipes and rebuilds it: the lock is compiled first, and the conda layer is destroyed only if that succeeds) |
stack create profile NAME PKG... |
Write a new profile YAML (--description, --tag) |
stack create bundle NAME TOKEN... |
Write a new bundle YAML (--description, --tag) |
stack delete env NAME |
Remove a shared environment: its micromamba environment, then envs/NAME/ |
stack delete profile NAME / stack delete bundle NAME |
Delete the YAML; refused while something still refers to it (--force overrides) |
stack delete project |
Withdraw uv-stack from the project in this directory: uv remove what it applied, drop [tool.uv-stack] |
stack edit KIND [NAME] |
Open a profile, bundle, env source, project file, or remotes.yaml in your editor and validate it when the editor exits |
stack upgrade [NAMES]... |
Re-render, re-lock, and sync shared environments |
stack sync |
Create, build, and recompile every environment the root declares (no prompt, keeps existing pins) |
stack sync env NAME... |
The same, for only the named environments |
stack sync project TOKENS... |
Add tokens to the tracked project in this directory and re-resolve it (the recorded stack is extended, never replaced) |
stack refresh |
Re-resolve a tracked project against current profiles/bundles |
stack status [NAMES]... |
Shared-env build state: drift, lock freshness, existence |
stack diff SOURCE SOURCE |
Compare two environments — interpreter, micromamba packages, channels, and pins — here or across machines (--json, --exit-code) |
stack list env|profile|bundle |
Tables of what exists (--tag filters, --json for scripts) |
stack show env|profile|bundle [NAME] |
Details for one item (NAME defaults to main for envs) |
stack show project |
The tracked project in this directory: tokens, applied packages, pending state |
stack resolve [--full] TOKENS... |
Classify tokens, or expand them to a flat package list |
stack doctor [--fix] |
Detect problems; --fix applies the safe repairs |
stack completion bash|zsh|fish |
Print the shell-completion script |
stack config init |
Create missing config directories (bare primitive) |
stack config portable |
Write the managed .gitignore block so the config root can be committed |
stack config remote list |
Per-host settings for stack sync remote, with defaults filled in (--json for the stored values) |
stack config remote set HOST |
Set a host's --stack command, --root, or both in remotes.yaml |
stack config remote remove HOST [stack|root]... |
Remove a host's entry, or only the named fields |
Editing configuration
stack edit opens a config file in your editor and checks it the moment the
editor exits. If the file does not validate, the error is shown and the editor
is offered again, with your changes left in place. Nothing is reverted: if you
decline the re-offer, the file stays exactly as you left it and the command
exits non-zero. When stdin is not a terminal there is no re-offer at all — the
error is printed and the command exits 1. Quitting the editor with a non-zero
status (:cq in vi) aborts without validating at all.
stack edit profile ds # profiles/ds.yaml
stack edit bundle standard # bundles/standard.yaml
stack edit env # envs/main/stack.txt
stack edit env chem --file python # envs/chem/python.txt
stack edit project # ./pyproject.toml
stack edit remotes # remotes.yaml
On success the command names the file it validated and how to apply the change:
stack upgrade NAME for an environment, stack refresh for a tracked project,
for a profile or bundle a reminder that it takes effect on the next upgrade or
refresh, and for remotes.yaml a reminder that the next stack sync remote
uses it. A remotes.yaml that does not exist when the editor exits (you quit
without saving, or deleted it) is reported as such and the command exits 0: no
file means no remotes.
--file selects which environment source to open and accepts stack
(default), python, micromamba, channels, and local
(requirements.local.in). It applies to env only. An optional source that
does not exist yet is opened anyway, so this is how you add a
channels.txt. edit never creates a profile, bundle, or environment — use
stack create for that.
Symlinked config files work, so a profile can live in a dotfiles repo and be linked into the config root. When the file you name is itself a symlink, the success line reports the real file it resolves to rather than the link, so you can see where the write landed. A link pointing at nothing is refused instead of followed.
The editor is chosen from the first of these that is set:
--editor$UV_STACK_EDITOR<config-root>/editor.txt$VISUAL$EDITOR
The value is a command line, so code -w and emacsclient -nw both work; a
value that is spelled as a path — absolute, ~-prefixed, or containing a / —
and names an existing file is used as a single argument, so a path containing
spaces needs no quoting, and a leading ~ is expanded to your home directory.
The edited file is appended as the final argument. An empty
environment variable or editor.txt is skipped rather than treated as a
choice, so an exported-but-empty $VISUAL does not shadow $EDITOR. If none
of the five is set, stack edit says so and stops — it will not drop you into
an editor you did not choose.
editor.txt uses the same line grammar as the other config-root text files:
the first non-blank line wins, and everything from a # onward is a comment.
An editor command containing # would therefore be truncated, so set one of
the environment variables instead if you need one.
echo 'code -w' > ~/.config/python-envs/editor.txt
Use your editor's blocking flag. stack edit validates the file at the
moment the editor process exits. A GUI editor that hands the file to an
already-running instance and returns immediately — code without -w,
subl without -w, gvim without -f — exits before you have typed
anything, so validation runs against the file as it was and reports success
prematurely. This cannot be fixed from uv-stack's side; the fix is the flag:
stack edit profile ds --editor 'code -w'
Deleting
stack delete is the inverse of stack create. Every form asks before it acts;
-y answers yes, and a declined or unanswered prompt exits 1 with nothing
touched.
stack delete profile ds # profiles/ds.yaml
stack delete bundle standard # bundles/standard.yaml
stack delete env chem # the micromamba env, then envs/chem/
stack delete project # withdraw from ./pyproject.toml
A profile or bundle is refused while an environment's stack.txt or another
bundle still refers to it, and the refusal lists each place. The reason is the
same one stack import enforces: a bare token that named a profile would
silently start meaning the pip package of that name on the next upgrade, and a
qualified one (profile:ds, @standard) would stop resolving. Edit the
references out first, or pass --force to delete anyway; a forced delete
prints one warning per reference it leaves behind. A source the check cannot
read counts as a possible reference and is refused the same way.
stack delete env removes only an environment uv-stack manages — one with a
stack.txt under the config root. The micromamba environment goes first, so if
micromamba remove fails the sources are still there and the command can be
run again. If no micromamba environment is built, that step is skipped, and
the prompt says so.
stack delete project does not delete your project. It runs uv remove for
the packages uv-stack applied — the table's applied list, the same ownership
rule stack refresh uses — and drops the [tool.uv-stack] table, leaving a
plain uv project with only the dependencies you added yourself.
pyproject.toml, uv.lock, and .venv/ stay. The prompt lists what will be
removed. A final plain uv sync prunes the removed packages from .venv/
(--no-sync skips it); it is not pinned to the recorded interpreter, since
that choice is no longer uv-stack's once the table is gone.
Upgrading
stack upgrade main # one environment
stack upgrade main scratch # several
stack upgrade # every environment (lists them, asks first)
stack upgrade --all -y # every environment, no prompt
stack upgrade --no-upgrade main # re-lock without floating pins upward
stack upgrade --upgrade-package numpy main # upgrade only numpy
stack upgrade --dry-run main # print the command plan
By default upgrade recompiles the lock with --upgrade (everything floats
to the newest allowed versions), then syncs the environment to match the lock
exactly — packages you removed from a profile are uninstalled. A batch keeps
going past a failing environment and ends with a ✓/✗ summary
(--stop-on-error aborts at the first failure).
Under --stop-on-error the summary gains a third marker: – for an
environment the batch never reached. Those appear only in that mode — without
it every requested environment is attempted, so nothing is ever skipped.
To bring up a whole config root instead of named environments — creating the
missing ones, without a prompt — use stack sync:
stack sync # every environment the root declares
stack sync env main scratch # only these
stack sync --upgrade # force new pins (the default preserves them)
To add tokens to a tracked project and bring it up to date in one step, use
stack sync project from the project root:
stack sync project @qsar # stack ["standard"] becomes ["standard", "@qsar"]
stack sync project @qsar --dry-run # show the new stack and the delta first
It adds the tokens to the stack the project already records; it never
replaces that stack, so an existing token cannot be lost by forgetting to
repeat it. A token already recorded makes the run a plain re-resolve. The
tokens are ordinary stack tokens, and --python, --strict, --no-sync,
and --dry-run behave as they do for stack refresh, which remains the way
to re-resolve the recorded tokens without adding any.
Changed behavior.
--no-upgradeand--upgrade-packagenow genuinely preserve the pins the existing lock already holds. Previously both compiled into an empty file, so uv had no prior pins to keep and re-resolved everything. The first run after this change may therefore move pins that should have stayed put under the old behavior.
stack upgrade refuses a non-recreate upgrade when the running interpreter does
not match python.txt — use stack create env NAME --recreate to resolve
the mismatch first.
Changing an environment's Python version
To upgrade or downgrade an existing environment's interpreter:
stack create env NAME --python 3.14 --recreate
--recreate requires python.txt to hold a plain dotted version such as 3.14;
a conda match spec such as 3.12.* is refused.
The command compiles the candidate lock before destroying the environment, so an
unsatisfiable resolve leaves the old environment intact. micromamba.txt,
channels.txt, and python.txt are applied at environment creation and
--recreate only; stack upgrade re-syncs the pip layer without rebuilding
the conda layer.
Inspecting
$ stack resolve standard ds numpy
bundle:standard
profile:ds
package:numpy
$ stack resolve --full standard
numpy
pandas
rdkit
rich
$ stack list profile --tag data # only profiles tagged 'data'
$ stack show env main # python, tokens, channels, resolved packages
Comparing environments
stack diff dev prod # two environments here
stack diff dev ../laptop/envs/dev # this machine vs another, all layers
stack diff dev ../from-laptop.lock.txt # pins only, and it says so
stack diff dev prod --json
stack diff dev prod --exit-code # exit 1 when they differ
Each source is an environment in this config root, the path to an
envs/<name>/ directory from another machine, or a compiled
requirements.lock.txt. A directory is read where it sits, so keep it outside
this root's envs/, where it would become an environment of its own. An
environment or such a directory carries four layers — the interpreter from python.txt, the
micromamba packages, the effective channel order, and the compiled pins — and
diff compares all four. A bare lock file carries pins only, so against one
the other three are reported as not compared rather than as matching.
The interpreter, micromamba packages, and channels are declarations, not what
is installed: two machines that both declare 3.12 can run different patch
releases. So a match on those layers means the two environments are
configured the same way; only the pins, which are resolved, show that the
two resolved to the same Python distributions. diff reads what was
compiled, not what is installed.
The verdict is one of three values. identical means every layer matched;
identical-where-comparable means the pins matched but a bare lock hid the
other layers; different means at least one layer differs. diff exits 0 in
all three cases — a difference is a finding, not a failure — and exits 1 for
different only under --exit-code. A source that cannot be read exits 1
either way.
Checking your setup
stack doctor
doctor never changes anything — it reports problems with a suggested fix:
line for each. It covers the shape of the tree (missing directories, legacy
file formats, environments in the wrong place, a name published as both a
profile and a bundle) and its portability: declared
variables with no value on this machine, references that are undeclared,
malformed, or in an entry that may not hold one, editable checkouts that are
absent (given as a path or a local file: URL; a relative path is looked up
from the directory stack doctor runs in, where uv resolves it), a project-python.txt value that will not travel,
and a missing or stale managed .gitignore block in a root that sits inside a
git repository.
Watching for drift
stack status
Shows one row per shared environment: whether the micromamba env exists, whether a
lock is present, and whether your sources changed since the last build
(sources changed means "run stack upgrade"). When the running interpreter
does not match python.txt, the state becomes python changed and the Python
column shows both the configured and actual versions (e.g. 3.12 (env 3.14.0)).
--json makes every inspection command (list, show, resolve, status,
diff, doctor) script-friendly.
Typo protection
A bare token that matches no profile or bundle becomes a literal PyPI
package. If it looks like a near-miss of one of your names, stack warns
(did you mean 'standard'?). Add --strict to upgrade, create env,
create project, create bundle, or resolve to turn any unqualified
fallthrough into an error, and use the pkg: prefix to say "yes, really a
package."
Shell completion
# zsh — add to ~/.zshrc:
eval "$(stack completion zsh)"
Tab-completes commands, options, and your environment/profile/bundle names.
Configuration reference
Config root
All state lives under one directory, resolved in this order:
--root PATH(global flag)$UV_STACK_ROOT$UV_ENV_ROOT(the historical spelling, still accepted)~/.config/python-envs(default)
~/.config/python-envs/
├── variables.txt # optional: declares the names profiles may reference
├── variables.local.txt # optional: this machine's values — do not commit
├── .gitignore # written by `stack config portable`
├── project-python.txt # optional: default --python for `create project`
├── editor.txt # optional: editor command for `stack edit`
├── profiles/
│ └── <name>.yaml
├── bundles/
│ └── <name>.yaml
├── .locks/ # internal lock files (safe to leave alone)
└── envs/
└── <name>/
├── stack.txt # required (defines the env)
├── python.txt # optional
├── micromamba.txt # optional
├── channels.txt # optional
├── requirements.local.in # optional
├── requirements.in # generated
├── environment.yml # generated
└── requirements.lock.txt # generated
The root, or a directory above it, can be a symbolic link, for example to a
checkout kept elsewhere. If the link's target is missing, commands that write
refuse rather than create an empty tree in its place. Restore the target (clone
or mount it), or run stack config init to create it; stack init offers to
create it as well.
Shell environment variables
| Variable | Purpose |
|---|---|
UV_STACK_ROOT |
Config root (overridden by --root) |
UV_ENV_ROOT |
The historical spelling of UV_STACK_ROOT; used only when UV_STACK_ROOT is unset or empty |
UV_STACK_PROJECT_PYTHON |
Default interpreter spec for stack create project |
UV_STACK_EDITOR |
Editor command for stack edit; beats editor.txt, $VISUAL, and $EDITOR |
VISUAL |
Standard fallback editor for stack edit, below editor.txt |
EDITOR |
Standard fallback editor for stack edit, consulted last |
MAMBA_EXE |
Path to the micromamba binary; set by micromamba shell init and preferred over PATH lookup |
Portable config roots
A config root is a directory of text files, so it can live in git and be
cloned onto another machine. Two things make that work: a managed .gitignore
block that keeps generated files out of the repository, and variables that let
one profile describe paths that differ per machine.
What travels
| Travels | Does not travel |
|---|---|
profiles/, bundles/, envs/*/stack.txt, python.txt, micromamba.txt, channels.txt |
requirements.in, environment.yml, requirements.lock.txt (all generated) |
variables.txt — the declared names |
variables.local.txt — this machine's values |
project-python.txt, when it holds a portable selector |
envs/*/requirements.local.in — one machine's extra packages |
editor.txt — this machine's editor |
|
.locks/, .DS_Store |
Compiled locks deliberately do not travel. A lock is resolved for one
platform and one interpreter, and uv-stack compiles per machine rather than
committing a lock that is only correct where it was produced. The consequence
is worth stating plainly: two machines converged from the same sources will have
compatible environments, not identical pins. stack status is a single-machine
command; stack diff reports the difference between two machines once one of
them has the other's environment directory to read (see below).
Bring-up on a new machine
git clone <url> ~/.config/python-envs
stack doctor # names the variables with no value
$EDITOR ~/.config/python-envs/variables.local.txt
stack sync # builds every environment
Step two is what makes this work on a machine you have never set up: doctor reads the declared list and reports what is missing before anything tries to build.
To check the result against a machine that is already set up, bring that
machine's envs/<name>/ directory over by any means, including its
requirements.lock.txt — the clone deliberately left the lock behind. Put it
anywhere outside this root's envs/, where it would become an environment of
its own, and compare:
stack diff main ../other/envs/main
Making a root committable
stack config portable
This writes a managed block into <root>/.gitignore, delimited by markers:
# BEGIN uv-stack — managed block, do not edit by hand.
...
# END uv-stack
Edit the file freely outside the markers — your lines are preserved. Anything you write inside them is replaced the next time the command runs.
A .gitignore that is a symlink is refused rather than written or replaced:
git commits the link itself, so the rules it points at would never reach the
machine that cloned the root.
A .gitignore that carries a second hard link is refused too. A hard link
can be created without permission to read what it points at, so reading through
one could copy a file you can read — and whoever created the link cannot — into
a file you are about to commit. The same refusal catches an innocent case: a
hardlink snapshot backup such as cp -al or rsnapshot leaves every file it
covers with a second link, so a .gitignore under one must be de-linked (copy
it to a new path and move it back) before the command will refresh it.
An empty profiles/, bundles/, or envs/ also gets a .gitkeep. Git tracks
files and not directories, so without one the directory would simply be absent
from the clone, and the bring-up above would report it as a missing directory.
Only a directory that already exists and holds nothing gets a placeholder —
creating a directory is stack config init's job, not this command's.
The command prints the git commands to follow, and for a root that is already
a repository those begin with an untracking step: files that are now ignored
stay tracked until git rm --cached removes them from the index. That applies
to any root that is a repository, including one whose .gitignore this command
is creating for the first time. A root that is a subdirectory of a repository
— python-envs/ inside a dotfiles repo — counts as one: every command is
addressed to the config root with -C, and git finds the enclosing repository
by walking up from there.
That sequence stages only the .gitignore it just wrote, so nothing else
sitting in the root is committed on your behalf; a root whose own files have
never been committed needs a git add of your own. Its commit records
everything already staged in the repository, this root or not, which is worth
a git status before you paste it.
uv-stack never invokes git. It writes the ignore file and prints the commands; you run them.
Variables
variables.txt declares the names, one per line, comments allowed:
# Root of the development checkouts this root's editables point into.
DEV
WHEELS
variables.local.txt supplies this machine's values and is not committed:
DEV=~/src
WHEELS=/opt/wheels
An exported environment variable of the same name wins over the file, which is what makes a CI runner work without writing a file at all. Only declared names are read from the environment.
Which project-python.txt values travel
| Form | Example | Travels |
|---|---|---|
| Python version | 3.12 |
yes |
| uv implementation form | cpython@3.12, pypy-3.10 |
yes |
| An environment this root declares | main |
yes |
| An environment it does not declare | scratch |
no |
| An interpreter path | /opt/envs/x/bin/python |
no |
A path is the one form that reaches uv unchanged and still fails to travel,
which is why stack doctor warns about it separately. The same check applies
to --python values recorded in a project's [tool.uv-stack] table: uv-stack
records what you gave it, and says so when the value will not travel.
The limit on tracked projects
uv-stack keeps its own project ledger portable — [tool.uv-stack] records
requirement strings unexpanded, so a committed pyproject.toml never holds
another machine's home directory.
It cannot keep uv's half. uv add writes the resolved absolute path of a
local source into the project's own [tool.uv.sources] table, and uv
exposes no flag to keep that symbolic. So a project whose stack resolves to
ordinary named requirements is portable, and a project whose stack pulls in any
local source — an editable or a plain path — is machine-bound in that table.
Closing that gap would mean rewriting uv's output behind its back.
Moving environments between machines
stack export
stack export [ITEMS...] writes the named environments, profiles, and bundles
as a single JSON document. Each ITEM is env:NAME, profile:NAME,
bundle:NAME, @NAME, or a bare NAME that names exactly one kind. With no
ITEMS, the whole config root is exported.
Everything the named items reach is included: all referenced profiles and
bundles, plus each environment's lock file as a seed. The document goes to
standard output by default; pass -o FILE to write it to a file instead.
When standard output is the destination, only the document is written there. Warnings (such as the absolute-path notice below) go to standard error so they do not corrupt the JSON stream.
Absolute paths are not portable. If a profile or environment includes an
editable install (-e /src/foo), a local path (/wheels/bar.whl), or a
file:// URL, the export warns once per file: those paths must exist at the
same locations on the target machine for its environments to build. The import
writes the definitions either way. Its pre-flight refuses a missing editable
checkout, given as a path or a local file: URL, before anything is written
(unless --no-build), and uv reports any other missing path when the build
runs. Use ${NAME} variable references to
make paths portable across machines.
References are matched by spelling. On a filesystem that ignores letter
case, such as a default macOS install, a stack file's foo reaches
profiles/Foo.yaml, and the export ships it as profiles/foo.yaml, the key
the reference names. An export that would ship that file under both spellings
is refused and names both; spell the reference as the file is named.
Pull from another machine. You can export and import in one command by piping over ssh:
ssh HOST stack export ITEMS | stack import -
stack import
stack import FILE|- installs the environments, profiles, and bundles from a
document written by stack export. Pass the document's file path, or - to
read from standard input.
Each file in the document is installed as new, left alone if identical
to this machine's copy, replaced when --overwrite is given and the
content differs, or removed (for environment files only) when the source
environment no longer has it and --overwrite is given.
Conflicts refuse the import. When a file differs from this machine's copy
and --overwrite was not given, the import stops before writing anything,
prints a unified diff showing the difference, and lists the environments that
use the file. Fix the conflict manually or re-run with --overwrite.
Meaning changes are always refused. An unqualified token in a stack file or
bundle means whichever profile, bundle, or package it resolves to, and the
import refuses to change that meaning in either direction, even with
--overwrite:
- A token in a shipped stack file or bundle must mean here what it meant on the
source machine. A shipped environment whose stack names
utilsas a package is refused when this machine has autilsprofile. - A token in one of this machine's stack files or bundles that the import does
not replace must mean the same after the import. Importing
profile:utilsis refused when an existing environment's stack namesutilsas a package.
Fix it by qualifying the token so it keeps its meaning (pkg:utils for the
package, profile:utils, or @utils for a bundle); the refusal's hint names
the form to write. Qualify this machine's own file in place; for a shipped
file, qualify it on the source machine and export again.
Options:
--overwrite: Replace files that differ and remove environment files that the source environment no longer has. Without this flag, differing files stop the import with a conflict error.--dry-run: Report what would change without writing anything. Unless--no-buildis given, the pre-flight below still runs, which can probe micromamba, and each build the import would run is listed asbuild: NAME (ACTION), where ACTION iscreate,sync, orrecreate.--strict: Refuse unqualified names in stack files and bundles that fall through to package literals rather than referencing a profile or bundle.--no-build: Install the definitions without building environments.--recreate: Wipe and rebuild each imported environment from the shipped pins. Only works with plain dotted Python versions (e.g.,3.12); refused for ranges or constraints. Cannot be combined with--no-build.
Variables. Variable names the imported definitions reference but this
machine does not declare are appended to variables.txt, names only, and
listed on a declare in variables.txt: line. The document does not carry
their values: set each in variables.local.txt as NAME=value, or export it
in the environment.
Paths. Writing the definitions checks none of the paths they name; the
build needs them. A relative editable path resolves against the directory
stack runs in, which is where uv resolves it, not against the config root.
Under stack sync remote that directory is the remote user's home.
Pre-flight refusals. Unless --no-build is given, the import checks each
imported environment before writing anything, and refuses when it references a
variable with no value here, installs an editable checkout (a path or a local
file: URL) that is missing here, already runs a Python outside the plain version its imported
python.txt names (without --recreate; 3.12 when the document ships no
python.txt), or has a python.txt that is not a plain version (with
--recreate). Each refusal's hint also offers --no-build. uv reports any
other missing path during the build.
Build phase. After writing the definitions, each imported environment is
built: created if absent, recreated if --recreate is given, or synced
otherwise. The environment's shipped lock seeds its compile, so those pins act
as preferences and uv re-resolves them for this machine. For each environment
that shipped a lock and built, uv-stack then prints a pin report:
main: 41 pins kept, 1 changed, 0 dropped, 1 added
changed: numpy 1.26.4 -> 2.0.2
added: typing-extensions 4.12.2
kept counts the shipped lock's version and URL pins that the new lock holds
unchanged. changed, dropped, and added count requirements whose pin
differs, that only the shipped lock has, or that only the new lock has. An
editable or unnamed path requirement in both locks is in none of the four, so
kept, changed, and dropped need not add up to the number of requirements in the
shipped lock. An environment exported without a lock prints no report.
Environments not rebuilt. Environments on this machine that the document does not ship are never rebuilt. When the import replaces a profile or bundle they use, or ships a new profile that captures a bare token in an unchanged bundle they reach, a line names them:
Not rebuilt, but using changed definitions: work. Rebuild them with 'stack sync env work'.
The line prints whenever such environments exist: after a successful or failed
build, with --no-build, and with --dry-run. It can print even without
--overwrite, since adding a new profile replaces nothing.
Build failures. If a build fails, the definitions stay written, the
remaining environments still build, and the import exits 1 after naming only
the failed ones: Re-run the failed build(s) once the cause is fixed: stack sync env NAME. That command syncs; it does not recreate. After a failed
--recreate build, re-run the same import with --recreate instead, which is
safe: the files already written are reported as identical, and each imported
environment is recreated again, including those that built.
stack sync remote
stack sync remote [ITEMS...] DEST exports the named items (or the whole root
if no ITEMS are given) and imports them on a remote machine, running:
ssh DEST <stack> [--root ROOT] import - [FLAGS]
The document is sent to ssh's stdin; ssh's stdout and stderr stream to the local terminal as they arrive; the local exit status is the remote's. Everything the import does—pre-flight checks, building environments, pin reports—happens on the remote and prints locally.
Printed commands are for the remote. The stack sync env ... commands
the import prints refer to the remote machine: the re-run command for
environments whose build failed, and the "Not rebuilt, but using changed
definitions" line naming the remote's other environments that use a changed
profile or bundle. The second line can appear even when every build
succeeds, and with --no-build or --dry-run. Run these commands on the
remote (for example over ssh DEST), not locally. The printed commands use
bare stack and the remote's default root; when the stack command or root
was customized, run them with the same command and root the import used—for
example, ssh gpu-box 'PATH=$HOME/.local/bin:$PATH stack --root /data/python-envs sync env NAME'
for the remotes.yaml entry below.
Customizing the remote command. --remote-stack CMD sets the stack command
to run on the remote. --remote-root PATH sets the remote's config root. Both
can be specified per-host in remotes.yaml (see below), with the command-line
flags taking precedence.
The remote's PATH. ssh runs the command in a non-interactive shell, whose
PATH often lacks ~/.local/bin (where uv tool install and the micromamba
installer put their commands) or Homebrew's /opt/homebrew/bin, because the
startup files that add them are skipped or return early for such shells.
Finding stack is not enough: the import runs uv from PATH, and micromamba
from $MAMBA_EXE when that is set, otherwise from PATH. The stack command is
inserted into the remote command line as written, so a PATH= prefix in it
reaches stack and everything stack runs:
gpu-box:
stack: PATH=$HOME/.local/bin:$PATH stack
List every directory that holds stack, uv, or micromamba on that host, for
example PATH=$HOME/.local/bin:/opt/homebrew/bin:$PATH stack, or set
MAMBA_EXE=/path/to/micromamba in the same way. A prefix needs a remote login
shell that accepts NAME=value command, as sh, bash, and zsh do.
Exit status hints:
- Exit 255 is ssh's own failure: the connection failed, or it dropped during the import. The hint says that re-running the same command is safe: files already written are reported as identical and the rest are completed. If the dropped import is still running on the remote, a re-run waits a few seconds for its lock and then refuses.
- Exit 127 means the remote shell did not find the stack command. The hint
suggests
uv tool install uv-stackon the remote and, for an installed uv-stack that the ssh shell cannot find, aremotes.yamlentry for DEST whosestackis the PATH recipe above. - Exit 2 with "No such command 'import'" in the stderr means the remote's
uv-stack predates
import; the hint says to runuv tool upgrade uv-stackthere.
Options forwarded to the remote:
--overwrite: Replace files that differ and remove environment files the source no longer has.--no-build: Install definitions without building environments.--recreate: Wipe and rebuild each environment from the shipped pins.--dry-run: Report what the remote would change without writing anything. Unless--no-buildis given, this can still probe the remote's micromamba installation.--strict: Refuse unqualified names that fall through to package literals.
Using ssh prompts. Password and passphrase prompts still work—ssh reads them from the terminal, not stdin.
Without a remote uv-stack install. For a remote that has uv but not
uv-stack, run it through uvx. The stack it starts runs uv too, so give the
command the same PATH prefix:
gpu-box:
stack: PATH=$HOME/.local/bin:$PATH uvx --from uv-stack stack
Pull from another machine. To pull items from a remote into the local machine, reverse the direction by piping the remote export into a local import:
ssh HOST stack export ITEMS | stack import -
remotes.yaml
A root-level file mapping host names to optional settings:
gpu-box:
stack: PATH=$HOME/.local/bin:$PATH stack
root: /data/python-envs
Each entry allows only stack (the command to run on the remote) and root
(the remote's config root), both optional non-empty strings. Command-line flags
(--remote-stack, --remote-root) take precedence over the file's values.
- DEST must match the key exactly as typed.
stack sync remote gpu-boxuses the entry above;stack sync remote user@gpu-boxdoes not. rootis passed literally. It reaches the remote quoted, and only a leading~is expanded there.$HOME/envsis not expanded: it names a directory literally called$HOME, relative to the remote home.stack, by contrast, is inserted unquoted, so the remote shell expands~and$HOMEin it.- Quote host names that YAML reads as something else. A key such as
yes,no,null, or1is a boolean, null, or number to YAML, andstack sync remoterefuses the file until it is quoted ("yes":).stack config remote setquotes such names itself. - List each host once. YAML would silently keep only the last of two
entries for one host, so every command that reads the file refuses it and
names both lines.
"gpu-box"andgpu-boxare the same host. - Write each setting once per entry. YAML uses only the last of two
root:lines in one entry, so commands that read the file warn and name both lines.stack config remote setandremovekeep the used one when they rewrite the file.
Two commands change the file. stack edit remotes opens it in your editor and
validates it on exit (see Editing configuration).
stack config remote set HOST --stack CMD --root PATH merges the given fields
into HOST's entry, creating it if needed. stack config remote remove HOST
deletes the entry, and remove HOST stack or remove HOST root clears only
that field, leaving HOST: {} when none remain. stack config remote list
prints each entry with an unset field shown as its default. set and remove
rewrite the whole file, normalizing its quoting and indentation, so they refuse
a file that holds comments, which the rewrite would drop; edit a commented file
with stack edit remotes instead.
The file is portable across machines—the remote's paths are properties of the
remote, not of the machine that pushes. stack doctor reports an unreadable or
invalid remotes.yaml file.
Tips and gotchas
- Edit sources, not generated files.
requirements.in,environment.yml, andrequirements.lock.txtare overwritten on every upgrade. For env-specific additions userequirements.local.in; for anything reusable, a profile. --dry-runis almost dry. Forstack upgrade,stack syncandstack sync env,--dry-runruns no uv commands and never touches the lock or the environment, but it re-rendersrequirements.inandenvironment.ymland can still ask micromamba for the environment's Python version.stack refresh --dry-runandstack sync project --dry-runchange nothing.stack import --dry-runandstack sync remote --dry-runwrite nothing, but can still run micromamba probes (on the remote, over ssh, forstack sync remote).- Import's letter-case checks assume matching filesystems.
stack importstages its view of the result in the system temporary directory, and its checks of whether two names that differ only in letter case or Unicode normalization are one file see that directory's filesystem. They are exact only when it folds names the same way as the config root's filesystem, as a default macOS install or a plain Linux system does. On a target that folds case, a shipped case variant of an existing definition replaces it, and the environments that used it are reported under the shipped name. - Your lock is safe from failed compiles. The lock is compiled to a
temporary file and atomically swapped in, so a failed
uv pip compilenever corrupts an existingrequirements.lock.txt. If the later sync step fails (e.g. a network blip), just runstack upgrade NAMEagain. - Activation is micromamba's job.
stackbuilds shared environments; you enter them withmicromamba activate <name>or run one-offs withmicromamba run -n <name> <command>. - Name collisions. A bare token prefers a profile over a bundle over a
literal package. When a package name collides with one of your profile or
bundle names, force the package with
pkg:<name>. A name that is both a profile and a bundle still works — bare reaches the profile,@<name>the bundle — butstack doctorwarns about it, because otherwise you only hear about it on the runs that happen to reference the name. doctor --fixis conservative. It only applies safe repairs (creating missing directories, renaming legacy files, converting.in/.bundlefiles to YAML with a.bakbackup). Anything destructive stays a printed suggestion.- Migrating old configs. If you previously used
.inprofiles,.bundlefiles, or aprofiles.txt,stack doctorwill point at each leftover and tell you what to rename or convert. - The
[tool.uv-stack]table is tool-owned.stack refreshrewrites it wholesale; comments inside that one table are not preserved. uv-stack's own writes leave every other byte ofpyproject.tomlalone — but refresh also runsuv remove/uv addunder the hood, which edit[project.dependencies]just as they would if you ran them yourself. An interrupted refresh or tracked create may leave apendingkey in the table; the next successfulstack refresh(or re-running the tracked create with--force) cleans it up. - A value in
variables.local.txtcannot contain#. The file grammar strips comments, so everything from the first#is discarded. An exported environment variable has no such restriction. - A value holding
${...}is refused, not passed through. Substitution is single-pass, so a${OTHER}inside a value is never itself expanded — and an entry that still holds${afterwards is rejected rather than written out, because uv expands environment variables in a requirements file and would resolve the leftover reference behind uv-stack's back.DEV=/a/${OTHER}/bturns every entry that referencesDEVinto an error:stack doctorreports it andstack syncrefuses. - No value may contain whitespace. That rules out a path containing a space, and it is what keeps expansion from splitting an already-validated entry into a new token. Use a symlink or move the checkout.
Metadata
Release files for uv-stack 0.8.0
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| uv_stack-0.8.0-py3-none-any.whl | Python 3 | none | any | Details |
Release files / uv_stack-0.8.0-py3-none-any.whl
| Download URL | uv_stack-0.8.0-py3-none-any.whl |
|---|---|
| Size | 254.3 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
27ff176760b5444c33ef3a26eb7ed51fd872e488c6187dffa93322840c64bef9
|
|
BLAKE2b-256 checksum How to use checksums |
0aca3999debc26220be20b1efb428e668ee9837091107559a193b155e9cc086f
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
No |
| Uploaded via |
twine/7.0.0 CPython/3.14.7
|