Metadata-Version: 2.5
Name: uv-stack
Version: 0.4.4
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`) |
| `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.
- 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 it first) |
| `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 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) |

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

### 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`"). `--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`
├── 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` |
| `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.
