|
| 1 | +# Plan: a default-Python-tooling Dagger module |
| 2 | + |
| 3 | +## Goal and boundary |
| 4 | + |
| 5 | +`dagger/go` gives a Go workspace free defaults: discover every module, then lint, |
| 6 | +test, and generate across all of them. This module does the same for Python |
| 7 | +source, minus lint and format — those belong to `dagger/ruff`, which already |
| 8 | +covers them with a standalone binary and no Python runtime. Installing both |
| 9 | +gives a Python workspace the same "free defaults" experience a Go workspace |
| 10 | +gets today. |
| 11 | + |
| 12 | +What is left once lint and format are removed is the part that actually needs a |
| 13 | +Python runtime and a resolver: **tests**, **type checking**, **dependency |
| 14 | +lock freshness**, and **packaging**. Those are the four things this module owns. |
| 15 | + |
| 16 | +## Discovery |
| 17 | + |
| 18 | +Marker: `pyproject.toml`, via the native `ws.findRoots(markers: ["pyproject.toml"])` |
| 19 | +(beta.10). No polyfill dependency. `findRoots` answers relative to the workspace |
| 20 | +cwd — `.` for the cwd, `..` for an enclosing project — so results are resolved |
| 21 | +back to workspace-root-relative paths before they become project roots, using the |
| 22 | +same `workspaceRootPath` helper shape as `dagger/go` PR #42. |
| 23 | + |
| 24 | +Discovery excludes `**/.venv/**`, `**/site-packages/**`, `**/node_modules/**`: |
| 25 | +a virtualenv contains hundreds of vendored `pyproject.toml` files and every one |
| 26 | +of them would otherwise become a "project". |
| 27 | + |
| 28 | +Legacy `setup.py` / `requirements.txt` projects are **not** supported in v1. |
| 29 | +It is not a matter of adding a marker: a `requirements.txt` directory has no |
| 30 | +declarative project metadata, so `uv run`/`uv lock`/`uv build` do not apply and |
| 31 | +the whole toolchain below would need a second code path (`uv pip install -r`, |
| 32 | +no lock, no build). PEP 621 `pyproject.toml` is the modern baseline and is what |
| 33 | +`uv`, `pytest`, `mypy`, and `ruff` all key off. Revisit if real workspaces ask. |
| 34 | + |
| 35 | +## Toolchain |
| 36 | + |
| 37 | +Astral-aligned, matching how `dagger/ruff` is positioned. |
| 38 | + |
| 39 | +- **`uv`** for everything environment-shaped. Non-negotiable given the ecosystem |
| 40 | + choice. The binary is pinned via a Dependabot-trackable |
| 41 | + `images/uv/Dockerfile`, exactly like `dagger/ruff` pins the ruff binary, and |
| 42 | + is layered onto a `python:<version>-slim` base. Debian, not Alpine: Python |
| 43 | + wheels are manylinux, so musl forces source builds of half the ecosystem. |
| 44 | +- **`pytest` via `uv run --with pytest`** for tests. `--with` means the project |
| 45 | + does not have to declare pytest itself to get a test run, and a project that |
| 46 | + *does* pin pytest still resolves to its own pin. |
| 47 | +- **`mypy` via `uv run --with mypy`** for type checking, with `ty` selectable by |
| 48 | + a one-word constructor switch. mypy is the default because it is the stable, |
| 49 | + de-facto standard whose `[tool.mypy]` config already exists in real projects, |
| 50 | + and because its non-strict default only checks annotated code — so it stays |
| 51 | + quiet on codebases that have not opted in, which is what a "free defaults" |
| 52 | + module needs. `ty` is the Astral-aligned successor but is still 0.0.x/beta as |
| 53 | + of this writing; making it a switch rather than the default means flipping it |
| 54 | + later is a one-line change, not a migration. |
| 55 | +- **`uv lock`** for dependency freshness, exposed as a `@generate` changeset |
| 56 | + rather than a pass/fail `uv lock --check`. Drift then shows up in |
| 57 | + `dagger check` *and* is fixable with `dagger generate` — strictly more useful |
| 58 | + than a check that only tells you it is stale. |
| 59 | +- **`uv build`** for packaging, exposed as a plain `Directory!` of `dist/` |
| 60 | + rather than a `@check`. Not every `pyproject.toml` is buildable (app-only |
| 61 | + projects, non-packaged uv workspace members), so making it a default check |
| 62 | + would fail workspaces that are perfectly fine. |
| 63 | + |
| 64 | +Each of these is gated on a real precondition so an unrelated project is never |
| 65 | +failed by a tool it does not use: tests only run where test files exist |
| 66 | +(bare `pytest` exits 5 on an empty collection), lock only runs where a `uv.lock` |
| 67 | +already exists (so nothing is forced to adopt uv locking), and type checking |
| 68 | +only runs where `.py` files exist. |
| 69 | + |
| 70 | +## Include strategy |
| 71 | + |
| 72 | +Deliberate v1 scope reduction: **no static import-graph analysis.** `dagger/go` |
| 73 | +ships a compiled `go-includes` helper that walks the import graph to compute a |
| 74 | +minimal per-module include set. Python's equivalent would be a real analyzer, |
| 75 | +and it would buy much less: `uv` needs the whole project tree anyway (build |
| 76 | +backends read arbitrary files, `[tool.uv.workspace]` members are path |
| 77 | +dependencies, `conftest.py` and fixture data are loaded at runtime and are |
| 78 | +invisible to imports). So the include set is layout-based: |
| 79 | + |
| 80 | + <path>/** minus **/.venv/**, **/site-packages/**, **/node_modules/**, |
| 81 | + **/__pycache__/**, **/*.pyc, **/.pytest_cache/**, |
| 82 | + **/.mypy_cache/**, **/.ruff_cache/**, **/*.egg-info/** |
| 83 | + |
| 84 | +plus `includeExtraFiles` for workspace-root files a project needs. Including |
| 85 | +`<path>/**` wholesale is not laziness here — it is what makes uv workspaces |
| 86 | +work, since a workspace root genuinely needs its members' sources on disk. |
| 87 | + |
| 88 | +`uv.lock` must be inside the include set. That is the regenerate-idempotency |
| 89 | +trap: the changeset baseline is the project's own source, so if the lock were |
| 90 | +excluded, a second `uv lock` would report it as newly added forever. There is an |
| 91 | +e2e check for exactly this. |
| 92 | + |
| 93 | +## Nested projects |
| 94 | + |
| 95 | +A nested `pyproject.toml` is its own discovered project with its own run. The |
| 96 | +parent therefore *mounts* the nested subtree (uv needs it) but *excludes it from |
| 97 | +its own commands*: `pytest --ignore=<rel>` and `mypy --exclude '^<rel>/'` / |
| 98 | +`ty check --exclude <rel>/`. Without that, a nested project's tests run twice |
| 99 | +and a parent fails on a child whose dependencies it never synced. |
| 100 | + |
| 101 | +## Shape |
| 102 | + |
| 103 | +Mirrors `Go`/`GoModule`. |
| 104 | + |
| 105 | + Python PythonProject |
| 106 | + ------ ------------- |
| 107 | + version / base (mutually exclusive) path |
| 108 | + typeChecker ("mypy" | "ty") version |
| 109 | + includeExtraFiles includeExtraFiles |
| 110 | + test / typeCheck / lock (selection) skipTest / skipTypeCheck / skipLock |
| 111 | + projects(ws, include:, exclude:, ...) hasTests / hasPythonFiles / hasLock |
| 112 | + project(ws, path, findUp:) nestedProjects |
| 113 | + includeBase / include / exclude / source |
| 114 | + testAll @check test @check |
| 115 | + typeCheckAll @check typeCheck @check |
| 116 | + lockAll @generate lock -> Changeset! |
| 117 | + build -> Directory! |
| 118 | + |
| 119 | +Selection patterns use `dagger/go`'s rules verbatim: bare pattern includes, |
| 120 | +`"!"`-prefix excludes, exclude always wins regardless of order, empty list means |
| 121 | +everything, `"**"`/`"*"` match all, `X` and `X/**` both mean "X and below". |
| 122 | +Consistency across the two modules is worth more here than any improvement. |
| 123 | + |
| 124 | +## Testing |
| 125 | + |
| 126 | +`.dagger/modules/e2e` with `testdata/` fixtures, following `dagger/go`: |
| 127 | +discovery (root / nested / deep / cwd-relative `..` resolution / `.venv` |
| 128 | +exclusion), a passing and a failing test project, a passing and a failing type |
| 129 | +check, both type checkers, nested-project isolation, selection patterns, |
| 130 | +lock drift detection, and regenerate idempotency (`lockAll` twice on a current |
| 131 | +lock reports zero added/modified/removed). |
0 commit comments