Skip to content

UV#61

Open
dpausp wants to merge 43 commits into
flyingcircusio:masterfrom
dpausp:uv
Open

UV#61
dpausp wants to merge 43 commits into
flyingcircusio:masterfrom
dpausp:uv

Conversation

@dpausp

@dpausp dpausp commented Mar 3, 2026

Copy link
Copy Markdown
Member

No description provided.

@zagy zagy self-requested a review March 12, 2026 07:13

@zagy zagy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation is a lot and it is very redundant. It doesn't help this way.

Also I stopped review at "fix_whitespace.py". There is o much noise here that It's really hard to review.

The commits need to be squashed to a useful amount.

Functionality itself seems to be there and working (in my tests) alas the repo is almost unmaintainable currently.

Comment thread docs/dev-guide/architecture.md Outdated
Comment on lines +19 to +32
def main():
# 1. Ensure correct Python version
ensure_best_python(base)

# 2. Clear PYTHONPATH for isolation
os.environ.pop("PYTHONPATH", None)

# 3. Dispatch to run or meta commands
appenv = AppEnv(base, original_cwd)
if application_name == "appenv":
appenv.meta(remaining)
else:
appenv.run(application_name, remaining)
```

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this is very useful here. It will get out of date.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand this file apart from being an AI code generation artefact.

@dpausp dpausp force-pushed the uv branch 3 times, most recently from 9f683b0 to 9f744c2 Compare March 19, 2026 21:29
@dpausp dpausp force-pushed the uv branch 2 times, most recently from 8024e92 to fed46a5 Compare March 26, 2026 09:40
Comment thread docs/dev-guide/contributing.md Outdated
Comment on lines +121 to +128
### PR Checklist

- [ ] Tests pass: `uv run pytest`
- [ ] Linting passes: `uv run ruff check .`
- [ ] Formatting correct: `uv run ruff format --check .`
- [ ] Type checking passes: `uv run ty check .`
- [ ] Documentation updated (if applicable)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't make sense here. If there was a checklist put it into the proper github template.

Comment thread scripts/fix_whitespace.py Outdated

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uhm, is this used anywhere? We usually use pre-commit to achieve this.

Comment thread docs/Makefile Outdated

# You can set these variables from the command line.
SPHINXOPTS ?=
SPHINXBUILD ?= sphinx-build

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this would need to be installed somehow … don't we have UV now?

@dpausp dpausp force-pushed the uv branch 4 times, most recently from 5d42002 to 3750be2 Compare March 30, 2026 04:00
@dpausp dpausp force-pushed the uv branch 3 times, most recently from 952acef to 571a893 Compare May 3, 2026 10:42
@dpausp dpausp force-pushed the uv branch 2 times, most recently from e8e5ba2 to 71a2501 Compare May 7, 2026 22:02
@zagy

zagy commented Jun 8, 2026

Copy link
Copy Markdown
Member

I have a system where a python 3.8 is lying around, and uv/appenv fails miserably. At least a useful error message would be nice.

Also, are newer pythons but Python 3.8 seems to be preferred for some reason.

# ~/deployment $ ./batou
Traceback (most recent call last):
  File "./batou", line 1906, in <module>
    main()
  File "./batou", line 1884, in main
    settings = appenv_settings_from_env()
  File "./batou", line 1828, in appenv_settings_from_env
    extras = cast(list[str], [e.strip() for e in extras_raw.split(",") if e.strip()])
TypeError: 'type' object is not subscriptable

# ~/deployment $ vim appenv ^C

# ~/deployment $ vim batou

[1]+  Stopped                    vim batou

# ~/deployment $ ./batou
> /srv/XXX/deployment/batou(1829)appenv_settings_from_env()
-> extras = cast(list[str], [e.strip() for e in extras_raw.split(",") if e.strip()])
(Pdb) sys.version
'3.8.11 (default, Jun 28 2021, 10:57:31) \n[GCC 10.3.0]'

dpausp and others added 8 commits June 11, 2026 11:42
Major migration from setuptools/tox to uv with comprehensive quality
improvements:

Core migration:
- Replace setuptools + tox with uv + tox-uv for all package management
- Add uv.lock, remove tox.ini in favor of pyproject [tool.tox]
- Self-update delegates standalone script updates to uvx
- init updates existing pyproject.toml on Python 3.11+ (tomllib)
- Introduce SubprocessError for clean subprocess failure exit

Test quality:
- Achieve 100% test coverage with fail_under=100
- Add E2E tests for all CLI commands + platform targets
  (Alpine/musl, macOS ARM64, Debian Bookworm/Bullseye)
- Add .pyi type stubs for all test modules
- Integrate complexipy cognitive complexity checks

Logging:
- Enforce structured topic:key=value format via Logrambo audit
- Add dual-channel error reporting, fill logging gaps

Documentation:
- Restructure docs into user/dev sections with Sphinx/Furo/MyST
- Add SPDX headers, --version flag, improved --help texts

Refactoring:
- Extract methods to reduce cognitive complexity below 15
- Remove runtime type evaluation, update stubs to Python 3.14
- Hardened mocks (spec=), narrowed exception clauses
- tests/integration/ -> tests/e2e/ (renamed in recent commits)
- slow marker no longer excluded by default; update Running Tests + Slow Marker sections
- remove stale repo-tree ASCII art (replaced by git ls-files pointer)
- compress discovery chain (6-step list -> concept + source pointer)
- compress platform detection table -> description with example triples
- compress quality-gates command table -> principle + pyproject pointer
- fix orphaned definition-list term for command symlinks
- add exit code numeric values to Error Handling Strategy
- add hidden toctree for logging-conventions (fixes sphinx -W build)
- delete .agents/impl_specs/ (7 planning specs for completed work)
- delete .agents/reports/quality-audit.md (stale one-time audit)
- reframe tests/test_docs_spec.py -> tests/test_docs.py:
  drop spec framing + 4 one-time cleanup checks,
  keep 11 valuable doc regression tests with descriptive names
- .agents/ now has no tracked files (tmp/ is gitignored)
Devloper and others added 28 commits June 16, 2026 23:37
The private-registry migration example named the wrong UV_INDEX_* env
vars: it showed UV_INDEX_GITLAB_USERNAME/_PASSWORD, but the token uv
expects is derived from the full registry hostname, not just the first
label. The example now reads UV_INDEX_GITLAB_EXAMPLE_COM_USERNAME/
_PASSWORD, matching the _index_name_from_url derivation in src/appenv.py
(gitlab.example.com -> gitlab-example-com -> token GITLAB_EXAMPLE_COM).

- Replace UV_INDEX_GITLAB_USERNAME/_PASSWORD with
  UV_INDEX_GITLAB_EXAMPLE_COM_USERNAME/_PASSWORD to reflect
  hostname-based token derivation
- Add a clarifying sentence explaining the <NAME> token derives from the
  registry hostname (gitlab.example.com -> GITLAB_EXAMPLE_COM)
…EADME

Five user-facing bugs found during self-use testing are fixed:
--python-version now validates its value, --description gained a
non-interactive flag, --help passes through on run/uv/python, init and
migrate write a consistent .gitignore, and the README init answer-hint
order matches the wizard prompt order. F3 (python-version) was critical;
F5/F6/F7/F8 were inconvenient.

This commit also bundles two non-dogfooding changes that belong with
this batch. (1) A hatchling sdist-exclude in pyproject.toml surfaced by
the build gate running on this batch: `uv build` previously captured the
runtime-bootstrapped .appenv/ venv (absolute-external symlinks) into the
sdist and failed; it now succeeds. (2) Two previously-untracked spec
files (fix-argparse-and-init-dispatch, fix-verbose-output) documenting
work already shipped in src/appenv.py.

All gates green: pre-commit (ruff check/format, ty, detect-private-key,
check-logging, yaml/toml), pytest (346 passed incl. 32 e2e), uv build.

- F3 (critical): add _validate_python_version type= validator on
  --python-version; rejects anything not matching ^\d+\.\d+$. argparse
  ArgumentTypeError routes through UsageArgumentParser.error -> exit 64
  (EXIT_CODE_USAGE) before create_pyproject writes anything. Format
  only; no minimum enforced (the user's choice).
- F7: add --description flag to the init subparser;
  _resolve_init_params_noninteractive uses args.description or "" instead
  of the hardcoded empty string. Interactive pre-fill is out of scope
  (the wizards do not yet receive the args Namespace).
- F5: add add_help=False to the python/run/uv subparsers so --help falls
  through parse_known_args into `remaining` and reaches the wrapped tool
  (uv run / uv / python) instead of argparse's empty auto-help. init
  keeps its own --help.
- F6: introduce module-level _GITIGNORE_ENTRIES = [".venv", ".appenv",
  ".batou-lock"] as the single source of truth shared by init and
  migrate, so `git add -A` after migrate never silently commits the
  .appenv/venv tree.
- F8: swap README init walkthrough hints so "http as binary" precedes
  "httpie as dependency" in both blocks, matching the wizard prompt
  order.
- docs: commands.md gains the --description row and the migrate
  .gitignore bullet now lists all three entries.
- tests: add test_init_cli_flags.py (12 collected: 9 python-version
  parametrized + 3 description), test_help_passthrough.py (4),
  test_gitignore_consistency.py (5) + .pyi, test_readme_init_order.py
  (1) + .pyi; add description=None to the test_init.py Namespace.
- build: pyproject.toml [tool.hatch.build.targets.sdist]
  exclude = [".appenv"] so the sdist no longer embeds the runtime
  .appenv/ venv; uv build now produces a valid sdist + wheel.
- specs: add fix-init-cli-flags, fix-help-passthrough,
  fix-gitignore-consistency, fix-readme-init-order; backfill
  fix-argparse-and-init-dispatch and fix-verbose-output (implementation
  already in src/appenv.py; spec files were never committed).
… rollback

`init` now refuses to re-run against an existing `[project]` section
instead of silently dropping fields it cannot round-trip without a TOML
writer, validates `--binary`/`--dep` before touching the filesystem, and
rolls back pyproject.toml plus the command symlink when uv-lock fails
mid-init. The CLI contract is tightened: run/uv/python forward unknown
flags to the wrapped tool while every other subcommand exits 64 on
unknown flags, and run/uv now require a pyproject.toml (exit 67) like
prepare/python. Docs and the migrate-pip-options coverage gap are closed
(full suite at 100% coverage).

BREAKING: `init` no longer updates projects that already have a
`[project]` section — it exits 65 (DATAERR) and points at
`./appenv uv add` or direct editing. `--dep` is now always required (at
least one); the optional-deps-when-updating path is gone. Behavior is
identical on every supported Python version.

- init: refuse re-init on existing [project] (F5); validate --binary
  against path traversal, absolute paths, and clobbering of user files or
  foreign symlinks (F1/F2/F3); dedup repeated --dep entries (F8)
- init: wrap the create+symlink+lock sequence in try/except BaseException
  that restores pyproject bytes (or deletes a freshly-created file) and
  removes a symlink this run created, then re-raises so the original
  error and exit code propagate (F7)
- contract: interactive init EOF surfaces as a clean exit 64 with a usage
  hint instead of a raw EOFError traceback (F4)
- contract: run/uv gain an ensure_pyproject pre-flight (exit 67); run
  inherits it via run_script -> run_uv delegation (F9)
- contract: argparse post-parse strictness — non-passthrough subcommands
  exit 64 on leftover flags; run/uv/python keep forwarding to the wrapped
  tool (F10)
- safety: add "no-compatible-python" to the verbose-console hidden events
  so the internal debug line stops leaking to verbose stdout (F13)
- docs: migrate after-step uses `appenv run <binary>` (F6); add a
  passthrough row to the exit-codes table (F11); correct the [app]
  default in the init interactive example (F12)
- remove dead code orphaned by refuse-re-init: _get_tomllib, the
  module-level tomllib global, _replace_project_section,
  _find_project_section_bounds, the unreachable has_project_section branch
  in _generate_content_with_project, their tests, two no-op tomllib
  monkeypatches, and the stale "Conditional tomllib" dev-doc bullet
- tests: cover migrate-pip-options edge branches (index-name collision
  suffix, dedup, value-consumption guards, inline `=` forms, unknown
  options) to close the pre-existing coverage gap and reach 100%
- specs: add seven implementation specs under .agents/impl_specs/
  documenting the decisions (fix-refuse-re-init, fix-init-safety-layer,
  fix-rollback-on-failure, fix-contract-treue, fix-dead-code-cleanup,
  fix-doc-consistency, fix-migrate-pip-options-coverage)

Verification: 364 tests passed (incl. 32 e2e), ruff check + format clean,
ty clean, vulture src/ clean, coverage 100% (1238 stmts, 346 branches).
…gaps

The dogfooding bundle (8ec1123) pushed AppEnv.init cognitive complexity
from 13 to 20, tripping the complexipy <=15 gate that the `cov` tox env
enforces. init is now a ~24-line orchestrator delegating to three private
helpers (_resolve_init_target, _resolve_init_params_with_eof_guard,
_atomic_write_and_lock), all under the threshold. The move is behavior-
preserving (the EOF-clean-exit and atomic-rollback SPEC markers travel into
the helpers); the full suite stays at 364 passed / 100% coverage. Smaller
post-dogfooding gaps close alongside it.

- init: extract _resolve_init_target, _resolve_init_params_with_eof_guard
  (EOF guard), and _atomic_write_and_lock (snapshot + atomic write + rollback);
  init drops out of the complexipy top-5 (highest function now 15)
- init: pass self.base instead of the out-of-scope `target` to create_pyproject
  — same value (_chdir_to_project sets self.base = target.resolve()), no
  observable change
- appenv.pyi: declare the three new private helper signatures
- commands.md: add exit-1 row to the BSD sysexits table for self-update
  --check version drift (already documented in the self-update subsection;
  the table gains only the pointer)
- test_docs.py: test_init_prompt_matches_source now also pins the [app]
  default to appear in both src/appenv.py and commands.md, so future drift
  in either direction fails here
- test_help_passthrough.py: relax "usage: appenv init" to "usage:" + "init"
  so the assertion holds on Python <3.14, where argparse leaks the
  <COMMAND> placeholder into the usage line (pre-existing failure, verified
  at 58548b4 on 3.10; init still owns its flags and is not delegated)
- specs: delete obsolete migrate-pip-options-refactor.md (the
  _parse_requirement_line complexity-32 refactor it targeted has landed);
  update fix-migrate-pip-options-coverage.md to drop the dangling reference
- add .agents/impl_specs/fix-post-dogfooding-cleanup.md documenting this work
GroupedHelpFormatter inherited from HelpFormatter, which collapses all
whitespace in the parser description to single spaces. The intended blank
line between the version banner and the description disappeared, merging
'appenv 2026.6.16b1' and 'appenv pins Python packages...' into one line.
Inherit from RawDescriptionHelpFormatter instead — _format_action override
for subcommand grouping is unaffected.
The 'New Project from Scratch' section created a project with only httpie,
then showed 'ln -s appenv pytest && ./pytest -xvs' — pytest was never
installed, so ./pytest failed with 'Binary not found'. Removed the block;
the Development Workflow section right after already demonstrates
'uv run pytest -xvs' for dev dependencies.
Add tools/check_stub_sync.py + the check-stub-sync pre-commit hook (runs
in CI via the pre-commit tox env) so drifted .pyi stubs can no longer be
committed. Reconcile all 17 existing stubs (src/appenv.pyi + 16 tests)
with their runtime modules, add a [tool.mypy] section (mypy_path + a
scoped pytest_patterns.* override), and document the gate in
docs/dev/index.md.

src/appenv.py and all test .py files are untouched; reconciliation is
one-directional (stub matches runtime, never vice versa).
tools/stubtest-allowlist masks the single mypy-dataclass-plugin
synthetic UvVersion._DT (not literal in any stub).

Validation: tools/check_stub_sync.py green (17/17), ty check src/ clean,
pytest 364 passed, ruff clean.

Spec: .agents/impl_specs/stubs-sync.md
Add the 9 remaining .pyi stubs (tests/__init__.pyi, tests/e2e/__init__.pyi,
tests/e2e/conftest.pyi, tests/test_migrate_pip_options.pyi,
tests/test_init_cli_flags.pyi, tests/test_help_passthrough.pyi,
tests/test_check_logging.pyi, tests/e2e/test_e2e_commands.pyi,
tests/e2e/test_e2e_github_download.pyi) so every test module is now
type-contracted. The stub-sync gate covers 26/26 modules.

Fix tools/check_stub_sync.py _module_name() to map __init__.pyi to its
package name (tests/__init__.pyi -> 'tests'), matching mypy's package-stub
convention; otherwise mypy errors 'Source file found twice under different
module names'.

No .py runtime touched; reconciliation is one-directional (stub matches
runtime, never vice versa).

Validation: check_stub_sync 26/26, direct stubtest on tests + tests.e2e
packages clean (gate fix not masking drift), ty check src/ clean, pytest
364 passed, ruff clean.

Spec: .agents/impl_specs/stubs-complete.md
Add "--ty" to [tool.pytest.ini_options].addopts so pytest-ty (already in
the test group but dormant until now) runs the ty type checker on every
test run. This complements the mypy.stubtest stub-sync gate: stubtest
enforces stub<->runtime consistency; pytest-ty enforces type-correctness
of the test code itself.

Cost is negligible (+0.5s / +2.7%); the suite is green (390 passed
incl. the ty items); xdist-compatible. Activating a dormant tool rather
than leaving it to rot.

Validation: pytest 390 passed (0 fail/0 error), ty check src/ clean,
stub-sync gate 26/26.
…l tests

Merge 8 source-confirmed duplicate/subset test pairs across 5 files into
pytest-patterns `full ==` structural assertions, replacing ad-hoc
`assert "x" in stdout` fragments with ordered backbones + no_errors.refused
guards.

Net: 83 -> 75 test functions (-8), 390 -> 384 pytest cases (-6), zero
coverage loss. Every merge verified against the identical src/appenv.py
branch; fs/exit asserts preserved.

Notable:
- test_init: two single-branch rejections (path/absolute binary name,
  clobber user-file/foreign-symlink) parametrized; the py39
  [project]-refusal variant removed (has_project_section is purely
  line-based, no tomllib path exists -- its documented purpose was
  impossible in the code).
- test_update_lockfile: 4 ad-hoc negative fragments -> no_errors.refused.
- First stderr-pattern tests in the repo (test_init merges assert
  full == captured.err; verified log.error does not reach captured.err
  under pytest).

Test stubs (.pyi) co-updated to keep the stub-sync gate green (26/26).

Validation: pytest 384 passed (0 fail/0 error), ty check src/ clean,
stub-sync 26/26, ruff clean, src/ untouched.
Two small cleanups bundled:
- Delete test_self_update_standalone_script_same_version (byte-identical
  duplicate of test_self_update_standalone_delegates_to_uvx: identical
  same-version-standalone setup + identical run_calls assertion; only
  comments/docstrings differed). Keep the better-named sibling.
- Fix merge-count typo in the consolidation spec ("Merge 7" / "## The 7
  merges" -> 8; the doc lists MERGE 1-8).

Suite: 384 -> 383 (-1 case).

The deferred third cleanup (delete the zero-assertion
  test_init_interactive_new_default_name) was NOT applied: doing so exposed
  a pre-existing test-order-pollution bug -- src/appenv.py:1404
  (_chdir_to_project) performs an un-monkeypatched os.chdir(), so init()-
  calling tests leak the process cwd; test_init_skips_appenv_script_creation
  depends on that leaked cwd for its expected SystemExit. Properly fixing
  this needs a systemic test-hygiene refactor (autouse cwd-restore fixture
  or per-test cwd management), out of scope here. Flagged for follow-up.

Validation: pytest 383 passed (0 fail/0 error), stable on re-run (randomized
  + deterministic), ty check src/ clean, stub-sync 26/26, ruff clean, src/
  untouched.
…test

appenv's _chdir_to_project (src/appenv.py:1404) performs an un-monkeypatched
os.chdir() -- intended CLI behavior, but in tests it leaks the process cwd
across tests. test_init_skips_appenv_script_creation depended on that leaked
cwd for its expected SystemExit (the error only fires when the cwd's
pyproject has a [project] section), making the suite fragile to collection-
order changes (e.g. test deletions).

Add _isolate_cwd autouse fixture to tests/conftest.py: snapshots Path.cwd()
before each test, restores it after, OSError-safe on both legs with a stable
_INVOCATION_CWD fallback (captured at collection). Complements the existing
workdir fixture (finalizes after it, catching any leak).

Now safe to remove the zero-assertion smoke test
test_init_interactive_new_default_name. Suite 383 -> 382 (-1 case).

Proof: with the test deleted but NO fixture, the canary fails with
"DID NOT RAISE SystemExit"; with the fixture, it passes standalone and
in-suite. src/appenv.py untouched (the chdir is intended CLI behavior).

Validation: pytest 382 passed (0 fail/0 error), canary passes standalone,
ty check src/ clean, stub-sync 26/26, ruff clean, src/ untouched.
…, ship pytest-randomly

pytest-randomly (added in this commit) surfaced a second cross-test leak
that the cwd-isolation fix (856c20e) does not cover: src/appenv.py:2115
and :1873 mutate os.environ['UV_PROJECT_ENVIRONMENT'] directly (intended
CLI behavior -- transports venv path to the uv subprocess).
test_stale_venv_broken_python called env.prepare() without the opt-in
clean_uv_project_env fixture, leaking the var in-process; the e2e
subprocess/pexpect boundaries had zero env sanitization, so real uv
inherited the stale var and failed with EACCES on a non-executable
python artifact. Reproduced 14/14 failures on seed 413120988.

Two complementary test-layer fixes (src/appenv.py untouched -- the
os.environ mutation is intended CLI behavior):

FIX flyingcircusio#1 (stopgap): make clean_uv_project_env autouse in tests/conftest.py.
Pops UV_PROJECT_ENVIRONMENT after every test, closing the leak at source.
Tests asserting the var mid-test read it before teardown, so they stay
green.

FIX flyingcircusio#2 (durable): sanitize the e2e subprocess env boundary.
_base_env() (relocated to tests/e2e/conftest.py) strips 6 appenv/uv
leak keys (UV_PROJECT_ENVIRONMENT, VIRTUAL_ENV, APPENV_BASEDIR/VERBOSE/
EXTRAS/BEST_PYTHON) before applying overrides; keeps PATH/HOME/UV_INDEX_*.
All previously-unsanitized boundaries (subprocess + pexpect across
conftest, test_e2e_commands, test_cli, test_subprocess) now thread
env=_base_env().

Proof: baseline polluter-then-e2e repro = 14 failed -> post-fix = 0
failures. Full suite 382 passed in 3 modes (randomized, deterministic,
seed 413120988). Canary (test_init_skips_appenv_script_creation) green.

Known architectural debt: src/appenv.py still mutates os.environ globally
(investigator's FIX flyingcircusio#3). Tracked as a separate follow-up; the test layer
now compensates so the suite is robust regardless.

Validation: pytest 382 passed (3 modes), ty check src/ clean, stub-sync
26/26, ruff clean, src/ untouched, uv.lock stable.
…(TDD)

22 new tests across 6 classes specify the target behaviour for the
upcoming production refactor that replaces os.environ mutation with
explicit env dicts threaded through subprocess boundaries and execve.

All 22 are intentionally RED (production code unchanged in this commit).
Failure modes are spec-faithful, not setup bugs:
- 6x TestBuildEnv: AttributeError ('AppEnv' has no '_build_env')
- 2x TestCmdEnvThreading: cmd() does not yet accept env=
- 5x TestUvBinSubprocessEnv: subprocess.run/check_output env=None
- 2x TestModuleSubprocessEnv: find_available_pythons/_self_update_via_uvx env=None
- 3x TestExecveConversion: production uses os.execv, not os.execve
- 4x TestNoGlobalMutation: os.environ still mutated (UV_PROJECT_ENVIRONMENT, APPENV_BASEDIR, PYTHONPATH)

The new public surface specified:
- AppEnv._build_env(overlays) builds the env dict, drops PYTHONPATH,
  preserves PATH/HOME, applies overlays without mutating os.environ
- cmd() accepts env= kwarg, threads to subprocess.check_output
- All 9 subprocess sites and 3 os.execv sites receive explicit env

pytest-ty kept green via '# ty: ignore[unknown-argument]' on the one
call that uses the not-yet-existing env= kwarg on cmd() -- this is the
TDD equivalent of an xfail marker for type-checks and gets removed when
FIX flyingcircusio#3 lands and the stub declares the kwarg.

Validation: 22 spec tests RED, 383 rest-of-suite + 2 ty meta tests GREEN
(no regression), ruff check + format clean (both .py and .pyi), ty clean,
stub-sync 27/27 (new file paired with .pyi stub).

Next commit (separate, by developer) implements FIX flyingcircusio#3 in src/appenv.py
to turn these 22 RED tests GREEN.
…utation

All subprocess boundaries now receive explicit env dicts built by the new
`_build_env()` helper instead of mutating `os.environ` before `os.execv()`.
`main()` no longer pops `PYTHONPATH` — the filter is embedded in
`_build_env()` so children get clean envs without side effects on the parent.
Test files are refactored from class-based to module-level functions, and a
new convention test (`test_no_test_classes`) prevents regression.

BREAKING: `UV_PROJECT_ENVIRONMENT`, `APPENV_BASEDIR`, and
`APPENV_BEST_PYTHON` are no longer set in the parent's `os.environ` after
`run()`/`run_uv()`/`prepare()`/`ensure_best_python()`. These vars are passed
exclusively via child process env dicts. Code reading them from `os.environ`
after these calls will see `None`.

- Replace `os.execv` with `os.execve` at all 3 call sites (AppEnv.run,
  AppEnv.run_uv, ensure_best_python) and in _self_update_via_uvx/meta — env
  dicts built with per-call overlays (APPENV_BASEDIR, UV_PROJECT_ENVIRONMENT,
  APPENV_BEST_PYTHON)
- Add module-level `_build_env(overlays)` — copies os.environ, drops
  PYTHONPATH, applies overlays; all subprocess boundaries consume it
- Add `AppEnv._build_env(overlays)` instance method delegating to the
  module-level helper
- Remove `os.environ.pop("PYTHONPATH", None)` from `main()` — filter lives
  in `_build_env()` instead
- Remove 4 `os.environ[...] = ...` mutations that leaked env vars globally
- Add `env=` parameter to `cmd()`, UvBin subprocess.run sites, `_uv_sync`,
  `self_update_via_uvx`, and `find_available_pythons` — every subprocess
  call receives an explicit env
- Add `tests/test_no_test_classes.py` — convention test banning `class
  Test...` in `tests/**/test_*.py` (excludes conftest.py)
- Refactor `test_check_logging.py` and `test_env_threading.py` from
  class-based to module-level functions (0 behavior change, purely
  structural to satisfy the new convention)
- Refactor corresponding `.pyi` stubs to match flat function signatures
- Update `test_main.py::test_main_clears_pythonpath` to assert PYTHONPATH
  is preserved in os.environ after main()
- Update `test_prepare.py::test_prepare_pyproject_sets_uv_project_environment`
  to assert UV_PROJECT_ENVIRONMENT is absent from os.environ after prepare()
…est_prepare

The mypy dependency group is now included when running tests, enabling
stub-sync verification via mypy.stubtest in test tox environments. Three
stub files had drifted from their implementation: _uv_sync was missing
the env parameter, and two test functions had been renamed without
updating their .pyi counterparts. These are now all in sync.

- pyproject.toml: add { include-group = "mypy" } to test dependency group
- uv.lock: regenerate to include mypy and types-pexpect under test dep manifests
- src/appenv.pyi: add env: dict[str, str] | None = None to _uv_sync signature — implementation gained the parameter, stub was never updated
- tests/test_main.pyi: rename test_main_clears_pythonpath -> test_main_preserves_pythonpath — test name changed to reflect behavior
- tests/test_prepare.pyi: rename test_prepare_pyproject_sets_uv_project_environment -> test_prepare_pyproject_does_not_leak_uv_project_environment — test name changed to accurately describe what it verifies
…ix stale docstrings and SPEC references

FIX flyingcircusio#3 (env dict threading) is now implemented — the global os.environ mutation is gone. The autouse clean_uv_project_env fixture and _LEAKED_ENV_KEYS e2e sanitization were compensating for the old global-mutation behaviour and are no longer needed. Stale RED-until docstrings and broken SPEC references that drifted during the refactor are corrected.

No test behavior change — all removed compensations were rendered redundant by ff84ca9 (env dict threading). Test-only changes.

- Remove clean_uv_project_env autouse fixture from conftest.py (was compensating for UV_PROJECT_ENVIRONMENT leaking via os.environ mutation, no longer needed since ff84ca9)
- Remove _LEAKED_ENV_KEYS tuple and stripping loop from e2e/conftest.py (same compensation for e2e subprocess boundary, now redundant)
- Update stale RED-until docstring in test_env_threading.py to GREEN-since
- Fix broken SPEC reference test_main_does_not_pop_pythonpath -> test_no_global_mutation_main_does_not_pop_pythonpath
- Fix broken SPEC reference test_prepare_leaves_uv_project_env_alone -> test_no_global_mutation_prepare_leaves_uv_project_env_alone
- Remove clean_uv_project_env fixture argument from 5 test_prepare test functions (no longer needed as fixture is gone)
- Sync conftest.pyi and test_prepare.pyi stubs with above deletions
Two coupled test-hygiene refactors. The internal exception class no longer
shadows stdlib subprocess.SubprocessError, and the e2e uv-runnability guard
now lives in one place and fails loudly on a broken helper instead of
silently skipping the whole e2e suite.

- Rename class SubprocessError→CommandError across src/appenv.py,
  src/appenv.pyi, tests/test_main.py, tests/test_prepare.py (12 edit points).
  Removes the namespace shadowing of stdlib subprocess.SubprocessError; no
  backward-compat alias (CODEX Art. 4).
- Deduplicate _uv_is_runnable: single source of truth in
  tests/e2e/conftest.py; remove the copy from tests/e2e/test_e2e_commands.py
  and consolidate the import with the existing _base_env import. Drop the
  stale stub entry from tests/e2e/test_e2e_commands.pyi.
- Narrow _uv_is_runnable exception handler from `except Exception` to
  `except (subprocess.SubprocessError, OSError)` so a broken helper crashes
  loudly instead of silently skipping e2e (CODEX Art. 5).
- Ship the impl spec at .agents/impl_specs/refactor-subprocesserror-and-uv-is-runnable.md
  alongside the change (matches repo convention).
- Verification: ruff + ty clean; mypy.stubtest clean for the renamed class (a
  pre-existing, unrelated UvVersion._DT stubtest error persists on HEAD);
  406/407 tests pass — the single failure
  (test_pip_install_uv_from_which_pip) is pre-existing and environmental
  (no pip in PATH), confirmed failing identically on HEAD.
Two previously uncovered surfaces now have direct unit tests: the
appenv_settings_from_env() env-parsing function (sole caller is
main(), run on every invocation) and the LockFile class backing the
update-lockfile workflow. A new `characterization` pytest marker
documents four spots of current buggy behavior so the planned smell
fixes make their behavior change visible instead of silent. Sibling
.pyi stubs ship alongside so the project's check_stub_sync gate
stays green.

- Add 9 tests in tests/test_main.py for appenv_settings_from_env:
  APPENV_VERBOSE/APPENV_EXTRAS/APPENV_BASEDIR parsing, defaults,
  verbose truthy-on-any-value quirk, frozen-dataclass invariant,
  plus a _clear_appenv_settings_env helper for test isolation
- Add tests/test_lockfile.py with 17 tests targeting LockFile
  directly: read_lockfile_lines set/filter semantics, diff_summary
  count/label behavior, mock-uv diff flow, pyproject copy into the
  temp dir, and original-lockfile immutability
- Extend tests/test_main.pyi and add tests/test_lockfile.pyi so the
  stub set matches the new test surface (check_stub_sync: 29/29 ok)
- Register `characterization` pytest marker in pyproject.toml
- Mark 4 tests @pytest.mark.characterization that pin current
  behavior to flip on the planned fixes: APPENV_EXTRAS no-dedup,
  diff_summary empty->empty reports "Created", diff silent-Changed
  when uv produces no lockfile, and read_lockfile_lines keeping an
  inline `#` (regression guard against over-eager simplification)
