froot learns Python
froot learns Python
A second package ecosystem, slotted into a seam that was cut for it.
froot is a Temporal worker that runs dependency-patch loops against a repo and lets the repo's own CI verify each bump. It shipped speaking one ecosystem, npm. This PR teaches it a second, uv (Python). The interesting thing about the change is how little of it there is, and where the new lines are not. Expand any code block to read the surrounding context.
What "Python support" actually meant
froot's dependency-patch loop is mostly chassis: a durable schedule, a checkout, a wait on CI, the PR plumbing, the recorded outcome. Only three small things make a loop ecosystem-specific. The signal (which versions exist), the lockfile command (how to pin a bump), and the changelog source (where the release notes live).
So adding Python was never going to be a rewrite. It is a new adapter behind an existing port, one new line in an enum, and one small dispatcher that picks the adapter by ecosystem. Everything downstream of the adapter, the part that makes froot durable, did not move.
The tour follows the change from the inside out. We start at the seam that was already prepared, settle the two uv facts the whole adapter rests on, read the adapter's pure core, then walk out through dispatch, config, the changelog, the PR body, and the proof.
The extension point was cut on day one
src/froot/domain/ecosystem.pyOne module is built for exactly this. Its docstring states the extension contract in plain words: a new ecosystem is one enum member plus one adapter, and the match statements below "fail to type-check until it is [handled], which is the point." That contract predates this PR. The npm-only version already ended each match in assert_never, so adding uv was a guided edit rather than an open-ended one.
src/froot/domain/ecosystem.py ยท 43 lines
โฏ 7 lines hidden (lines 12โ18)
The change is one enum member plus two new cases. UV = "uv" joins the enum, and each match gains a case Ecosystem.UV returning pyproject.toml and uv.lock. The assert_never(ecosystem) after each match is the part that made this edit mandatory rather than optional.
Two facts, verified not guessed
src/froot/adapters/uv.pysrc/froot/adapters/npm.pyAn adapter is only as good as its grip on the real tool. Two questions decide the whole uv adapter, and both were answered against a live uv and a live PyPI before a line was written.
Q1. How do you list a package's available versions?
The npm adapter shells out to npm view <pkg> versions. uv has no equivalent. Its pip subcommands inspect an installed environment, and froot never installs anything. So the version list comes from the PyPI JSON API, which is the registry query that plays the role npm view plays for npm.
Q2. How do you pin a bump without running anything?
The npm adapter rewrites the lockfile with --package-lock-only --ignore-scripts. The uv equivalent is one command, and its precise behavior is the hinge the whole design hangs on.
The bump action, in full. Compare npm's apply_patch_bump below.
src/froot/adapters/uv.py ยท 251 lines
โฏ 238 lines hidden (lines 1โ238)
src/froot/adapters/npm.py ยท 162 lines
โฏ 148 lines hidden (lines 1โ148)
The two facts also draw the adapter's scope. Because uv lock edits only the lock, a dependency that someone pinned exactly in pyproject.toml (pkg==1.2.3) cannot be patched lockfile-only; uv errors, and apply_patch_bump raises rather than papering over it. That is the right failure: loud, and at the one bump it affects.
Pure parsers, a thin shell
src/froot/adapters/uv.pyThe adapter follows the same split as npm: a few pure functions that parse text, wrapped in two thin methods that do the I/O. The parsers are where the care is, because they run with no network and are unit-tested with fixtures. Three of them turn the three inputs into facts.
Names that agree across two files
pyproject.toml writes a dependency as Pydantic-Settings; uv.lock writes it as pydantic-settings. If the two never line up, every lookup misses. One function settles it for both sides.
src/froot/adapters/uv.py ยท 251 lines
โฏ 65 lines hidden (lines 1โ65)
โฏ 177 lines hidden (lines 75โ251)
normalize_name applies the PEP 503 rule: lowercase, and collapse runs of ., -, _ to a single -. The direct-dependency set and the locked-version map are both keyed through it, so a name declared one way and locked another still matches.
Direct dependencies, across three sections
A pyproject.toml can declare dependencies in three places, and froot reads all of them: the main list, every optional-dependency group, and PEP 735 dependency groups (where uv keeps its dev tools).
src/froot/adapters/uv.py ยท 251 lines
โฏ 91 lines hidden (lines 1โ91)
โฏ 134 lines hidden (lines 118โ251)
Available versions, with yanked releases dropped
The PyPI body lists every release. Two kinds are filtered before parsing: a release that ships no files, and one whose files are all yanked. A yanked patch is one PyPI is asking you not to install, so froot never proposes it.
src/froot/adapters/uv.py ยท 251 lines
โฏ 145 lines hidden (lines 1โ145)
โฏ 69 lines hidden (lines 183โ251)
The two methods on the class are deliberately thin. list_upgrades reads the manifest and lock, then asks PyPI for each direct dependency, returning raw AvailableUpgrades. It does not decide anything; choosing the patch is the shared pure policy's job, the same one npm feeds.
src/froot/adapters/uv.py ยท 251 lines
โฏ 202 lines hidden (lines 1โ202)
โฏ 14 lines hidden (lines 238โ251)
| Concern | npm adapter | uv adapter |
|---|---|---|
| direct deps | package.json deps + devDeps | pyproject.toml PEP 621 + PEP 735 |
| current version | package-lock.json | uv.lock [[package]] |
| available versions | npm view <pkg> versions | PyPI JSON API |
| pin the bump | npm install --package-lock-only --ignore-scripts | uv lock --upgrade-package pkg==target |
| who picks the target | select_patch_candidates (shared) | select_patch_candidates (shared) |
Reuse semver, drop the rest
src/froot/domain/version.pysrc/froot/adapters/uv.pyHere is the one genuine judgement call in the change. froot's Version is strict three-part semver. Python versions are PEP 440, which is wider: epochs like 1!2.0, two- or four-segment releases, post and dev releases, prereleases with no hyphen. The adapter does not teach Version PEP 440. It reuses semver as-is and lets anything that does not fit fall out.
src/froot/domain/version.py ยท 123 lines
โฏ 91 lines hidden (lines 1โ91)
โฏ 11 lines hidden (lines 113โ123)
is_patch_bump_of is the relation the whole loop turns on, and it is unchanged. A clean patch keeps the major and minor, raises the patch, and is stable on both ends. A PEP 440 oddity does not parse into a Version at all, so it never reaches this test. The funnel below is the result.
One place to choose an adapter
src/froot/adapters/registry.pysrc/froot/workflow/activities.pyBefore this PR, two activities built NpmPackageManager() by hand. A second ecosystem makes that wrong, because the choice now has to follow the target's ecosystem. So it moves out of the activities and into one small function.
src/froot/adapters/registry.py ยท 33 lines
โฏ 21 lines hidden (lines 1โ21)
The activities then ask the registry for the right manager instead of naming one. It is two lines each, in scan_candidates and open_pull_request.
src/froot/workflow/activities.py ยท 153 lines
โฏ 46 lines hidden (lines 1โ46)
โฏ 23 lines hidden (lines 53โ75)
โฏ 72 lines hidden (lines 82โ153)
Config in, changelog out
src/froot/config/settings.pysrc/froot/adapters/changelog_http.pyTwo edges of the loop still spoke only npm: how you point froot at a Python repo, and where it finds a Python package's changelog. Both are small, and one hid a trap.
Pointing froot at a uv repo
Targets come from FROOT_REPOS, a comma-separated list of owner/name slugs. A slug now takes an optional @<ecosystem> suffix. No suffix still means npm, so every existing config keeps working.
src/froot/config/settings.py ยท 138 lines
โฏ 47 lines hidden (lines 1โ47)
โฏ 63 lines hidden (lines 76โ138)
Finding a Python changelog
For npm, froot reads the registry's repository.url. For uv, it reads PyPI's project_urls and home_page, prefers a labelled source link over a homepage, and from there the GitHub release-notes fetch is shared with npm. Both ecosystems tag releases on GitHub the same way, so only the repo discovery differs.
src/froot/adapters/changelog_http.py ยท 181 lines
โฏ 87 lines hidden (lines 1โ87)
โฏ 68 lines hidden (lines 114โ181)
src/froot/adapters/changelog_http.py ยท 181 lines
โฏ 43 lines hidden (lines 1โ43)
โฏ 105 lines hidden (lines 77โ181)
The PR body has to match the diff
src/froot/policy/compose.pyfroot never merges. A human approves every pull request, which makes the body part of the safety surface rather than decoration. The body carries one line that says what the bump changed, and for uv the first draft of this PR got that line wrong.
npm rewrites both files. npm install --package-lock-only updates the dependency spec in package.json and regenerates package-lock.json. uv rewrites only uv.lock. The original template always named the manifest, so a uv PR told the reviewer that pyproject.toml changed while the diff held only uv.lock. A reviewer who trusted the body would hunt for a hunk that was not there.
src/froot/policy/compose.py ยท 103 lines
โฏ 38 lines hidden (lines 1โ38)
โฏ 47 lines hidden (lines 57โ103)
_changed_files now branches by ecosystem, so the sentence the human reads matches the files the human sees. npm keeps "manifest + lockfile". uv reads "uv.lock only; pyproject.toml unchanged". The body is built straight from it.
src/froot/policy/compose.py ยท 103 lines
โฏ 86 lines hidden (lines 1โ86)
Fixtures for the pure parts, reality for the rest
tests/test_uv_adapter.pyThe adapter is tested the way npm's is. The pure parsers get fixture tests with no network; the I/O methods are exercised through the activities over in-memory fakes. That keeps the suite fast and deterministic, and it is why the parsers were written as free functions in the first place.
tests/test_uv_adapter.py ยท 103 lines
โฏ 76 lines hidden (lines 1โ76)
โฏ 12 lines hidden (lines 92โ103)
This one test pins the filtering rules in place: a clean release is kept, a missing yanked key counts as not yanked, the prerelease 1.3.0rc1 is dropped as non-semver, a fully yanked release and one with no files are dropped, and an unparseable string is ignored. The dispatcher gets its own check that each ecosystem returns its own manager.
| What | How it is checked |
|---|---|
| PEP 503 normalization, PEP 621/735 parsing, uv.lock parsing | fixture unit tests |
| PyPI filtering (yanked, fileless, non-semver) | fixture unit test |
| ecosystem โ adapter dispatch | test_package_manager_for_dispatch |
activities pick the adapter by target.ecosystem | activity test over fakes |
@<ecosystem> config parsing, unknown rejected | settings tests |
| uv PR body says uv.lock only | compose test |
| repo discovery, sponsors guard, fragment URLs | changelog tests |
| the real uv + PyPI grip | two live smoke runs |
Did the seam hold?
The claim this PR makes is that a second ecosystem is one adapter, one enum member, and one dispatcher, with the durable machinery untouched. Below, that claim is scored against the actual change, with the file that proves each row.
| Claim | Upheld by | Where |
|---|---|---|
| A new ecosystem is one adapter behind the existing port | UvPackageManager satisfies PackageManager structurally; the spine names only the protocol | adapters/uv.py, ports/protocols.py |
| The seam was prepared, and the compiler enforces it | assert_never on every Ecosystem match; one enum member added | domain/ecosystem.py |
| Patch selection is shared, not reimplemented | both adapters feed the same select_patch_candidates | policy/candidates.py (unchanged) |
| The bump runs no third-party code in the worker | uv lock --upgrade-package, lockfile-only; CI does the install | adapters/uv.py, Dockerfile |
| Conservative versions: never a wrong bump | non-semver versions dropped at parse; the live skip of 0.63b1 | adapters/uv.py, domain/version.py |
| The human approver is told the truth | _changed_files matches the body to the diff per ecosystem | policy/compose.py |
Two findings from the adversarial review are folded in above: the PR-body mismatch (ยง7) and the funding-link trap (ยง6). A third, a URL with a #fragment losing the repo, was a pre-existing shared-code nit and was tightened in the same pass. All three came with regression tests. So, from the code: the change is additive, the new logic is conservative and tested, the one place that had assumed npm now states its ecosystem, and the durable loop that makes froot worth running is exactly as it was.