Metadata-Version: 2.5
Name: uv-stack
Version: 0.5.0
Summary: Formalized uv + micromamba Python environment management
Requires-Python: >=3.12
Requires-Dist: pydantic>=2
Requires-Dist: pyyaml>=6
Requires-Dist: rich-click>=1.7
Description-Content-Type: text/markdown

# uv-stack

Reusable package sets for shared Python environments and uv projects — powered by
[micromamba](https://mamba.readthedocs.io/en/latest/user_guide/micromamba.html)
and [uv](https://docs.astral.sh/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 |

```mermaid
flowchart LR
    subgraph sources["You define these"]
        P["profiles/*.yaml"]
        B["bundles/*.yaml"]
        S["envs/NAME/stack.txt"]
        T["pyproject.toml<br>tool.uv-stack"]
    end

    R{{"stack create env<br>stack upgrade"}}
    EY["environment.yml"]
    RI["requirements.in"]
    LK["requirements.lock.txt"]
    ENV[("shared env: NAME")]

    R2{{"stack create project<br>stack refresh"}}
    D["pyproject.toml<br>dependencies"]
    V[("project .venv")]

    P --> R
    B --> R
    S --> R
    R --> EY
    R --> RI
    EY -- "micromamba create" --> ENV
    RI -- "uv pip compile" --> LK
    LK -- "uv pip sync + check" --> ENV

    P --> R2
    B --> R2
    T --> R2
    R2 -- "records what it applied" --> T
    R2 -- "uv add --no-sync" --> D
    D -- "uv sync" --> V
```

`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 with `uv` (fast), and
  verified with `uv pip check`.

---

- [Installation](#installation)
- [Quickstart](#quickstart)
- [What you define](#what-you-define)
- [Where it goes](#where-it-goes)
- [Everyday commands](#everyday-commands)
- [Configuration reference](#configuration-reference)
- [Tips and gotchas](#tips-and-gotchas)

## Installation

Prerequisites (both must be installed and reachable):

- [`uv`](https://docs.astral.sh/uv/getting-started/installation/)
- [`micromamba`](https://mamba.readthedocs.io/en/latest/installation/micromamba-installation.html) —
  run `micromamba shell init` after installing so `$MAMBA_EXE` is set

Then install the CLI as a standalone tool (requires Python 3.12+):

```bash
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.

```bash
# 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 always the same two steps: edit a
source file (a profile, a bundle, or stack.txt), then run stack upgrade main.
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:

```yaml
description: Core data-science stack   # optional
tags: [data, core]                     # optional, used by `stack list --tag`
includes:                              # required: literal pip requirements
  - numpy>=2
  - pandas
```

### 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*:

```yaml
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` |
| 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:

```bash
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 like `cpython@3.12` is passed straight
  to `uv`.
- **Anything else is treated as a micromamba environment name** — the project
  uses that environment's interpreter. `stack create project ds --python main`
  builds the project on `main`'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 called `3.12.x`. `stack edit
  project` warns about one in the tracking table when the editor exits. (Note
  that `3.12.*` *is* legal in an environment's `python.txt`, which conda reads,
  not uv.)
- With no `--python`, the default comes from `$UV_STACK_PROJECT_PYTHON`, then
  `<config-root>/project-python.txt`, then `3.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:

```bash
stack refresh              # apply profile/bundle changes to this project
stack refresh --dry-run    # see the add/remove delta first
```

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 edit KIND [NAME]` | Open a profile, bundle, env source, or project file in your editor and validate it when the editor exits |
| `stack upgrade [NAMES]...` | Re-render, re-lock, and sync shared environments |
| `stack refresh` | Re-resolve a tracked project against current profiles/bundles |
| `stack status [NAMES]...` | Shared-env build state: drift, lock freshness, existence |
| `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) |

### 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.

```bash
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
```

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,
and for a profile or bundle a reminder that it takes effect on the next upgrade
or refresh.

`--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:

1. `--editor`
2. `$UV_STACK_EDITOR`
3. `<config-root>/editor.txt`
4. `$VISUAL`
5. `$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.

```bash
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:

```bash
stack edit profile ds --editor 'code -w'
```

### Upgrading

```bash
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).

`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:

```bash
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

```bash
$ 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
```

### Checking your setup

```bash
stack doctor
```

`doctor` never changes anything — it reports problems (missing directories,
legacy file formats, environments in the wrong place) with a suggested `fix:`
line for each.

### Watching for drift

```bash
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`,
`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

```bash
# 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:

1. `--root PATH` (global flag)
2. `$UV_STACK_ROOT`
3. `$UV_ENV_ROOT` (the historical spelling, still accepted)
4. `~/.config/python-envs` (default)

```text
~/.config/python-envs/
├── 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
```

### 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 |

## Tips and gotchas

- **Edit sources, not generated files.** `requirements.in`,
  `environment.yml`, and `requirements.lock.txt` are overwritten on every
  upgrade. For env-specific additions use `requirements.local.in`; for
  anything reusable, a profile.
- **`--dry-run` is almost dry.** It runs no commands and never touches the
  lock or the environment, but it does re-render `requirements.in` and
  `environment.yml`.
- **Your lock is safe from failed compiles.** The lock is compiled to a
  temporary file and atomically swapped in, so a failed `uv pip compile`
  never corrupts an existing `requirements.lock.txt`. If the later *sync*
  step fails (e.g. a network blip), just run `stack upgrade NAME` again.
- **Activation is micromamba's job.** `stack` builds shared environments; you enter
  them with `micromamba activate <name>` or run one-offs with
  `micromamba 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>`.
- **`doctor --fix` is conservative.** It only applies safe repairs (creating
  missing directories, renaming legacy files, converting `.in`/`.bundle`
  files to YAML with a `.bak` backup). Anything destructive stays a printed
  suggestion.
- **Migrating old configs.** If you previously used `.in` profiles,
  `.bundle` files, or a `profiles.txt`, `stack doctor` will point at each
  leftover and tell you what to rename or convert.
- **The `[tool.uv-stack]` table is tool-owned.** `stack refresh` rewrites
  it wholesale; comments inside that one table are not preserved. uv-stack's
  own writes leave every other byte of `pyproject.toml` alone — but refresh
  also runs `uv remove`/`uv add` under the hood, which edit
  `[project.dependencies]` just as they would if you ran them yourself.
  An interrupted refresh or tracked create may leave a `pending` key in the table; the next successful `stack refresh` (or re-running the tracked create with `--force`) cleans it up.