- Commit the two driving impl specs under .agents/impl_specs/
…g uv.lock

Fixes three src/appenv.py bugs whose correct behavior was pinned by the
characterization tests added in 5231579. APPENV_EXTRAS is now deduplicated
(matching the --dep path); LockFile.diff_summary reports "No changes" for
empty→empty instead of "✓ Created (+0 lines)"; LockFile.diff now fails loudly
with CommandError when uv exits 0 without writing uv.lock, instead of silently
rendering the entire old lockfile as removed. The three characterization tests
are flipped to assert the fixed contracts and their markers dropped; only the
inline-hash regression guard keeps its characterization marker.

- appenv_settings_from_env now wraps APPENV_EXTRAS parsing in
  _dedup_preserve_order (line ~2516), aligning the env-var path with the --dep
  path (line 1503); "dev,dev,test" -> ["dev", "test"]
- LockFile.diff_summary short-circuits empty→empty to "No changes" before the
  is_new computation, fixing the "✓ Created (+0 lines)" fallthrough
- LockFile.diff raises CommandError(returncode=1) and logs
  "uv-lock-no-output: reason=no-lockfile-written" when uv exits 0 without
  writing uv.lock (CODEX Art. 5 "Fail loudly"); previously returned "Changed"
  and rendered the whole old lockfile as removed
