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 toappenv), 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.pywith minimal typing. Complete type annotations (public and private methods) live inappenv.pyi. Any signature change must update both files. See #type-annotations for the full policy.Conditional tomllib:
initusestomllib(stdlib, Python 3.11+) to parse existingpyproject.tomlwhen a[project]section already exists. The import is guarded with try/except — no third-party dependency. On Python 3.9–3.10,initfalls 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:
Clear PYTHONPATH — prevents host environment contamination in the venv
Select best Python via
ensure_best_python()(see #python-version-selection)Dispatch based on filename:
Filename is
appenv→meta()(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 (requirestomllib, 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:
PATH —
shutil.which("uv"), validate versionCached binary —
.appenv/.uv/bin/uvfrom a previous nix/pip installNix channel —
nix-build <nixpkgs> -A uvinto.appenv/.uvNix flake —
nix build nixpkgs#uvinto.appenv/.uv(more expensive, fresher packages)pip install —
pip install uv -t .appenv/.uvas last resortDirect 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 |
|
|
|
aarch64 |
|
|
|
armv7 |
|
|
— |
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:
Run guards:
ensure_pyproject(),ensure_lock_file(),ensure_uv()Set
UV_PROJECT_ENVIRONMENTto.appenv/venvso uv targets the right directory_check_venv_health()— corruption recovery (missingbin/pythonon NixOS garbage collection) and stale version recovery (venv Python doesn’t satisfyrequires-python). ReturnsTrueif the venv was removed and needs recreation.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)Sync production dependencies via
uv sync --no-dev --frozen_ensure_venv_symlinks()— creates/updates the.venvsymlink and the legacy.appenv/currentsymlink
Sync Modes¶
Production (
prepare, symlink dispatch):uv sync --no-dev --frozen— only production dependencies, lockfile must exist and be unchanged.Run (
run): Delegates directly touv runwith 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.tomlProject definition with
[project]section andrequires-python. Required for all operations.uv.lockDependency lockfile created by
./appenv update-lockfile. Required beforeprepare.appenvThe bootstrap script — a copy of
src/appenv.py.migrateupdates this file when the running appenv version differs from the one on disk.initwarns 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.
.venvSymlink 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
uvviaMockUvBin. Fast, no external dependencies. Covers logic branches, error handling, and argument construction.- E2E tests (
tests/integration/) Real
uv, real subprocess viapexpect. Exercises the full bootstrap workflow end-to-end. Requiresuvinstalled 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
pytestconfiguration excludes slow tests (-m "not slow")toxcov environment runs all tests including slowE2E 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 |
|---|---|
|
|
|
|
|
|
|
|
|
|
Project fixture types (from tests/conftest.pyi):
Fixture |
Return type |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 |
|
Code quality rules |
Format |
|
Formatting consistency |
Types |
|
Type correctness via |
Dead code |
|
Unused code detection |
Complexity |
|
Cognitive complexity per function ≤ 15 (hard failure in |
Tests |
|
All tests pass |
Full CI |
|
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 workflowsDev docs:
docs/dev/— this guideAPI reference: auto-generated from source by autoapi — do not write API docs by hand
Pull Request Process¶
Fork and create a feature branch
Make changes with accompanying tests
Run
tox— all environments must passUpdate documentation if behavior changed
Submit pull request
PR checklist:
toxpasses cleanlyNew behavior has tests
Documentation updated if applicable
No
# noqawithout justification