Developer Guide

How appenv works internally and how to contribute.

Architecture

How appenv’s components fit together and why.

Design Philosophy

appenv is a single-file Python CLI that pins packages to exact versions and exposes their binaries via symlinks, using uv for environment management. The single-file constraint is deliberate: appenv gets copied into project repositories as a self-contained bootstrap script with zero runtime dependencies. Commit it alongside pyproject.toml and uv.lock, and every checkout — local or on a remote deployment target — gets the same tools at the same versions by running ./http or ./batou.

This shapes every architectural decision:

  • Zero system modification: appenv never installs anything outside the project directory. All state lives in .appenv/ — venv, cached uv binary, logs. No system packages, no global bin directories, no PATH modifications. Drop the script, remove .appenv/, and the system is unchanged.

  • Symlink dispatch: When invoked as ./http (a symlink to appenv), it prepares the venv and runs .appenv/venv/bin/http. When invoked as ./appenv, it parses subcommands via argparse. The script detects its own filename (Path(__file__).stem) to choose the mode. Multiple symlinks can coexist to expose different binaries from the same venv.

  • Type hints in stubs only: Implementation lives in appenv.py with minimal typing. Complete type annotations (public and private methods) live in appenv.pyi. Any signature change must update both files. See #type-annotations for the full policy.

  • Conditional tomllib: init uses tomllib (stdlib, Python 3.11+) to parse existing pyproject.toml when a [project] section already exists. The import is guarded with try/except — no third-party dependency. On Python 3.9–3.10, init falls back to refusing to modify an existing [project] section. This is a conscious trade-off: full functionality on modern Python, zero cost on older interpreters.

  • Guard-then-act pattern: ensure_* functions validate preconditions and exit with specific error codes if unsatisfied. The main flow only proceeds after all guards pass.

Command Dispatch

The entry point main() does three things:

  1. Clear PYTHONPATH — prevents host environment contamination in the venv

  2. Select best Python via ensure_best_python() (see #python-version-selection)

  3. Dispatch based on filename:

    • Filename is appenvmeta() (argparse subcommand handling)

    • Filename is anything else → run() (exec the venv binary)

Run Mode

./http arg1 arg2 → appenv prepares the venv, then os.execv() replaces the current process with .appenv/venv/bin/http arg1 arg2. The original Python process is gone — no wrapper, no subprocess overhead.

Meta Mode

./appenv <subcommand> → argparse dispatches to handler methods grouped by category (Project, Venv, Tools, Debug). Subcommands are defined in AppEnv.meta() with func defaults pointing to handler methods.

The init subcommand decomposes into four helpers that all return an InitParams dataclass:

  • _check_existing_project() — guards against invalid existing [project] sections (requires tomllib, exits on unparseable state)

  • _resolve_init_params_noninteractive() — resolves all five init parameters from CLI flags and existing project data when all required flags are present

  • _resolve_init_params_interactive_existing() — runs the interactive wizard when an existing [project] section is present, pre-filling defaults from existing data

  • _resolve_init_params_interactive_new() — runs the interactive wizard for a brand-new project with fresh defaults

After resolving parameters, init creates the pyproject.toml, sets up the command symlink, and generates the lockfile.

Python Version Selection

ensure_best_python() runs before any other setup. It reads requires-python from pyproject.toml, scans PATH for python3.X binaries (newest first), and re-execs with the best match via os.execv(). A guard (APPENV_BEST_PYTHON env var) prevents infinite re-exec loops.

Base directory: APPENV_BASEDIR overrides the project location. By default, appenv uses Path(__file__).parent — meaning appenv must be located next to pyproject.toml. This is intentional: the bootstrap script lives beside the project it manages.

If no compatible Python is found, it lists available versions and exits with EXIT_CODE_DATAERR (65).

Each candidate is probed by running python -c "print(1)" to verify the binary actually works — important on systems like NixOS where symlink targets may have been garbage-collected.

uv Management

Discovery Chain

UvBin discovers the uv binary through a six-step cascade. Each step validates the version before accepting — see UvVersion.minimum() in the API reference for the current minimum:

  1. PATHshutil.which("uv"), validate version

  2. Cached binary.appenv/.uv/bin/uv from a previous nix/pip install

  3. Nix channelnix-build <nixpkgs> -A uv into .appenv/.uv

  4. Nix flakenix build nixpkgs#uv into .appenv/.uv (more expensive, fresher packages)

  5. pip installpip install uv -t .appenv/.uv as last resort

  6. Direct download — download the uv binary tarball from astral-sh/uv GitHub releases via urllib + tarfile (stdlib only), extract to .appenv/.uv/bin/uv. See #platform-detection for supported platforms.

When a PATH uv is valid, any previously cached .appenv/.uv is cleaned up automatically. The cascade handles environments where uv may not be pre-installed (CI, NixOS, minimal containers).

Platform Detection

The direct-download step (6) needs to know the correct release archive for the current platform. _uv_platform_triple() in src/appenv.py maps platform.machine() and sys.platform to the archive naming convention used by astral-sh/uv:

Architecture

Linux (glibc)

Linux (musl/Alpine)

macOS

x86_64

x86_64-unknown-linux-gnu

x86_64-unknown-linux-musl

x86_64-apple-darwin

aarch64

aarch64-unknown-linux-gnu

aarch64-unknown-linux-musl

aarch64-apple-darwin

armv7

armv7-unknown-linux-gnu

armv7-unknown-linux-musl

Linux glibc vs. musl is detected by checking for /etc/alpine-release (Alpine uses musl). Unsupported platforms (unrecognized architecture or OS) cause the direct-download step to be skipped silently.

Version Enforcement

ensure_uv() wraps UvBin construction and exits with EXIT_CODE_UNAVAILABLE (68) if the discovered binary doesn’t meet the minimum version. This guard runs before any venv operations.

Venv Lifecycle

The venv lives at .appenv/venv — a real virtual environment managed by uv. .venv is a symlink to .appenv/venv for IDE and tool compatibility (editors, linters, debuggers that expect .venv by convention).

Creation

_prepare_venv() coordinates the full lifecycle through two helper methods:

  1. Run guards: ensure_pyproject(), ensure_lock_file(), ensure_uv()

  2. Set UV_PROJECT_ENVIRONMENT to .appenv/venv so uv targets the right directory

  3. _check_venv_health() — corruption recovery (missing bin/python on NixOS garbage collection) and stale version recovery (venv Python doesn’t satisfy requires-python). Returns True if the venv was removed and needs recreation.

  4. Create venv with uv venv --python <current_python> if needed — explicitly uses the current Python to prevent uv from downloading its own (which breaks on NixOS)

  5. Sync production dependencies via uv sync --no-dev --frozen

  6. _ensure_venv_symlinks() — creates/updates the .venv symlink and the legacy .appenv/current symlink

Sync Modes

  • Production (prepare, symlink dispatch): uv sync --no-dev --frozen — only production dependencies, lockfile must exist and be unchanged.

  • Run (run): Delegates directly to uv run with appenv’s configured environment. Does not enforce frozen — uv run handles its own sync.

Project Layout Conventions

appenv expects specific files relative to the project root. Paths are conventions, not configuration:

pyproject.toml

Project definition with [project] section and requires-python. Required for all operations.

uv.lock

Dependency lockfile created by ./appenv update-lockfile. Required before prepare.

appenv

The bootstrap script — a copy of src/appenv.py. migrate updates this file when the running appenv version differs from the one on disk. init warns when a version mismatch is detected.

Symlink to appenv. Running ./<command> executes the <command> binary from the installed dependencies. See Command Dispatch for how dispatch works.

.appenv/

Internal state directory (venv, cached uv binary, logs). Managed entirely by appenv.

.venv

Symlink to .appenv/venv. Created for tool compatibility — do not delete manually.

Error Handling Strategy

appenv uses BSD sysexits.h exit codes to communicate specific failure modes. See Exit Codes for the complete reference.

Logging Architecture

Each command gets its own log file at .appenv/logs/<command>.log. Logs use TimedRotatingFileHandler with daily rotation and 7-day retention.

Verbose mode (APPENV_VERBOSE=1) adds a console handler with dimmed caller info (funcName:lineno) prepended to each message. Useful during development without cluttering normal output.

The logger is a module-level logging.getLogger("appenv") singleton, configured per-command by setup_logging(). All components use it for structured debug output.

See Logging Conventions for the logging patterns contributors must follow.

Contributing

How to set up a development environment and submit changes.

Development Setup

git clone https://github.com/flyingcircusio/appenv.git
cd appenv

Repository layout:

.
├── src/appenv.py       # The entire tool — single file
├── tests/              # Test suite
├── docs/               # Sphinx documentation
│   ├── user/           # User-facing docs (commands, workflows)
│   └── dev/            # Developer guide (this page)
├── pyproject.toml      # Project config, dependencies, tool settings
└── uv.lock             # Pinned dependency versions

Run tests:

uv run pytest

All CI checks (lint, format, type-check, test) run via:

tox

Code Style

appenv follows the style defined in pyproject.toml under [tool.ruff]. Run uv run ruff check --fix . and uv run ruff format . to apply.

Type Annotations

Type annotations live in .pyi stub files, not in .py source files. The src/appenv.pyi stub is the complete type surface — it must include all public and private methods. Ruff’s ANN rules are dropped because they ignore .pyi files entirely.

Any method signature change requires updating both src/appenv.py and src/appenv.pyi. Validate with uv run ruff check --select PYI src/appenv.pyi.

The src/py.typed marker file signals PEP 561 compliance to type checkers.

Test files follow the same stub-only policy — every .py in tests/ has a matching .pyi. See Test Type Stubs for patterns and fixture types.

Exit Codes

Use BSD sysexits.h constants (EXIT_CODE_USAGE, EXIT_CODE_DATAERR, EXIT_CODE_NOINPUT, EXIT_CODE_UNAVAILABLE) defined at module level. See Error Handling Strategy for the full error handling strategy.

Running Tests

# All tests (excludes slow by default)
uv run pytest

# Specific file
uv run pytest tests/test_prepare.py

# With coverage report
uv run pytest --cov=appenv --cov-report=term-missing

# Include slow tests
uv run pytest -m ''

Two-Tier Model

appenv has no natural seam for an integration tier — uv is either mocked (unit) or real (E2E). Tests fall into exactly two tiers:

Unit tests (tests/test_*.py)

Mock uv via MockUvBin. Fast, no external dependencies. Covers logic branches, error handling, and argument construction.

E2E tests (tests/integration/)

Real uv, real subprocess via pexpect. Exercises the full bootstrap workflow end-to-end. Requires uv installed on the system.

The standard 70/20/10 pyramid model does not apply — test expansion beyond unit coverage is E2E-only.

Slow Marker

Any test consistently exceeding 2 seconds gets @pytest.mark.slow. The threshold is objective: measure new tests and apply the marker if they cross it.

  • Default pytest configuration excludes slow tests (-m "not slow")

  • tox cov environment runs all tests including slow

  • E2E tests should target under 2s to avoid needing the marker

Test Type Stubs

Test stubs live alongside their .py files in tests/. Every test function, helper, and class has a corresponding stub entry. Markers (@pytest.mark.slow, @pytest.mark.parametrize(...)) are preserved in stubs.

Signature patterns:

def test_example(monkeypatch: MonkeyPatch, tmp_path: Path) -> None: ...
def helper(verbose: bool = ...) -> None: ...

Builtin pytest fixture types:

Fixture

Type

monkeypatch

MonkeyPatch

capsys

CaptureFixture[str]

tmp_path

Path

request

FixtureRequest

caplog

LogCaptureFixture

Project fixture types (from tests/conftest.pyi):

Fixture

Return type

workdir

Path

mock_uv

MockUvBin

mock_uv_version

None

subprocess_run_fail

None

app_env

Callable[..., AppEnv]

make_mock_uv

Callable[..., MockUvBin]

no_ensure_python

None

mock_cmd_python

None

create_venv

Callable[..., Path]

mock_uv_lock

None

mock_logdir

Path

clean_uv_project_env

None

make_pyproject

Callable[..., None]

capture_appenv_logs

logging.Logger

test_settings

Callable[..., AppEnvSettings]

patterns

pytest-patterns plugin type

Validation:

uv run ruff check --select PYI tests/
uv run ty check tests/

Stubs must stay in sync with their .py counterparts — any signature change updates both files. Ty runs on src/ only in CI; test stub validation is manual until a follow-up enables it.

Test Strategy

  • Existing code with gaps: write tests first (tests-after) to cover uncovered paths

  • New features with code changes: write tests alongside or before implementation

  • Existing features with no code changes (e.g., untested subcommands): write E2E tests first (E2E-first) since the feature already works

Quality Gates

All of these must pass before submitting a PR:

Gate

Command

What it checks

Lint

uv run ruff check .

Code quality rules

Format

uv run ruff format --check .

Formatting consistency

Types

uv run ty check .

Type correctness via .pyi stubs

Dead code

uv run vulture .

Unused code detection

Complexity

complexipy src/

Cognitive complexity per function ≤ 15 (hard failure in tox -e cov)

Tests

uv run pytest

All tests pass

Full CI

tox

All environments (fix, cov, multiple Python versions)

Pre-commit hooks run these automatically.

Documentation

Docs are built with Sphinx using MyST markdown and autoapi:

tox -e docs
  • User docs: docs/user/ — usage and workflows

  • Dev docs: docs/dev/ — this guide

  • API reference: auto-generated from source by autoapi — do not write API docs by hand

Pull Request Process

  1. Fork and create a feature branch

  2. Make changes with accompanying tests

  3. Run tox — all environments must pass

  4. Update documentation if behavior changed

  5. Submit pull request

PR checklist:

  • tox passes cleanly

  • New behavior has tests

  • Documentation updated if applicable

  • No # noqa without justification