- test_diff_copies_pyproject_to_tmpdir mock now writes uv.lock so it passes the
  new no-output guard (collateral fix; original pyproject-copy verification
  intent preserved)
- Three characterization tests renamed/flipped to pin the new contracts:
  test_settings_from_env_extras_deduplicated,
  test_diff_summary_empty_to_empty_reports_no_changes,
  test_diff_when_uv_produces_no_lockfile_raises; characterization markers
  removed
- Sibling .pyi stubs updated to match renames; test_lockfile.pyi gains the
  LogCaptureFixture import for the caplog assertion
- Exactly one characterization marker remains:
  test_read_lockfile_lines_inline_hash_kept — the inline-comment regression
  guard, intentionally kept
- Track the governing spec
  .agents/impl_specs/fix-smells-extras-dedup-empty-empty-diff-silent.md
  alongside the implementation; the code's inline comments reference it by name
- Verification: ruff + ty clean, tools/check_stub_sync.py 29/29 in sync,
  443 pytest pass
The "What It Preserves" list for the `reset` command described
`.appenv/current` as "Version tracking" — inaccurate, since it is a legacy
symlink to the active venv, not a version tracker. Readers of commands.md now
see the correct purpose of the preserved entry.

- Rewrite line 297 from "Version tracking in `.appenv/current/`" to "Legacy symlink to current venv at `.appenv/current`"
- Description verified against `_ensure_venv_symlinks` (src/appenv.py:2131-2135), which creates `.appenv/current` as a symlink to `venv`
- Categorization under "What It Preserves" remains correct: the reset() keep-set (src/appenv.py:1941) retains "current"
APPENV_VERBOSE now activates verbose only when its value is non-empty
after stripping. Previously `os.environ.get(...) is not None` made ANY
assignment truthy — including `APPENV_VERBOSE=` (empty) and
`APPENV_VERBOSE=   ` (whitespace) — which silently enabled verbose
output. Callers using the documented `APPENV_VERBOSE=1` convention are
unaffected.

BREAKING: Empty/whitespace-only APPENV_VERBOSE no longer activates
verbose (CODEX Art. 4 — BREAK EVERYTHING). Python string truthiness
still applies, so "0"/"false" remain truthy; the documented convention
is `APPENV_VERBOSE=1` or `=true`.

- Replace `is not None` with `bool(os.environ.get("APPENV_VERBOSE", "").strip())`
  in appenv_settings_from_env (Smell flyingcircusio#2 of the settings/lockfile audit)
- Flip the characterization test into two contract tests: 7 non-empty
  cases assert verbose=True, 4 empty/whitespace cases assert verbose=False
- Sync tests/test_main.pyi stub to the renamed/added test signatures
- Add governing spec .agents/impl_specs/fix-smell-appenv-verbose-truthy.md,
  referenced by the inline SPEC comment and test docstrings
- All doc/e2e references already use the unaffected `=1` convention
  (commands.md, workflows.md, logging-conventions.md, dev/index.md,
  .github/workflows/e2e.yml) — no doc changes required
- Verified: ruff + ty + tools/check_stub_sync.py (29/29) clean; 448
  pytest pass (was 443, +5 from splitting the 6-case test into 7+4);
  characterization marker count unchanged at exactly 1
All 27 implementation-spec files under .agents/impl_specs/ have been
implemented and committed across earlier sessions, so they are removed as
bulk cleanup. The five inline references in src/appenv.py and three test
files that pointed at the now-deleted specs are edited to drop the path
while preserving the substantive comment/docstring text. No executable
code or behavior changes — comment and docstring edits only.

- Delete 27 spec files under .agents/impl_specs/ (2510 deletions); each
  was implemented in a prior commit (e.g. fix-smell-appenv-verbose-truthy
  -> 8feb18b, fix-smells-extras-dedup-empty-empty-diff-silent -> 78512e9,
  refactor-subprocesserror-and-uv-is-runnable -> 21ee7b9)
- Drop the .agents/impl_specs/migrate-pip-options.md reference from the
  index/extra-index comment in src/appenv.py (~L130), keeping the
  explanation of index/extra-index URL handling
- Drop the .agents/impl_specs/fix-init-cli-flags.md reference from the
  _validate_python_version docstring in src/appenv.py (~L348), keeping
  the note that only format, not a minimum, is enforced
- Remove the "Encodes .agents/impl_specs/fix-init-cli-flags.md:" preamble
  line from the tests/test_init_cli_flags.py module docstring
- Remove the spec-contract sentence from the
  tests/test_migrate_pip_options.py module docstring, preserving the rest
- Collapse tests/test_readme_init_order.py module docstring to one line
  after dropping its Spec: reference
- Verified zero remaining references: `rg "\.agents/impl_specs" src/ tests/`
  exits 1 (no matches); the .agents/impl_specs/ directory is gone
- Verification: the git pre-commit hook suite passed on commit —
  detect-private-key, ruff check, ruff format, check logging conventions,
  ty type check, and stub<->runtime sync (mypy.stubtest) all Passed.
  (doit is installed system-wide but no dodo.py/precommit task is
  configured for this repo, so that task gate does not apply.)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants