froot ยท uvยท

froot learns Python

A code-level review of one PR ยท the uv (Python) ecosystem

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.

๐Ÿ†• added by this PR โ™ป๏ธ chassis ยท unchanged ๐Ÿ uv & PyPI ยท external truth
2
ecosystems (was 1)
1
new adapter
0
lines changed in the state machine & workflows
116
tests green
3
review findings fixed

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.

target.ecosystem

Ecosystem.NPM

Ecosystem.UV

satisfies (structural)

satisfies (structural)

โš™๏ธ scan_candidates ยท open_pull_request
(activities โ€” chassis)

๐Ÿงญ package_manager_for(ecosystem)
(new dispatch)

๐Ÿ“œ PackageManager
(Protocol โ€” the seam)

๐Ÿ”Œ NpmPackageManager
(unchanged)

๐Ÿ†• UvPackageManager
(this PR)

โ™ป๏ธ state machine ยท bump & scan workflows
CI wait ยท record outcome
(0 lines changed)

The whole shape of the change. A new dispatcher picks a package manager by ecosystem; the new uv adapter sits behind the same Protocol the npm one already satisfied. The durable loop below is untouched.

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 seam1๐ŸŽฌ The prepared seam

The extension point was cut on day one

src/froot/domain/ecosystem.py

One 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
src/froot/domain/ecosystem.py43 lines ยท Python
1"""The package ecosystems froot can patch, and their manifest/lockfile names.
2 
3froot's chassis is ecosystem-agnostic; the per-ecosystem facts live here and in
4the matching adapter. Two ecosystems ship today โ€” :data:`Ecosystem.NPM`
5(JavaScript) and :data:`Ecosystem.UV` (Python) โ€” and each is exactly one enum
6member plus one adapter (:mod:`froot.adapters.npm`, :mod:`froot.adapters.uv`),
7selected by :func:`froot.adapters.registry.package_manager_for`. A further
8ecosystem is the same shape: add a member, handle it in the ``match`` statements
9below (they fail to type-check until it is, which is the point), and add an
10adapter.
11"""
โ‹ฏ 7 lines hidden (lines 12โ€“18)
12 
13from __future__ import annotations
14 
15from enum import StrEnum
16from typing import assert_never
17 
18 
19class Ecosystem(StrEnum):
20 """A package manager whose dependencies froot can propose patches for."""
21 
22 NPM = "npm"
23 UV = "uv"
24 
25 
26def manifest_filename(ecosystem: Ecosystem) -> str:
27 """The dependency manifest a human edits for this ecosystem."""
28 match ecosystem:
29 case Ecosystem.NPM:
30 return "package.json"
31 case Ecosystem.UV:
32 return "pyproject.toml"
33 assert_never(ecosystem)
34 
35 
36def lockfile_filename(ecosystem: Ecosystem) -> str:
37 """The resolved lockfile froot regenerates alongside the manifest."""
38 match ecosystem:
39 case Ecosystem.NPM:
40 return "package-lock.json"
41 case Ecosystem.UV:
42 return "uv.lock"
43 assert_never(ecosystem)

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.

The uv adapter2๐ŸŽฌ Q&A from the source

Two facts, verified not guessed

src/froot/adapters/uv.pysrc/froot/adapters/npm.py

An 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
src/froot/adapters/uv.py251 lines ยท Python
โ‹ฏ 238 lines hidden (lines 1โ€“238)
1"""The uv (Python) package-manager adapter.
2 
3Reads upgrade availability from the committed manifest + lockfile (no install
4needed) plus the PyPI registry, and regenerates the lockfile with ``uv lock
5--upgrade-package <pkg>==<target>`` โ€” lockfile-only, so the real install, build,
6and tests happen in the repo's CI (the oracle), never in the worker.
7 
8Three boundary facts, each parsed by a pure, fixture-tested function:
9 
10* **Direct dependencies** come from ``pyproject.toml`` (PEP 621
11 ``[project.dependencies]`` / ``[project.optional-dependencies]`` and PEP 735
12 ``[dependency-groups]``). Only direct dependencies are bumped; a transitive
13 one would be promoted to direct, which is not a patch.
14* **The installed baseline** comes from ``uv.lock`` (its ``[[package]]``
15 entries), *not* from an installed environment: froot only does a clone, never
16 a ``uv sync``. Names are PEP 503-normalized so the manifest and lock agree
17 (``Pydantic-Settings`` == ``pydantic-settings``).
18* **The available versions** come from the PyPI JSON API. uv has no
19 "list every version" command (its ``pip`` subcommands inspect an installed
20 environment only), so this is the registry query that mirrors ``npm view``.
21 
22Two deliberate scope notes, both conservative โ€” froot proposes *fewer* Python
23bumps, never a wrong one:
24 
25* Versions use the shared semver :class:`~froot.domain.version.Version`, so
26 PEP 440 forms outside ``X.Y.Z`` (epochs, two- or four-segment releases,
27 ``post``/``dev`` releases, non-``-`` prereleases) are skipped. The common
28 ``X.Y.Z -> X.Y.(Z+1)`` patch โ€” the loop's whole job โ€” round-trips cleanly.
29* ``uv lock`` only touches ``uv.lock``; it does not edit ``pyproject.toml``. A
30 patch within the existing constraint (``>=`` / ``~=`` / unbounded) needs no
31 manifest change and ``uv sync --frozen`` accepts the pair. A dependency
32 pinned exactly (``pkg==1.2.3``) cannot be patched lockfile-only โ€” ``uv lock``
33 errors, which :meth:`UvPackageManager.apply_patch_bump` surfaces.
34"""
35 
36from __future__ import annotations
37 
38import json
39import re
40import tomllib
41from typing import TYPE_CHECKING
42 
43import httpx
44 
45from froot.adapters._proc import run_text
46from froot.domain.candidate import AvailableUpgrade
47from froot.domain.version import Version
48from froot.result import Ok
49 
50if TYPE_CHECKING:
51 from pathlib import Path
52 
53 from froot.domain.candidate import PatchCandidate
54 from froot.domain.repo import TargetRepo
55 
56_PYPI_JSON = "https://pypi.org/pypi"
57_TIMEOUT = 15.0
58 
59# The leading distribution name of a PEP 508 requirement (before any extras,
60# version specifier, or environment marker).
61_REQUIREMENT_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*")
62# PEP 503 separators: runs of '.', '-', '_' collapse to a single '-'.
63_NAME_SEPARATORS = re.compile(r"[-_.]+")
64 
65 
66def normalize_name(name: str) -> str:
67 """PEP 503-normalize a project name (lowercase; ``.-_`` runs -> ``-``)."""
68 return _NAME_SEPARATORS.sub("-", name).strip("-").lower()
69 
70 
71def _requirement_name(requirement: str) -> str | None:
72 """The normalized distribution name of a PEP 508 requirement string."""
73 match = _REQUIREMENT_NAME.match(requirement.strip())
74 return normalize_name(match.group(0)) if match else None
75 
76 
77def _collect_requirements(requirements: object, into: set[str]) -> None:
78 """Add the normalized names from a list of requirement strings."""
79 if not isinstance(requirements, list):
80 return
81 for requirement in requirements:
82 # PEP 735 groups may hold ``{include-group = ...}`` tables; the group
83 # they reference is collected on its own pass, so non-strings are
84 # skipped here.
85 if not isinstance(requirement, str):
86 continue
87 name = _requirement_name(requirement)
88 if name:
89 into.add(name)
90 
91 
92def parse_direct_dependencies(pyproject: str) -> frozenset[str]:
93 """The direct dependency names declared in a ``pyproject.toml``.
94 
95 Reads PEP 621 ``[project.dependencies]`` and every
96 ``[project.optional-dependencies]`` group, plus PEP 735
97 ``[dependency-groups]`` (e.g. uv's ``dev`` group). Names are PEP
98 503-normalized. Malformed TOML yields an empty set (a boundary concern,
99 handled like any other unparseable input).
100 """
101 try:
102 data = tomllib.loads(pyproject)
103 except tomllib.TOMLDecodeError:
104 return frozenset()
105 names: set[str] = set()
106 project = data.get("project")
107 if isinstance(project, dict):
108 _collect_requirements(project.get("dependencies"), names)
109 optional = project.get("optional-dependencies")
110 if isinstance(optional, dict):
111 for group in optional.values():
112 _collect_requirements(group, names)
113 groups = data.get("dependency-groups")
114 if isinstance(groups, dict):
115 for group in groups.values():
116 _collect_requirements(group, names)
117 return frozenset(names)
118 
119 
120def parse_locked_versions(uv_lock: str) -> dict[str, str]:
121 """Resolved version per package from a ``uv.lock``.
122 
123 Reads the ``[[package]]`` array, keying on the PEP 503-normalized name. The
124 root project and any entry without a ``version`` are simply absent from the
125 map; callers look up direct dependencies by name, so extras are harmless.
126 Malformed TOML yields an empty map.
127 """
128 try:
129 data = tomllib.loads(uv_lock)
130 except tomllib.TOMLDecodeError:
131 return {}
132 versions: dict[str, str] = {}
133 packages = data.get("package")
134 if not isinstance(packages, list):
135 return versions
136 for package in packages:
137 if not isinstance(package, dict):
138 continue
139 name = package.get("name")
140 version = package.get("version")
141 if isinstance(name, str) and isinstance(version, str):
142 versions[normalize_name(name)] = version
143 return versions
144 
145 
146def _has_installable_file(files: object) -> bool:
147 """True if a PyPI release has at least one non-yanked distribution file."""
148 if not isinstance(files, list) or not files:
149 return False
150 return any(
151 not (isinstance(file, dict) and file.get("yanked", False))
152 for file in files
153 )
154 
155 
156def parse_available_versions(payload: str) -> tuple[Version, ...]:
157 """Parse a PyPI ``/pypi/<name>/json`` body into domain versions.
158 
159 Reads the ``releases`` map, dropping any release that is fully yanked or
160 ships no files (so a yanked patch is never proposed) and any version string
161 that is not a clean semver (prereleases and PEP 440 oddities fall out here).
162 Empty or non-JSON input yields ``()``.
163 """
164 if not payload.strip():
165 return ()
166 try:
167 data = json.loads(payload)
168 except json.JSONDecodeError:
169 return ()
170 releases = data.get("releases") if isinstance(data, dict) else None
171 if not isinstance(releases, dict):
172 return ()
173 versions: list[Version] = []
174 for raw, files in releases.items():
175 if not (isinstance(raw, str) and _has_installable_file(files)):
176 continue
177 match Version.parse(raw):
178 case Ok(version):
179 versions.append(version)
180 case _:
181 continue
182 return tuple(versions)
183 
184 
185async def _available_versions(
186 client: httpx.AsyncClient, name: str
187) -> tuple[Version, ...]:
188 """Fetch a package's published versions from PyPI (best-effort)."""
189 try:
190 response = await client.get(f"{_PYPI_JSON}/{name}/json")
191 except httpx.HTTPError:
192 # A network error means "no known upgrades for this package" โ€” the scan
193 # proposes nothing for it rather than failing the whole run.
194 return ()
195 if response.status_code != 200:
196 return ()
197 return parse_available_versions(response.text)
198 
199 
200class UvPackageManager:
201 """A :class:`~froot.ports.protocols.PackageManager` backed by ``uv``."""
202 
203 async def list_upgrades(
204 self, target: TargetRepo, workspace: Path
205 ) -> tuple[AvailableUpgrade, ...]:
206 """Report each direct dependency and the versions available to it."""
207 direct = parse_direct_dependencies(
208 (workspace / "pyproject.toml").read_text()
209 )
210 lock_path = workspace / "uv.lock"
211 locked = (
212 parse_locked_versions(lock_path.read_text())
213 if lock_path.exists()
214 else {}
215 )
216 upgrades: list[AvailableUpgrade] = []
217 async with httpx.AsyncClient(
218 timeout=_TIMEOUT, follow_redirects=True
219 ) as client:
220 for name in sorted(direct):
221 current_text = locked.get(name)
222 if current_text is None:
223 continue
224 match Version.parse(current_text):
225 case Ok(current):
226 pass
227 case _:
228 continue
229 upgrades.append(
230 AvailableUpgrade(
231 package=name,
232 ecosystem=target.ecosystem,
233 current=current,
234 available=await _available_versions(client, name),
235 )
236 )
237 return tuple(upgrades)
238 
239 async def apply_patch_bump(
240 self, candidate: PatchCandidate, workspace: Path
241 ) -> None:
242 """Regenerate ``uv.lock`` at the target version (lockfile-only)."""
243 code, out = await run_text(
244 "uv",
245 "lock",
246 "--upgrade-package",
247 f"{candidate.package}=={candidate.target}",
248 cwd=workspace,
249 )
250 if code != 0:
251 raise RuntimeError(f"uv lock failed ({code}): {out}")
src/froot/adapters/npm.py ยท 162 lines
src/froot/adapters/npm.py162 lines ยท Python
โ‹ฏ 148 lines hidden (lines 1โ€“148)
1"""The npm package-manager adapter.
2 
3Reads upgrade availability from the committed manifest + lockfile (no install
4needed) plus ``npm view <pkg> versions``, and regenerates the manifest +
5lockfile with ``npm install <pkg>@<target> --package-lock-only
6--ignore-scripts`` โ€” lockfile-only, no install scripts, so no third-party
7dependency code ever runs in the worker (the real install + tests happen in the
8repo's CI, the oracle).
9 
10The installed baseline comes from ``package-lock.json``, *not* ``npm
11outdated``: ``npm outdated``'s ``current`` field is absent without a
12``node_modules`` tree, and froot only does a clone (never an install). The
13parsing is split into pure module functions so it is unit-tested with fixtures,
14away from the subprocess and the network.
15"""
16 
17from __future__ import annotations
18 
19import json
20from typing import TYPE_CHECKING
21 
22from froot.adapters._proc import run_text
23from froot.domain.candidate import AvailableUpgrade
24from froot.domain.version import Version
25from froot.result import Ok
26 
27if TYPE_CHECKING:
28 from pathlib import Path
29 
30 from froot.domain.candidate import PatchCandidate
31 from froot.domain.repo import TargetRepo
32 
33_NODE_MODULES = "node_modules/"
34 
35 
36def parse_direct_dependencies(package_json: str) -> frozenset[str]:
37 """The direct dependency names from a ``package.json`` (deps + devDeps).
38 
39 Only direct dependencies are bumped: ``npm install <pkg>`` on a transitive
40 dependency would promote it to a direct one, which is not a patch.
41 """
42 data = json.loads(package_json)
43 names: set[str] = set()
44 if isinstance(data, dict):
45 for field in ("dependencies", "devDependencies"):
46 section = data.get(field)
47 if isinstance(section, dict):
48 names.update(name for name in section if isinstance(name, str))
49 return frozenset(names)
50 
51 
52def parse_locked_versions(package_lock: str) -> dict[str, str]:
53 """Resolved version per top-level dependency from a ``package-lock.json``.
54 
55 Reads the lockfileVersion 2/3 ``packages`` map (keys
56 ``node_modules/<name>``, skipping nested ``.../node_modules/...`` transitive
57 entries), falling back to the legacy v1 top-level ``dependencies`` map.
58 """
59 data = json.loads(package_lock)
60 if not isinstance(data, dict):
61 return {}
62 versions: dict[str, str] = {}
63 packages = data.get("packages")
64 if isinstance(packages, dict):
65 for key, info in packages.items():
66 if not (isinstance(key, str) and key.startswith(_NODE_MODULES)):
67 continue
68 name = key[len(_NODE_MODULES) :]
69 if "/node_modules/" in name: # a nested (transitive) entry
70 continue
71 version = info.get("version") if isinstance(info, dict) else None
72 if isinstance(version, str):
73 versions[name] = version
74 if versions:
75 return versions
76 legacy = data.get("dependencies") # lockfileVersion 1
77 if isinstance(legacy, dict):
78 for name, info in legacy.items():
79 version = info.get("version") if isinstance(info, dict) else None
80 if isinstance(name, str) and isinstance(version, str):
81 versions[name] = version
82 return versions
83 
84 
85def parse_versions(stdout: str) -> tuple[Version, ...]:
86 """Parse ``npm view <pkg> versions --json`` into domain versions.
87 
88 Accepts a JSON array (the usual case) or a bare JSON string (a single
89 version); empty or non-JSON output (e.g. a failed lookup) yields ``()``.
90 Unparseable entries are dropped.
91 """
92 if not stdout.strip():
93 return ()
94 try:
95 raw = json.loads(stdout)
96 except json.JSONDecodeError:
97 return ()
98 items = raw if isinstance(raw, list) else [raw]
99 versions: list[Version] = []
100 for item in items:
101 if isinstance(item, str):
102 match Version.parse(item):
103 case Ok(version):
104 versions.append(version)
105 case _:
106 continue
107 return tuple(versions)
108 
109 
110class NpmPackageManager:
111 """A :class:`~froot.ports.protocols.PackageManager` backed by ``npm``."""
112 
113 async def list_upgrades(
114 self, target: TargetRepo, workspace: Path
115 ) -> tuple[AvailableUpgrade, ...]:
116 """Report each direct dependency and the versions available to it."""
117 direct = parse_direct_dependencies(
118 (workspace / "package.json").read_text()
119 )
120 lock_path = workspace / "package-lock.json"
121 locked = (
122 parse_locked_versions(lock_path.read_text())
123 if lock_path.exists()
124 else {}
125 )
126 upgrades: list[AvailableUpgrade] = []
127 for name in sorted(direct):
128 current_text = locked.get(name)
129 if current_text is None:
130 continue
131 match Version.parse(current_text):
132 case Ok(current):
133 pass
134 case _:
135 continue
136 _, versions_out = await run_text(
137 "npm", "view", name, "versions", "--json", cwd=workspace
138 )
139 upgrades.append(
140 AvailableUpgrade(
141 package=name,
142 ecosystem=target.ecosystem,
143 current=current,
144 available=parse_versions(versions_out),
145 )
146 )
147 return tuple(upgrades)
148 
149 async def apply_patch_bump(
150 self, candidate: PatchCandidate, workspace: Path
151 ) -> None:
152 """Rewrite the manifest + lockfile to the target (lockfile-only)."""
153 code, out = await run_text(
154 "npm",
155 "install",
156 f"{candidate.package}@{candidate.target}",
157 "--package-lock-only",
158 "--ignore-scripts",
159 cwd=workspace,
160 )
161 if code != 0:
162 raise RuntimeError(f"npm install failed ({code}): {out}")

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.

The uv adapter3๐ŸŽฌ Spotlight the testable core

Pure parsers, a thin shell

src/froot/adapters/uv.py

The 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.

pyproject.toml

uv.lock

๐Ÿ PyPI JSON API

parse_direct_dependencies
PEP 621 + PEP 735 names

parse_locked_versions
current version per dep

parse_available_versions
drop yanked + non-semver

AvailableUpgrade[]
(raw facts)

select_patch_candidates
highest stable patch
(pure, shared)

uv lock --upgrade-package
pkg==target (lockfile-only)

The adapter gathers three facts โ€” direct names, current versions, available versions โ€” into raw AvailableUpgrades. The shared pure policy picks the patch; the adapter pins it.

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
src/froot/adapters/uv.py251 lines ยท Python
โ‹ฏ 65 lines hidden (lines 1โ€“65)
1"""The uv (Python) package-manager adapter.
2 
3Reads upgrade availability from the committed manifest + lockfile (no install
4needed) plus the PyPI registry, and regenerates the lockfile with ``uv lock
5--upgrade-package <pkg>==<target>`` โ€” lockfile-only, so the real install, build,
6and tests happen in the repo's CI (the oracle), never in the worker.
7 
8Three boundary facts, each parsed by a pure, fixture-tested function:
9 
10* **Direct dependencies** come from ``pyproject.toml`` (PEP 621
11 ``[project.dependencies]`` / ``[project.optional-dependencies]`` and PEP 735
12 ``[dependency-groups]``). Only direct dependencies are bumped; a transitive
13 one would be promoted to direct, which is not a patch.
14* **The installed baseline** comes from ``uv.lock`` (its ``[[package]]``
15 entries), *not* from an installed environment: froot only does a clone, never
16 a ``uv sync``. Names are PEP 503-normalized so the manifest and lock agree
17 (``Pydantic-Settings`` == ``pydantic-settings``).
18* **The available versions** come from the PyPI JSON API. uv has no
19 "list every version" command (its ``pip`` subcommands inspect an installed
20 environment only), so this is the registry query that mirrors ``npm view``.
21 
22Two deliberate scope notes, both conservative โ€” froot proposes *fewer* Python
23bumps, never a wrong one:
24 
25* Versions use the shared semver :class:`~froot.domain.version.Version`, so
26 PEP 440 forms outside ``X.Y.Z`` (epochs, two- or four-segment releases,
27 ``post``/``dev`` releases, non-``-`` prereleases) are skipped. The common
28 ``X.Y.Z -> X.Y.(Z+1)`` patch โ€” the loop's whole job โ€” round-trips cleanly.
29* ``uv lock`` only touches ``uv.lock``; it does not edit ``pyproject.toml``. A
30 patch within the existing constraint (``>=`` / ``~=`` / unbounded) needs no
31 manifest change and ``uv sync --frozen`` accepts the pair. A dependency
32 pinned exactly (``pkg==1.2.3``) cannot be patched lockfile-only โ€” ``uv lock``
33 errors, which :meth:`UvPackageManager.apply_patch_bump` surfaces.
34"""
35 
36from __future__ import annotations
37 
38import json
39import re
40import tomllib
41from typing import TYPE_CHECKING
42 
43import httpx
44 
45from froot.adapters._proc import run_text
46from froot.domain.candidate import AvailableUpgrade
47from froot.domain.version import Version
48from froot.result import Ok
49 
50if TYPE_CHECKING:
51 from pathlib import Path
52 
53 from froot.domain.candidate import PatchCandidate
54 from froot.domain.repo import TargetRepo
55 
56_PYPI_JSON = "https://pypi.org/pypi"
57_TIMEOUT = 15.0
58 
59# The leading distribution name of a PEP 508 requirement (before any extras,
60# version specifier, or environment marker).
61_REQUIREMENT_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*")
62# PEP 503 separators: runs of '.', '-', '_' collapse to a single '-'.
63_NAME_SEPARATORS = re.compile(r"[-_.]+")
64 
65 
66def normalize_name(name: str) -> str:
67 """PEP 503-normalize a project name (lowercase; ``.-_`` runs -> ``-``)."""
68 return _NAME_SEPARATORS.sub("-", name).strip("-").lower()
69 
70 
71def _requirement_name(requirement: str) -> str | None:
72 """The normalized distribution name of a PEP 508 requirement string."""
73 match = _REQUIREMENT_NAME.match(requirement.strip())
74 return normalize_name(match.group(0)) if match else None
โ‹ฏ 177 lines hidden (lines 75โ€“251)
75 
76 
77def _collect_requirements(requirements: object, into: set[str]) -> None:
78 """Add the normalized names from a list of requirement strings."""
79 if not isinstance(requirements, list):
80 return
81 for requirement in requirements:
82 # PEP 735 groups may hold ``{include-group = ...}`` tables; the group
83 # they reference is collected on its own pass, so non-strings are
84 # skipped here.
85 if not isinstance(requirement, str):
86 continue
87 name = _requirement_name(requirement)
88 if name:
89 into.add(name)
90 
91 
92def parse_direct_dependencies(pyproject: str) -> frozenset[str]:
93 """The direct dependency names declared in a ``pyproject.toml``.
94 
95 Reads PEP 621 ``[project.dependencies]`` and every
96 ``[project.optional-dependencies]`` group, plus PEP 735
97 ``[dependency-groups]`` (e.g. uv's ``dev`` group). Names are PEP
98 503-normalized. Malformed TOML yields an empty set (a boundary concern,
99 handled like any other unparseable input).
100 """
101 try:
102 data = tomllib.loads(pyproject)
103 except tomllib.TOMLDecodeError:
104 return frozenset()
105 names: set[str] = set()
106 project = data.get("project")
107 if isinstance(project, dict):
108 _collect_requirements(project.get("dependencies"), names)
109 optional = project.get("optional-dependencies")
110 if isinstance(optional, dict):
111 for group in optional.values():
112 _collect_requirements(group, names)
113 groups = data.get("dependency-groups")
114 if isinstance(groups, dict):
115 for group in groups.values():
116 _collect_requirements(group, names)
117 return frozenset(names)
118 
119 
120def parse_locked_versions(uv_lock: str) -> dict[str, str]:
121 """Resolved version per package from a ``uv.lock``.
122 
123 Reads the ``[[package]]`` array, keying on the PEP 503-normalized name. The
124 root project and any entry without a ``version`` are simply absent from the
125 map; callers look up direct dependencies by name, so extras are harmless.
126 Malformed TOML yields an empty map.
127 """
128 try:
129 data = tomllib.loads(uv_lock)
130 except tomllib.TOMLDecodeError:
131 return {}
132 versions: dict[str, str] = {}
133 packages = data.get("package")
134 if not isinstance(packages, list):
135 return versions
136 for package in packages:
137 if not isinstance(package, dict):
138 continue
139 name = package.get("name")
140 version = package.get("version")
141 if isinstance(name, str) and isinstance(version, str):
142 versions[normalize_name(name)] = version
143 return versions
144 
145 
146def _has_installable_file(files: object) -> bool:
147 """True if a PyPI release has at least one non-yanked distribution file."""
148 if not isinstance(files, list) or not files:
149 return False
150 return any(
151 not (isinstance(file, dict) and file.get("yanked", False))
152 for file in files
153 )
154 
155 
156def parse_available_versions(payload: str) -> tuple[Version, ...]:
157 """Parse a PyPI ``/pypi/<name>/json`` body into domain versions.
158 
159 Reads the ``releases`` map, dropping any release that is fully yanked or
160 ships no files (so a yanked patch is never proposed) and any version string
161 that is not a clean semver (prereleases and PEP 440 oddities fall out here).
162 Empty or non-JSON input yields ``()``.
163 """
164 if not payload.strip():
165 return ()
166 try:
167 data = json.loads(payload)
168 except json.JSONDecodeError:
169 return ()
170 releases = data.get("releases") if isinstance(data, dict) else None
171 if not isinstance(releases, dict):
172 return ()
173 versions: list[Version] = []
174 for raw, files in releases.items():
175 if not (isinstance(raw, str) and _has_installable_file(files)):
176 continue
177 match Version.parse(raw):
178 case Ok(version):
179 versions.append(version)
180 case _:
181 continue
182 return tuple(versions)
183 
184 
185async def _available_versions(
186 client: httpx.AsyncClient, name: str
187) -> tuple[Version, ...]:
188 """Fetch a package's published versions from PyPI (best-effort)."""
189 try:
190 response = await client.get(f"{_PYPI_JSON}/{name}/json")
191 except httpx.HTTPError:
192 # A network error means "no known upgrades for this package" โ€” the scan
193 # proposes nothing for it rather than failing the whole run.
194 return ()
195 if response.status_code != 200:
196 return ()
197 return parse_available_versions(response.text)
198 
199 
200class UvPackageManager:
201 """A :class:`~froot.ports.protocols.PackageManager` backed by ``uv``."""
202 
203 async def list_upgrades(
204 self, target: TargetRepo, workspace: Path
205 ) -> tuple[AvailableUpgrade, ...]:
206 """Report each direct dependency and the versions available to it."""
207 direct = parse_direct_dependencies(
208 (workspace / "pyproject.toml").read_text()
209 )
210 lock_path = workspace / "uv.lock"
211 locked = (
212 parse_locked_versions(lock_path.read_text())
213 if lock_path.exists()
214 else {}
215 )
216 upgrades: list[AvailableUpgrade] = []
217 async with httpx.AsyncClient(
218 timeout=_TIMEOUT, follow_redirects=True
219 ) as client:
220 for name in sorted(direct):
221 current_text = locked.get(name)
222 if current_text is None:
223 continue
224 match Version.parse(current_text):
225 case Ok(current):
226 pass
227 case _:
228 continue
229 upgrades.append(
230 AvailableUpgrade(
231 package=name,
232 ecosystem=target.ecosystem,
233 current=current,
234 available=await _available_versions(client, name),
235 )
236 )
237 return tuple(upgrades)
238 
239 async def apply_patch_bump(
240 self, candidate: PatchCandidate, workspace: Path
241 ) -> None:
242 """Regenerate ``uv.lock`` at the target version (lockfile-only)."""
243 code, out = await run_text(
244 "uv",
245 "lock",
246 "--upgrade-package",
247 f"{candidate.package}=={candidate.target}",
248 cwd=workspace,
249 )
250 if code != 0:
251 raise RuntimeError(f"uv lock failed ({code}): {out}")

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
src/froot/adapters/uv.py251 lines ยท Python
โ‹ฏ 91 lines hidden (lines 1โ€“91)
1"""The uv (Python) package-manager adapter.
2 
3Reads upgrade availability from the committed manifest + lockfile (no install
4needed) plus the PyPI registry, and regenerates the lockfile with ``uv lock
5--upgrade-package <pkg>==<target>`` โ€” lockfile-only, so the real install, build,
6and tests happen in the repo's CI (the oracle), never in the worker.
7 
8Three boundary facts, each parsed by a pure, fixture-tested function:
9 
10* **Direct dependencies** come from ``pyproject.toml`` (PEP 621
11 ``[project.dependencies]`` / ``[project.optional-dependencies]`` and PEP 735
12 ``[dependency-groups]``). Only direct dependencies are bumped; a transitive
13 one would be promoted to direct, which is not a patch.
14* **The installed baseline** comes from ``uv.lock`` (its ``[[package]]``
15 entries), *not* from an installed environment: froot only does a clone, never
16 a ``uv sync``. Names are PEP 503-normalized so the manifest and lock agree
17 (``Pydantic-Settings`` == ``pydantic-settings``).
18* **The available versions** come from the PyPI JSON API. uv has no
19 "list every version" command (its ``pip`` subcommands inspect an installed
20 environment only), so this is the registry query that mirrors ``npm view``.
21 
22Two deliberate scope notes, both conservative โ€” froot proposes *fewer* Python
23bumps, never a wrong one:
24 
25* Versions use the shared semver :class:`~froot.domain.version.Version`, so
26 PEP 440 forms outside ``X.Y.Z`` (epochs, two- or four-segment releases,
27 ``post``/``dev`` releases, non-``-`` prereleases) are skipped. The common
28 ``X.Y.Z -> X.Y.(Z+1)`` patch โ€” the loop's whole job โ€” round-trips cleanly.
29* ``uv lock`` only touches ``uv.lock``; it does not edit ``pyproject.toml``. A
30 patch within the existing constraint (``>=`` / ``~=`` / unbounded) needs no
31 manifest change and ``uv sync --frozen`` accepts the pair. A dependency
32 pinned exactly (``pkg==1.2.3``) cannot be patched lockfile-only โ€” ``uv lock``
33 errors, which :meth:`UvPackageManager.apply_patch_bump` surfaces.
34"""
35 
36from __future__ import annotations
37 
38import json
39import re
40import tomllib
41from typing import TYPE_CHECKING
42 
43import httpx
44 
45from froot.adapters._proc import run_text
46from froot.domain.candidate import AvailableUpgrade
47from froot.domain.version import Version
48from froot.result import Ok
49 
50if TYPE_CHECKING:
51 from pathlib import Path
52 
53 from froot.domain.candidate import PatchCandidate
54 from froot.domain.repo import TargetRepo
55 
56_PYPI_JSON = "https://pypi.org/pypi"
57_TIMEOUT = 15.0
58 
59# The leading distribution name of a PEP 508 requirement (before any extras,
60# version specifier, or environment marker).
61_REQUIREMENT_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*")
62# PEP 503 separators: runs of '.', '-', '_' collapse to a single '-'.
63_NAME_SEPARATORS = re.compile(r"[-_.]+")
64 
65 
66def normalize_name(name: str) -> str:
67 """PEP 503-normalize a project name (lowercase; ``.-_`` runs -> ``-``)."""
68 return _NAME_SEPARATORS.sub("-", name).strip("-").lower()
69 
70 
71def _requirement_name(requirement: str) -> str | None:
72 """The normalized distribution name of a PEP 508 requirement string."""
73 match = _REQUIREMENT_NAME.match(requirement.strip())
74 return normalize_name(match.group(0)) if match else None
75 
76 
77def _collect_requirements(requirements: object, into: set[str]) -> None:
78 """Add the normalized names from a list of requirement strings."""
79 if not isinstance(requirements, list):
80 return
81 for requirement in requirements:
82 # PEP 735 groups may hold ``{include-group = ...}`` tables; the group
83 # they reference is collected on its own pass, so non-strings are
84 # skipped here.
85 if not isinstance(requirement, str):
86 continue
87 name = _requirement_name(requirement)
88 if name:
89 into.add(name)
90 
91 
92def parse_direct_dependencies(pyproject: str) -> frozenset[str]:
93 """The direct dependency names declared in a ``pyproject.toml``.
94 
95 Reads PEP 621 ``[project.dependencies]`` and every
96 ``[project.optional-dependencies]`` group, plus PEP 735
97 ``[dependency-groups]`` (e.g. uv's ``dev`` group). Names are PEP
98 503-normalized. Malformed TOML yields an empty set (a boundary concern,
99 handled like any other unparseable input).
100 """
101 try:
102 data = tomllib.loads(pyproject)
103 except tomllib.TOMLDecodeError:
104 return frozenset()
105 names: set[str] = set()
106 project = data.get("project")
107 if isinstance(project, dict):
108 _collect_requirements(project.get("dependencies"), names)
109 optional = project.get("optional-dependencies")
110 if isinstance(optional, dict):
111 for group in optional.values():
112 _collect_requirements(group, names)
113 groups = data.get("dependency-groups")
114 if isinstance(groups, dict):
115 for group in groups.values():
116 _collect_requirements(group, names)
117 return frozenset(names)
โ‹ฏ 134 lines hidden (lines 118โ€“251)
118 
119 
120def parse_locked_versions(uv_lock: str) -> dict[str, str]:
121 """Resolved version per package from a ``uv.lock``.
122 
123 Reads the ``[[package]]`` array, keying on the PEP 503-normalized name. The
124 root project and any entry without a ``version`` are simply absent from the
125 map; callers look up direct dependencies by name, so extras are harmless.
126 Malformed TOML yields an empty map.
127 """
128 try:
129 data = tomllib.loads(uv_lock)
130 except tomllib.TOMLDecodeError:
131 return {}
132 versions: dict[str, str] = {}
133 packages = data.get("package")
134 if not isinstance(packages, list):
135 return versions
136 for package in packages:
137 if not isinstance(package, dict):
138 continue
139 name = package.get("name")
140 version = package.get("version")
141 if isinstance(name, str) and isinstance(version, str):
142 versions[normalize_name(name)] = version
143 return versions
144 
145 
146def _has_installable_file(files: object) -> bool:
147 """True if a PyPI release has at least one non-yanked distribution file."""
148 if not isinstance(files, list) or not files:
149 return False
150 return any(
151 not (isinstance(file, dict) and file.get("yanked", False))
152 for file in files
153 )
154 
155 
156def parse_available_versions(payload: str) -> tuple[Version, ...]:
157 """Parse a PyPI ``/pypi/<name>/json`` body into domain versions.
158 
159 Reads the ``releases`` map, dropping any release that is fully yanked or
160 ships no files (so a yanked patch is never proposed) and any version string
161 that is not a clean semver (prereleases and PEP 440 oddities fall out here).
162 Empty or non-JSON input yields ``()``.
163 """
164 if not payload.strip():
165 return ()
166 try:
167 data = json.loads(payload)
168 except json.JSONDecodeError:
169 return ()
170 releases = data.get("releases") if isinstance(data, dict) else None
171 if not isinstance(releases, dict):
172 return ()
173 versions: list[Version] = []
174 for raw, files in releases.items():
175 if not (isinstance(raw, str) and _has_installable_file(files)):
176 continue
177 match Version.parse(raw):
178 case Ok(version):
179 versions.append(version)
180 case _:
181 continue
182 return tuple(versions)
183 
184 
185async def _available_versions(
186 client: httpx.AsyncClient, name: str
187) -> tuple[Version, ...]:
188 """Fetch a package's published versions from PyPI (best-effort)."""
189 try:
190 response = await client.get(f"{_PYPI_JSON}/{name}/json")
191 except httpx.HTTPError:
192 # A network error means "no known upgrades for this package" โ€” the scan
193 # proposes nothing for it rather than failing the whole run.
194 return ()
195 if response.status_code != 200:
196 return ()
197 return parse_available_versions(response.text)
198 
199 
200class UvPackageManager:
201 """A :class:`~froot.ports.protocols.PackageManager` backed by ``uv``."""
202 
203 async def list_upgrades(
204 self, target: TargetRepo, workspace: Path
205 ) -> tuple[AvailableUpgrade, ...]:
206 """Report each direct dependency and the versions available to it."""
207 direct = parse_direct_dependencies(
208 (workspace / "pyproject.toml").read_text()
209 )
210 lock_path = workspace / "uv.lock"
211 locked = (
212 parse_locked_versions(lock_path.read_text())
213 if lock_path.exists()
214 else {}
215 )
216 upgrades: list[AvailableUpgrade] = []
217 async with httpx.AsyncClient(
218 timeout=_TIMEOUT, follow_redirects=True
219 ) as client:
220 for name in sorted(direct):
221 current_text = locked.get(name)
222 if current_text is None:
223 continue
224 match Version.parse(current_text):
225 case Ok(current):
226 pass
227 case _:
228 continue
229 upgrades.append(
230 AvailableUpgrade(
231 package=name,
232 ecosystem=target.ecosystem,
233 current=current,
234 available=await _available_versions(client, name),
235 )
236 )
237 return tuple(upgrades)
238 
239 async def apply_patch_bump(
240 self, candidate: PatchCandidate, workspace: Path
241 ) -> None:
242 """Regenerate ``uv.lock`` at the target version (lockfile-only)."""
243 code, out = await run_text(
244 "uv",
245 "lock",
246 "--upgrade-package",
247 f"{candidate.package}=={candidate.target}",
248 cwd=workspace,
249 )
250 if code != 0:
251 raise RuntimeError(f"uv lock failed ({code}): {out}")

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
src/froot/adapters/uv.py251 lines ยท Python
โ‹ฏ 145 lines hidden (lines 1โ€“145)
1"""The uv (Python) package-manager adapter.
2 
3Reads upgrade availability from the committed manifest + lockfile (no install
4needed) plus the PyPI registry, and regenerates the lockfile with ``uv lock
5--upgrade-package <pkg>==<target>`` โ€” lockfile-only, so the real install, build,
6and tests happen in the repo's CI (the oracle), never in the worker.
7 
8Three boundary facts, each parsed by a pure, fixture-tested function:
9 
10* **Direct dependencies** come from ``pyproject.toml`` (PEP 621
11 ``[project.dependencies]`` / ``[project.optional-dependencies]`` and PEP 735
12 ``[dependency-groups]``). Only direct dependencies are bumped; a transitive
13 one would be promoted to direct, which is not a patch.
14* **The installed baseline** comes from ``uv.lock`` (its ``[[package]]``
15 entries), *not* from an installed environment: froot only does a clone, never
16 a ``uv sync``. Names are PEP 503-normalized so the manifest and lock agree
17 (``Pydantic-Settings`` == ``pydantic-settings``).
18* **The available versions** come from the PyPI JSON API. uv has no
19 "list every version" command (its ``pip`` subcommands inspect an installed
20 environment only), so this is the registry query that mirrors ``npm view``.
21 
22Two deliberate scope notes, both conservative โ€” froot proposes *fewer* Python
23bumps, never a wrong one:
24 
25* Versions use the shared semver :class:`~froot.domain.version.Version`, so
26 PEP 440 forms outside ``X.Y.Z`` (epochs, two- or four-segment releases,
27 ``post``/``dev`` releases, non-``-`` prereleases) are skipped. The common
28 ``X.Y.Z -> X.Y.(Z+1)`` patch โ€” the loop's whole job โ€” round-trips cleanly.
29* ``uv lock`` only touches ``uv.lock``; it does not edit ``pyproject.toml``. A
30 patch within the existing constraint (``>=`` / ``~=`` / unbounded) needs no
31 manifest change and ``uv sync --frozen`` accepts the pair. A dependency
32 pinned exactly (``pkg==1.2.3``) cannot be patched lockfile-only โ€” ``uv lock``
33 errors, which :meth:`UvPackageManager.apply_patch_bump` surfaces.
34"""
35 
36from __future__ import annotations
37 
38import json
39import re
40import tomllib
41from typing import TYPE_CHECKING
42 
43import httpx
44 
45from froot.adapters._proc import run_text
46from froot.domain.candidate import AvailableUpgrade
47from froot.domain.version import Version
48from froot.result import Ok
49 
50if TYPE_CHECKING:
51 from pathlib import Path
52 
53 from froot.domain.candidate import PatchCandidate
54 from froot.domain.repo import TargetRepo
55 
56_PYPI_JSON = "https://pypi.org/pypi"
57_TIMEOUT = 15.0
58 
59# The leading distribution name of a PEP 508 requirement (before any extras,
60# version specifier, or environment marker).
61_REQUIREMENT_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*")
62# PEP 503 separators: runs of '.', '-', '_' collapse to a single '-'.
63_NAME_SEPARATORS = re.compile(r"[-_.]+")
64 
65 
66def normalize_name(name: str) -> str:
67 """PEP 503-normalize a project name (lowercase; ``.-_`` runs -> ``-``)."""
68 return _NAME_SEPARATORS.sub("-", name).strip("-").lower()
69 
70 
71def _requirement_name(requirement: str) -> str | None:
72 """The normalized distribution name of a PEP 508 requirement string."""
73 match = _REQUIREMENT_NAME.match(requirement.strip())
74 return normalize_name(match.group(0)) if match else None
75 
76 
77def _collect_requirements(requirements: object, into: set[str]) -> None:
78 """Add the normalized names from a list of requirement strings."""
79 if not isinstance(requirements, list):
80 return
81 for requirement in requirements:
82 # PEP 735 groups may hold ``{include-group = ...}`` tables; the group
83 # they reference is collected on its own pass, so non-strings are
84 # skipped here.
85 if not isinstance(requirement, str):
86 continue
87 name = _requirement_name(requirement)
88 if name:
89 into.add(name)
90 
91 
92def parse_direct_dependencies(pyproject: str) -> frozenset[str]:
93 """The direct dependency names declared in a ``pyproject.toml``.
94 
95 Reads PEP 621 ``[project.dependencies]`` and every
96 ``[project.optional-dependencies]`` group, plus PEP 735
97 ``[dependency-groups]`` (e.g. uv's ``dev`` group). Names are PEP
98 503-normalized. Malformed TOML yields an empty set (a boundary concern,
99 handled like any other unparseable input).
100 """
101 try:
102 data = tomllib.loads(pyproject)
103 except tomllib.TOMLDecodeError:
104 return frozenset()
105 names: set[str] = set()
106 project = data.get("project")
107 if isinstance(project, dict):
108 _collect_requirements(project.get("dependencies"), names)
109 optional = project.get("optional-dependencies")
110 if isinstance(optional, dict):
111 for group in optional.values():
112 _collect_requirements(group, names)
113 groups = data.get("dependency-groups")
114 if isinstance(groups, dict):
115 for group in groups.values():
116 _collect_requirements(group, names)
117 return frozenset(names)
118 
119 
120def parse_locked_versions(uv_lock: str) -> dict[str, str]:
121 """Resolved version per package from a ``uv.lock``.
122 
123 Reads the ``[[package]]`` array, keying on the PEP 503-normalized name. The
124 root project and any entry without a ``version`` are simply absent from the
125 map; callers look up direct dependencies by name, so extras are harmless.
126 Malformed TOML yields an empty map.
127 """
128 try:
129 data = tomllib.loads(uv_lock)
130 except tomllib.TOMLDecodeError:
131 return {}
132 versions: dict[str, str] = {}
133 packages = data.get("package")
134 if not isinstance(packages, list):
135 return versions
136 for package in packages:
137 if not isinstance(package, dict):
138 continue
139 name = package.get("name")
140 version = package.get("version")
141 if isinstance(name, str) and isinstance(version, str):
142 versions[normalize_name(name)] = version
143 return versions
144 
145 
146def _has_installable_file(files: object) -> bool:
147 """True if a PyPI release has at least one non-yanked distribution file."""
148 if not isinstance(files, list) or not files:
149 return False
150 return any(
151 not (isinstance(file, dict) and file.get("yanked", False))
152 for file in files
153 )
154 
155 
156def parse_available_versions(payload: str) -> tuple[Version, ...]:
157 """Parse a PyPI ``/pypi/<name>/json`` body into domain versions.
158 
159 Reads the ``releases`` map, dropping any release that is fully yanked or
160 ships no files (so a yanked patch is never proposed) and any version string
161 that is not a clean semver (prereleases and PEP 440 oddities fall out here).
162 Empty or non-JSON input yields ``()``.
163 """
164 if not payload.strip():
165 return ()
166 try:
167 data = json.loads(payload)
168 except json.JSONDecodeError:
169 return ()
170 releases = data.get("releases") if isinstance(data, dict) else None
171 if not isinstance(releases, dict):
172 return ()
173 versions: list[Version] = []
174 for raw, files in releases.items():
175 if not (isinstance(raw, str) and _has_installable_file(files)):
176 continue
177 match Version.parse(raw):
178 case Ok(version):
179 versions.append(version)
180 case _:
181 continue
182 return tuple(versions)
โ‹ฏ 69 lines hidden (lines 183โ€“251)
183 
184 
185async def _available_versions(
186 client: httpx.AsyncClient, name: str
187) -> tuple[Version, ...]:
188 """Fetch a package's published versions from PyPI (best-effort)."""
189 try:
190 response = await client.get(f"{_PYPI_JSON}/{name}/json")
191 except httpx.HTTPError:
192 # A network error means "no known upgrades for this package" โ€” the scan
193 # proposes nothing for it rather than failing the whole run.
194 return ()
195 if response.status_code != 200:
196 return ()
197 return parse_available_versions(response.text)
198 
199 
200class UvPackageManager:
201 """A :class:`~froot.ports.protocols.PackageManager` backed by ``uv``."""
202 
203 async def list_upgrades(
204 self, target: TargetRepo, workspace: Path
205 ) -> tuple[AvailableUpgrade, ...]:
206 """Report each direct dependency and the versions available to it."""
207 direct = parse_direct_dependencies(
208 (workspace / "pyproject.toml").read_text()
209 )
210 lock_path = workspace / "uv.lock"
211 locked = (
212 parse_locked_versions(lock_path.read_text())
213 if lock_path.exists()
214 else {}
215 )
216 upgrades: list[AvailableUpgrade] = []
217 async with httpx.AsyncClient(
218 timeout=_TIMEOUT, follow_redirects=True
219 ) as client:
220 for name in sorted(direct):
221 current_text = locked.get(name)
222 if current_text is None:
223 continue
224 match Version.parse(current_text):
225 case Ok(current):
226 pass
227 case _:
228 continue
229 upgrades.append(
230 AvailableUpgrade(
231 package=name,
232 ecosystem=target.ecosystem,
233 current=current,
234 available=await _available_versions(client, name),
235 )
236 )
237 return tuple(upgrades)
238 
239 async def apply_patch_bump(
240 self, candidate: PatchCandidate, workspace: Path
241 ) -> None:
242 """Regenerate ``uv.lock`` at the target version (lockfile-only)."""
243 code, out = await run_text(
244 "uv",
245 "lock",
246 "--upgrade-package",
247 f"{candidate.package}=={candidate.target}",
248 cwd=workspace,
249 )
250 if code != 0:
251 raise RuntimeError(f"uv lock failed ({code}): {out}")

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
src/froot/adapters/uv.py251 lines ยท Python
โ‹ฏ 202 lines hidden (lines 1โ€“202)
1"""The uv (Python) package-manager adapter.
2 
3Reads upgrade availability from the committed manifest + lockfile (no install
4needed) plus the PyPI registry, and regenerates the lockfile with ``uv lock
5--upgrade-package <pkg>==<target>`` โ€” lockfile-only, so the real install, build,
6and tests happen in the repo's CI (the oracle), never in the worker.
7 
8Three boundary facts, each parsed by a pure, fixture-tested function:
9 
10* **Direct dependencies** come from ``pyproject.toml`` (PEP 621
11 ``[project.dependencies]`` / ``[project.optional-dependencies]`` and PEP 735
12 ``[dependency-groups]``). Only direct dependencies are bumped; a transitive
13 one would be promoted to direct, which is not a patch.
14* **The installed baseline** comes from ``uv.lock`` (its ``[[package]]``
15 entries), *not* from an installed environment: froot only does a clone, never
16 a ``uv sync``. Names are PEP 503-normalized so the manifest and lock agree
17 (``Pydantic-Settings`` == ``pydantic-settings``).
18* **The available versions** come from the PyPI JSON API. uv has no
19 "list every version" command (its ``pip`` subcommands inspect an installed
20 environment only), so this is the registry query that mirrors ``npm view``.
21 
22Two deliberate scope notes, both conservative โ€” froot proposes *fewer* Python
23bumps, never a wrong one:
24 
25* Versions use the shared semver :class:`~froot.domain.version.Version`, so
26 PEP 440 forms outside ``X.Y.Z`` (epochs, two- or four-segment releases,
27 ``post``/``dev`` releases, non-``-`` prereleases) are skipped. The common
28 ``X.Y.Z -> X.Y.(Z+1)`` patch โ€” the loop's whole job โ€” round-trips cleanly.
29* ``uv lock`` only touches ``uv.lock``; it does not edit ``pyproject.toml``. A
30 patch within the existing constraint (``>=`` / ``~=`` / unbounded) needs no
31 manifest change and ``uv sync --frozen`` accepts the pair. A dependency
32 pinned exactly (``pkg==1.2.3``) cannot be patched lockfile-only โ€” ``uv lock``
33 errors, which :meth:`UvPackageManager.apply_patch_bump` surfaces.
34"""
35 
36from __future__ import annotations
37 
38import json
39import re
40import tomllib
41from typing import TYPE_CHECKING
42 
43import httpx
44 
45from froot.adapters._proc import run_text
46from froot.domain.candidate import AvailableUpgrade
47from froot.domain.version import Version
48from froot.result import Ok
49 
50if TYPE_CHECKING:
51 from pathlib import Path
52 
53 from froot.domain.candidate import PatchCandidate
54 from froot.domain.repo import TargetRepo
55 
56_PYPI_JSON = "https://pypi.org/pypi"
57_TIMEOUT = 15.0
58 
59# The leading distribution name of a PEP 508 requirement (before any extras,
60# version specifier, or environment marker).
61_REQUIREMENT_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*")
62# PEP 503 separators: runs of '.', '-', '_' collapse to a single '-'.
63_NAME_SEPARATORS = re.compile(r"[-_.]+")
64 
65 
66def normalize_name(name: str) -> str:
67 """PEP 503-normalize a project name (lowercase; ``.-_`` runs -> ``-``)."""
68 return _NAME_SEPARATORS.sub("-", name).strip("-").lower()
69 
70 
71def _requirement_name(requirement: str) -> str | None:
72 """The normalized distribution name of a PEP 508 requirement string."""
73 match = _REQUIREMENT_NAME.match(requirement.strip())
74 return normalize_name(match.group(0)) if match else None
75 
76 
77def _collect_requirements(requirements: object, into: set[str]) -> None:
78 """Add the normalized names from a list of requirement strings."""
79 if not isinstance(requirements, list):
80 return
81 for requirement in requirements:
82 # PEP 735 groups may hold ``{include-group = ...}`` tables; the group
83 # they reference is collected on its own pass, so non-strings are
84 # skipped here.
85 if not isinstance(requirement, str):
86 continue
87 name = _requirement_name(requirement)
88 if name:
89 into.add(name)
90 
91 
92def parse_direct_dependencies(pyproject: str) -> frozenset[str]:
93 """The direct dependency names declared in a ``pyproject.toml``.
94 
95 Reads PEP 621 ``[project.dependencies]`` and every
96 ``[project.optional-dependencies]`` group, plus PEP 735
97 ``[dependency-groups]`` (e.g. uv's ``dev`` group). Names are PEP
98 503-normalized. Malformed TOML yields an empty set (a boundary concern,
99 handled like any other unparseable input).
100 """
101 try:
102 data = tomllib.loads(pyproject)
103 except tomllib.TOMLDecodeError:
104 return frozenset()
105 names: set[str] = set()
106 project = data.get("project")
107 if isinstance(project, dict):
108 _collect_requirements(project.get("dependencies"), names)
109 optional = project.get("optional-dependencies")
110 if isinstance(optional, dict):
111 for group in optional.values():
112 _collect_requirements(group, names)
113 groups = data.get("dependency-groups")
114 if isinstance(groups, dict):
115 for group in groups.values():
116 _collect_requirements(group, names)
117 return frozenset(names)
118 
119 
120def parse_locked_versions(uv_lock: str) -> dict[str, str]:
121 """Resolved version per package from a ``uv.lock``.
122 
123 Reads the ``[[package]]`` array, keying on the PEP 503-normalized name. The
124 root project and any entry without a ``version`` are simply absent from the
125 map; callers look up direct dependencies by name, so extras are harmless.
126 Malformed TOML yields an empty map.
127 """
128 try:
129 data = tomllib.loads(uv_lock)
130 except tomllib.TOMLDecodeError:
131 return {}
132 versions: dict[str, str] = {}
133 packages = data.get("package")
134 if not isinstance(packages, list):
135 return versions
136 for package in packages:
137 if not isinstance(package, dict):
138 continue
139 name = package.get("name")
140 version = package.get("version")
141 if isinstance(name, str) and isinstance(version, str):
142 versions[normalize_name(name)] = version
143 return versions
144 
145 
146def _has_installable_file(files: object) -> bool:
147 """True if a PyPI release has at least one non-yanked distribution file."""
148 if not isinstance(files, list) or not files:
149 return False
150 return any(
151 not (isinstance(file, dict) and file.get("yanked", False))
152 for file in files
153 )
154 
155 
156def parse_available_versions(payload: str) -> tuple[Version, ...]:
157 """Parse a PyPI ``/pypi/<name>/json`` body into domain versions.
158 
159 Reads the ``releases`` map, dropping any release that is fully yanked or
160 ships no files (so a yanked patch is never proposed) and any version string
161 that is not a clean semver (prereleases and PEP 440 oddities fall out here).
162 Empty or non-JSON input yields ``()``.
163 """
164 if not payload.strip():
165 return ()
166 try:
167 data = json.loads(payload)
168 except json.JSONDecodeError:
169 return ()
170 releases = data.get("releases") if isinstance(data, dict) else None
171 if not isinstance(releases, dict):
172 return ()
173 versions: list[Version] = []
174 for raw, files in releases.items():
175 if not (isinstance(raw, str) and _has_installable_file(files)):
176 continue
177 match Version.parse(raw):
178 case Ok(version):
179 versions.append(version)
180 case _:
181 continue
182 return tuple(versions)
183 
184 
185async def _available_versions(
186 client: httpx.AsyncClient, name: str
187) -> tuple[Version, ...]:
188 """Fetch a package's published versions from PyPI (best-effort)."""
189 try:
190 response = await client.get(f"{_PYPI_JSON}/{name}/json")
191 except httpx.HTTPError:
192 # A network error means "no known upgrades for this package" โ€” the scan
193 # proposes nothing for it rather than failing the whole run.
194 return ()
195 if response.status_code != 200:
196 return ()
197 return parse_available_versions(response.text)
198 
199 
200class UvPackageManager:
201 """A :class:`~froot.ports.protocols.PackageManager` backed by ``uv``."""
202 
203 async def list_upgrades(
204 self, target: TargetRepo, workspace: Path
205 ) -> tuple[AvailableUpgrade, ...]:
206 """Report each direct dependency and the versions available to it."""
207 direct = parse_direct_dependencies(
208 (workspace / "pyproject.toml").read_text()
209 )
210 lock_path = workspace / "uv.lock"
211 locked = (
212 parse_locked_versions(lock_path.read_text())
213 if lock_path.exists()
214 else {}
215 )
216 upgrades: list[AvailableUpgrade] = []
217 async with httpx.AsyncClient(
218 timeout=_TIMEOUT, follow_redirects=True
219 ) as client:
220 for name in sorted(direct):
221 current_text = locked.get(name)
222 if current_text is None:
223 continue
224 match Version.parse(current_text):
225 case Ok(current):
226 pass
227 case _:
228 continue
229 upgrades.append(
230 AvailableUpgrade(
231 package=name,
232 ecosystem=target.ecosystem,
233 current=current,
234 available=await _available_versions(client, name),
235 )
236 )
237 return tuple(upgrades)
โ‹ฏ 14 lines hidden (lines 238โ€“251)
238 
239 async def apply_patch_bump(
240 self, candidate: PatchCandidate, workspace: Path
241 ) -> None:
242 """Regenerate ``uv.lock`` at the target version (lockfile-only)."""
243 code, out = await run_text(
244 "uv",
245 "lock",
246 "--upgrade-package",
247 f"{candidate.package}=={candidate.target}",
248 cwd=workspace,
249 )
250 if code != 0:
251 raise RuntimeError(f"uv lock failed ({code}): {out}")
Concernnpm adapteruv adapter
direct depspackage.json deps + devDepspyproject.toml PEP 621 + PEP 735
current versionpackage-lock.jsonuv.lock [[package]]
available versionsnpm view <pkg> versionsPyPI JSON API
pin the bumpnpm install --package-lock-only --ignore-scriptsuv lock --upgrade-package pkg==target
who picks the targetselect_patch_candidates (shared)select_patch_candidates (shared)
The adapters are the same shape. Only the three loop-specific facts differ; the patch-selection policy is identical and unchanged.
The uv adapter4๐ŸŽฌ The decision and its evidence

Reuse semver, drop the rest

src/froot/domain/version.pysrc/froot/adapters/uv.py

Here 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
src/froot/domain/version.py123 lines ยท Python
โ‹ฏ 91 lines hidden (lines 1โ€“91)
1"""Semantic versions and the patch-bump relation.
2 
3:class:`Version` is the value object the whole loop turns on: the deterministic
4signal is "a higher patch of a dependency exists", and *patch* is a precise
5relation between two versions, encoded in :meth:`Version.is_patch_bump_of`.
6Because :class:`~froot.domain.candidate` enforces that relation at construction,
7a candidate that is *not* a patch bump is unrepresentable.
8 
9Parsing untrusted version strings is a boundary concern, so
10:meth:`Version.parse` returns a :class:`~froot.result.Result`, not an exception.
11"""
12 
13from __future__ import annotations
14 
15import re
16from functools import total_ordering
17 
18from froot.domain.base import Frozen
19from froot.result import Err, Ok, Result
20 
21# major.minor.patch with an optional -prerelease and an ignored +build segment.
22_SEMVER = re.compile(
23 r"""
24 ^\s*v? # tolerate a leading 'v'
25 (?P<major>0|[1-9]\d*)\.
26 (?P<minor>0|[1-9]\d*)\.
27 (?P<patch>0|[1-9]\d*)
28 (?:-(?P<prerelease>[0-9A-Za-z.-]+))?
29 (?:\+[0-9A-Za-z.-]+)? # build metadata: parsed away, not compared
30 \s*$
31 """,
32 re.VERBOSE,
34 
35 
36@total_ordering
37class Version(Frozen):
38 """A semantic version, ordered and comparable.
39 
40 Attributes:
41 major: The major component (breaking changes).
42 minor: The minor component (backward-compatible features).
43 patch: The patch component (backward-compatible fixes).
44 prerelease: The prerelease tag (e.g. ``rc.1``), or ``None`` for a
45 stable release. A prerelease sorts below its stable release.
46 """
47 
48 major: int
49 minor: int
50 patch: int
51 prerelease: str | None = None
52 
53 @classmethod
54 def parse(cls, text: str) -> Result[Version, str]:
55 """Parse a semantic-version string at the untrusted boundary.
56 
57 Args:
58 text: A version string such as ``"1.4.2"`` or ``"2.0.0-rc.1"``.
59 A leading ``v`` and ``+build`` metadata are tolerated.
60 
61 Returns:
62 ``Ok(Version)`` on success, or ``Err(message)`` if the string is
63 not a recognizable semantic version.
64 """
65 match = _SEMVER.match(text)
66 if match is None:
67 return Err(f"not a semantic version: {text!r}")
68 return Ok(
69 cls(
70 major=int(match["major"]),
71 minor=int(match["minor"]),
72 patch=int(match["patch"]),
73 prerelease=match["prerelease"],
74 )
75 )
76 
77 @property
78 def is_stable(self) -> bool:
79 """True when this is a stable release (no prerelease tag)."""
80 return self.prerelease is None
81 
82 @property
83 def _sort_key(self) -> tuple[int, int, int, int, str]:
84 # A stable release outranks its prereleases: (โ€ฆ, 1, "") > (โ€ฆ, 0, tag).
85 # Prerelease-vs-prerelease compares the tag lexically, NOT by SemVer ยง11
86 # numeric-identifier rules (so "rc.2" sorts after "rc.10"). That is
87 # deliberate: the patch loop only ever compares stable versions
88 # (is_patch_bump_of requires both ends stable), so this path is unused.
89 rank = 1 if self.prerelease is None else 0
90 return (self.major, self.minor, self.patch, rank, self.prerelease or "")
91 
92 def is_patch_bump_of(self, other: Version) -> bool:
93 """Whether this version is a clean patch-level upgrade of ``other``.
94 
95 A patch bump keeps the major and minor fixed, raises the patch, and is
96 stable on both ends. Prereleases are never clean patch bumps: a loop
97 that proposed ``1.4.2 -> 1.4.3-rc.1`` would be proposing instability.
98 
99 Args:
100 other: The currently installed version.
101 
102 Returns:
103 True iff ``self`` is the same ``major.minor`` as ``other`` with a
104 strictly higher, stable patch.
105 """
106 return (
107 self.is_stable
108 and other.is_stable
109 and self.major == other.major
110 and self.minor == other.minor
111 and self.patch > other.patch
112 )
โ‹ฏ 11 lines hidden (lines 113โ€“123)
113 
114 def __lt__(self, other: object) -> bool:
115 """Order by ``major.minor.patch`` then prerelease rank."""
116 if not isinstance(other, Version):
117 return NotImplemented
118 return self._sort_key < other._sort_key
119 
120 def __str__(self) -> str:
121 """Render as ``major.minor.patch[-prerelease]``."""
122 base = f"{self.major}.{self.minor}.{self.patch}"
123 return f"{base}-{self.prerelease}" if self.prerelease else base

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.

not X.Y.Z

clean semver

yes

none

every published release
(~70 for httpx, ~1500 for hypothesis)

drop fully-yanked / fileless

Version.parse
strict semver?

dropped, conservatively:
1.3.0rc1 ยท 0.63b1 ยท 1!2.0
2.0 ยท 1.2.3.4 ยท 1.2.post1

kept: 1.2.3 ยท 1.2.4 ยท 2.0.1 โ€ฆ

is_patch_bump_of(current)
same major.minor, higher, stable

highest stable patch
= the proposed target

no candidate
(propose nothing)

Every published version runs the same gauntlet. Non-semver forms are dropped at parse time; only a clean, stable, higher patch of the current version becomes a target.
Wiring it in5๐ŸŽฌ Counterfactual

One place to choose an adapter

src/froot/adapters/registry.pysrc/froot/workflow/activities.py

Before 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
src/froot/adapters/registry.py33 lines ยท Python
โ‹ฏ 21 lines hidden (lines 1โ€“21)
1"""Select the package-manager adapter for an ecosystem.
2 
3The chassis is ecosystem-agnostic; this is the single place that maps an
4:class:`~froot.domain.ecosystem.Ecosystem` to its concrete
5:class:`~froot.ports.protocols.PackageManager`. The imports are deferred into
6the ``match`` arms so resolving one ecosystem never imports another's adapter
7(or its HTTP stack), keeping the activities' lazy-import discipline intact, and
8``assert_never`` makes a newly added ecosystem fail to type-check here until it
9is wired in.
10"""
11 
12from __future__ import annotations
13 
14from typing import TYPE_CHECKING, assert_never
15 
16from froot.domain.ecosystem import Ecosystem
17 
18if TYPE_CHECKING:
19 from froot.ports.protocols import PackageManager
20 
21 
22def package_manager_for(ecosystem: Ecosystem) -> PackageManager:
23 """Return the package-manager adapter for ``ecosystem``."""
24 match ecosystem:
25 case Ecosystem.NPM:
26 from froot.adapters.npm import NpmPackageManager
27 
28 return NpmPackageManager()
29 case Ecosystem.UV:
30 from froot.adapters.uv import UvPackageManager
31 
32 return UvPackageManager()
33 assert_never(ecosystem)

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
src/froot/workflow/activities.py153 lines ยท Python
โ‹ฏ 46 lines hidden (lines 1โ€“46)
1"""Activities: the effect interpreters that wrap the adapters.
2 
3Each activity is the impure boundary for one effect. Adapter imports are LAZY
4(inside the bodies) so the model and HTTP stacks never enter a workflow sandbox;
5the pure policy and domain imports stay at module level. Activities return
6domain values โ€” the workflow wraps them into events and feeds the pure state
7machine. The activity signatures' types are evaluated at runtime by Temporal's
8data converter, so the domain imports here are deliberately not deferred.
9"""
10 
11from __future__ import annotations
12 
13import json
14import logging
15import tempfile
16from pathlib import Path
17 
18from temporalio import activity
19 
20from froot.domain.candidate import PatchCandidate
21from froot.domain.changelog import ChangelogVerdict, UnknownVerdict
22from froot.domain.ci import CIStatus
23from froot.domain.pull_request import PullRequestRef
24from froot.domain.repo import TargetRepo
25from froot.policy.candidates import select_patch_candidates
26from froot.policy.compose import PR_LABELS, pull_request_draft
27from froot.policy.naming import branch_name, bump_workflow_id
28from froot.workflow.types import (
29 CiCheckInput,
30 DispatchInput,
31 OpenPrInput,
32 RecordInput,
34 
35_log = logging.getLogger("froot.outcome")
36 
37 
38def _manifest_dir(target: TargetRepo, workspace: Path) -> Path:
39 """The directory the manifest lives in (a monorepo subdir, or the root)."""
40 return workspace / target.manifest_dir if target.manifest_dir else workspace
41 
42 
43@activity.defn
44async def scan_candidates(
45 target: TargetRepo,
46) -> tuple[PatchCandidate, ...]:
47 """Check out the repo, read available upgrades, select patch candidates."""
48 from froot.adapters.github import GitHubForge
49 from froot.adapters.registry import package_manager_for
50 
51 forge = GitHubForge()
52 package_manager = package_manager_for(target.ecosystem)
โ‹ฏ 23 lines hidden (lines 53โ€“75)
53 with tempfile.TemporaryDirectory() as tmp:
54 workspace = Path(tmp)
55 await forge.checkout(target, workspace)
56 upgrades = await package_manager.list_upgrades(
57 target, _manifest_dir(target, workspace)
58 )
59 return select_patch_candidates(upgrades)
60 
61 
62@activity.defn
63async def judge_changelog(candidate: PatchCandidate) -> ChangelogVerdict:
64 """Fetch the candidate's changelog and get the model's typed verdict."""
65 from froot.adapters.changelog_http import HttpChangelogSource
66 from froot.adapters.model_judge import PydanticAiJudge
67 
68 changelog = await HttpChangelogSource().fetch(candidate)
69 if changelog is None:
70 return UnknownVerdict(rationale="No changelog could be fetched.")
71 return await PydanticAiJudge().judge(changelog)
72 
73 
74@activity.defn
75async def open_pull_request(params: OpenPrInput) -> PullRequestRef:
76 """Regenerate manifest+lockfile and open (idempotently) the bump's PR."""
77 from froot.adapters.github import GitHubForge
78 from froot.adapters.registry import package_manager_for
79 
80 forge = GitHubForge()
81 package_manager = package_manager_for(params.target.ecosystem)
โ‹ฏ 72 lines hidden (lines 82โ€“153)
82 branch = branch_name(params.candidate)
83 existing = await forge.find_open_pull_request(params.target, branch)
84 if existing is not None:
85 return existing
86 draft = pull_request_draft(params.target, params.candidate, params.verdict)
87 with tempfile.TemporaryDirectory() as tmp:
88 workspace = Path(tmp)
89 await forge.checkout(params.target, workspace)
90 await package_manager.apply_patch_bump(
91 params.candidate, _manifest_dir(params.target, workspace)
92 )
93 await forge.push_branch(workspace, branch, draft.title)
94 return await forge.open_pull_request(params.target, draft)
95 
96 
97@activity.defn
98async def check_ci(params: CiCheckInput) -> CIStatus:
99 """Read the repo's CI status for the PR's head commit (the oracle)."""
100 from froot.adapters.github import GitHubForge
101 
102 return await GitHubForge().ci_status(params.target, params.head_sha)
103 
104 
105@activity.defn
106async def record_outcome(params: RecordInput) -> None:
107 """Label the PR and log the run telemetry โ€” the signal-update."""
108 from froot.adapters.github import GitHubForge
109 
110 outcome = params.outcome
111 await GitHubForge().add_labels(params.target, outcome.pr.number, PR_LABELS)
112 _log.info(
113 json.dumps(
114 {
115 "event": "loop_outcome",
116 "loop": "dependency-patch",
117 "repo": params.target.repo.slug,
118 "package": outcome.candidate.package,
119 "from": str(outcome.candidate.current),
120 "to": str(outcome.candidate.target),
121 "changelog": outcome.verdict.kind,
122 "ci": outcome.ci.kind,
123 "ci_passed": outcome.ci_passed,
124 "pr": outcome.pr.number,
125 "pr_url": outcome.pr.url,
126 }
127 )
128 )
129 
130 
131@activity.defn
132async def dispatch_bump(params: DispatchInput) -> None:
133 """Start the bump loop for a candidate (idempotent per bump identity)."""
134 from temporalio.common import WorkflowIDReusePolicy
135 from temporalio.exceptions import WorkflowAlreadyStartedError
136 
137 from froot.workflow.bump_workflow import BumpWorkflow
138 from froot.workflow.temporal_client import client, task_queue
139 from froot.workflow.types import BumpParams
140 
141 temporal = await client()
142 try:
143 await temporal.start_workflow(
144 BumpWorkflow.run,
145 BumpParams(target=params.target, candidate=params.candidate),
146 id=bump_workflow_id(params.target, params.candidate),
147 task_queue=task_queue(),
148 id_reuse_policy=WorkflowIDReusePolicy.REJECT_DUPLICATE,
149 )
150 except WorkflowAlreadyStartedError:
151 # This bump already has a loop (running or completed) โ€” a no-op, so
152 # re-scanning never opens a second PR for the same bump.
153 return
Reaching the loop6๐ŸŽฌ Trace + a gotcha the review caught

Config in, changelog out

src/froot/config/settings.pysrc/froot/adapters/changelog_http.py

Two 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
src/froot/config/settings.py138 lines ยท Python
โ‹ฏ 47 lines hidden (lines 1โ€“47)
1"""All deployment config, as pydantic-settings models (env or ``.env``), frozen.
2 
3* :class:`Settings` (``FROOT_*``) โ€” loop config: repositories and scan interval.
4* :class:`TemporalSettings` (``TEMPORAL_*``) โ€” connection: host / namespace /
5 task queue, shared by the worker, the scan starter, and the activity client.
6* :class:`GitHubSettings` โ€” the API token (``FROOT_GITHUB_TOKEN``) as a
7 :class:`~pydantic.SecretStr`, so it is masked in ``repr``, logs, and
8 tracebacks and cannot leak accidentally.
9* :class:`ModelSettings` (``FROOT_OLLAMA_MODEL`` / ``FROOT_OLLAMA_URL``) โ€” the
10 changelog-judge model endpoint.
11* :class:`TelemetrySettings` (``FROOT_OTEL``) โ€” observability toggle.
12 
13Each consumer builds the small model it needs at its point of use; nothing
14secret lives in the repo. ``repos`` is ``NoDecode`` so ``FROOT_REPOS`` is a
15comma-separated list of ``owner/name`` slugs rather than JSON; a slug may carry
16an optional ``@<ecosystem>`` suffix (e.g. ``acme/pylib@uv``), defaulting to npm.
17"""
18 
19from __future__ import annotations
20 
21from typing import Annotated
22 
23from pydantic import Field, SecretStr, field_validator
24from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
25 
26from froot.domain.ecosystem import Ecosystem
27from froot.domain.repo import RepoRef, TargetRepo
28from froot.result import Ok
29 
30_DEFAULT_SCAN_INTERVAL_SECONDS = 86_400
31 
32 
33class Settings(BaseSettings):
34 """Non-secret worker config from ``FROOT_*`` (env or ``.env``)."""
35 
36 model_config = SettingsConfigDict(
37 env_prefix="FROOT_",
38 env_file=".env",
39 extra="ignore",
40 frozen=True,
41 )
42 
43 repos: Annotated[tuple[TargetRepo, ...], NoDecode] = Field(min_length=1)
44 scan_interval_seconds: int = Field(
45 default=_DEFAULT_SCAN_INTERVAL_SECONDS, gt=0
46 )
47 
48 @field_validator("repos", mode="before")
49 @classmethod
50 def _parse_repos(cls, value: object) -> object:
51 """Parse ``FROOT_REPOS`` as a comma-separated target list.
52 
53 Each entry is an ``owner/name`` slug, optionally suffixed with
54 ``@<ecosystem>`` (e.g. ``acme/pylib@uv``); the suffix is omitted for the
55 default ``npm``.
56 """
57 if not isinstance(value, str):
58 return value
59 targets: list[TargetRepo] = []
60 for raw in value.split(","):
61 entry = raw.strip()
62 if not entry:
63 continue
64 slug, _, eco = entry.partition("@")
65 match RepoRef.parse(slug):
66 case Ok(ref):
67 pass
68 case _:
69 raise ValueError(f"invalid repo slug: {slug!r}")
70 try:
71 ecosystem = Ecosystem(eco) if eco else Ecosystem.NPM
72 except ValueError:
73 raise ValueError(f"unknown ecosystem: {eco!r}") from None
74 targets.append(TargetRepo(repo=ref, ecosystem=ecosystem))
75 return tuple(targets)
โ‹ฏ 63 lines hidden (lines 76โ€“138)
76 
77 
78class TemporalSettings(BaseSettings):
79 """Temporal connection config from ``TEMPORAL_*`` (env or ``.env``).
80 
81 The same image runs anywhere by configuring these; the in-cluster
82 deployment sets them to the cluster's frontend, namespace, and queue.
83 """
84 
85 model_config = SettingsConfigDict(
86 env_prefix="TEMPORAL_",
87 env_file=".env",
88 extra="ignore",
89 frozen=True,
90 )
91 
92 host: str = Field(default="localhost:7233", min_length=1)
93 namespace: str = Field(default="default", min_length=1)
94 task_queue: str = Field(default="froot", min_length=1)
95 
96 
97class GitHubSettings(BaseSettings):
98 """GitHub credentials, from ``FROOT_GITHUB_TOKEN``.
99 
100 The token is a :class:`~pydantic.SecretStr`, so it is masked in ``repr``,
101 logs, and tracebacks and cannot leak accidentally; call
102 ``github_token.get_secret_value()`` only where the real value is sent.
103 """
104 
105 model_config = SettingsConfigDict(
106 env_prefix="FROOT_", env_file=".env", extra="ignore", frozen=True
107 )
108 
109 github_token: SecretStr | None = None
110 
111 
112class ModelSettings(BaseSettings):
113 """The changelog-judge model endpoint (a local Ollama by default)."""
114 
115 model_config = SettingsConfigDict(
116 env_prefix="FROOT_", env_file=".env", extra="ignore", frozen=True
117 )
118 
119 ollama_model: str = Field(default="gemma4:e4b", min_length=1)
120 ollama_url: str = Field(default="http://localhost:11434/v1", min_length=1)
121 
122 
123class TelemetrySettings(BaseSettings):
124 """OpenTelemetry toggle โ€” off unless ``FROOT_OTEL`` is truthy."""
125 
126 model_config = SettingsConfigDict(
127 env_prefix="FROOT_", env_file=".env", extra="ignore", frozen=True
128 )
129 
130 otel: bool = False
131 
132 @field_validator("otel", mode="before")
133 @classmethod
134 def _blank_is_off(cls, value: object) -> object:
135 """Treat an empty/whitespace ``FROOT_OTEL`` as off, not an error."""
136 if isinstance(value, str) and not value.strip():
137 return False
138 return value

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
src/froot/adapters/changelog_http.py181 lines ยท Python
โ‹ฏ 87 lines hidden (lines 1โ€“87)
1"""Best-effort changelog source: registry metadata + GitHub release notes.
2 
3There is no universal changelog format, so this fetches the one cheap, reliable
4signal of *what changed*: the linked GitHub repo's release notes for the version
5tag. It returns ``None`` when there are none. A package's registry *description*
6is deliberately NOT used as a fallback โ€” it describes what the package does, not
7what changed between versions, and feeding it to the judge produces misleading
8risk verdicts. The judge activity treats ``None`` as an ``UnknownVerdict``
9without spending a model call (spine-heavy: never ask the model to assess a
10non-changelog).
11 
12The GitHub repo is discovered per ecosystem โ€” npm's ``repository.url`` or
13PyPI's ``info.project_urls`` / ``home_page`` โ€” and the release-notes fetch is
14shared from there, since both ecosystems tag GitHub releases the same way. The
15registry-URL parsing is pure and tested offline.
16"""
17 
18from __future__ import annotations
19 
20import json
21import re
22from typing import TYPE_CHECKING, Any, assert_never
23 
24import httpx
25 
26from froot.domain.changelog import Changelog
27from froot.domain.ecosystem import Ecosystem
28from froot.domain.repo import RepoRef
29from froot.result import Ok
30 
31if TYPE_CHECKING:
32 from froot.domain.candidate import PatchCandidate
33 
34_REGISTRY = "https://registry.npmjs.org"
35_PYPI_JSON = "https://pypi.org/pypi"
36_GITHUB_API = "https://api.github.com"
37# The name group stops at '.', '#', and '?' so a ``.git`` suffix, a ``#readme``
38# fragment, or a ``?tab=`` query never bleeds into the captured repo name.
39_GITHUB_URL = re.compile(r"github\.com[/:]([^/]+)/([^/.#?]+)")
40_TIMEOUT = 15.0
41# PyPI ``project_urls`` labels that name the source repo, preferred over a
42# homepage that may point somewhere other than the code. Deliberately the
43# source-*role* words only: "github" is a platform name, not a role, and would
44# wrongly promote a "GitHub Sponsors" funding link over the real "Source".
45_SOURCE_HINTS = ("source", "repository", "code")
46# First path segment of a github.com URL that is a reserved namespace, never a
47# repo owner โ€” so a funding/profile link like github.com/sponsors/<org> is not
48# mistaken for the repo ``sponsors/<org>`` (which would 404 and suppress a real
49# changelog).
50_RESERVED_OWNERS = frozenset(
51 {
52 "sponsors",
53 "orgs",
54 "users",
55 "marketplace",
56 "apps",
57 "topics",
58 "collections",
59 "about",
60 "settings",
61 "pricing",
62 "features",
63 }
65 
66 
67def _github_repo(url: str) -> RepoRef | None:
68 """Parse an ``owner/name`` GitHub repo out of a URL, if it is one."""
69 found = _GITHUB_URL.search(url)
70 if found is None or found.group(1).lower() in _RESERVED_OWNERS:
71 return None
72 match RepoRef.parse(f"{found.group(1)}/{found.group(2)}"):
73 case Ok(ref):
74 return ref
75 case _:
76 return None
77 
78 
79def github_repo_from_registry(metadata: Any) -> RepoRef | None:
80 """Extract the GitHub repo from npm registry ``repository.url``, if any."""
81 repository = (
82 metadata.get("repository") if isinstance(metadata, dict) else None
83 )
84 url = repository.get("url") if isinstance(repository, dict) else repository
85 return _github_repo(url) if isinstance(url, str) else None
86 
87 
88def _pypi_candidate_urls(info: Any) -> list[str]:
89 """PyPI ``info`` URLs, source/repo links first, then the homepage."""
90 project_urls = info.get("project_urls") if isinstance(info, dict) else None
91 preferred: list[str] = []
92 rest: list[str] = []
93 if isinstance(project_urls, dict):
94 for label, url in project_urls.items():
95 if not isinstance(url, str):
96 continue
97 key = label.lower() if isinstance(label, str) else ""
98 target = preferred if any(h in key for h in _SOURCE_HINTS) else rest
99 target.append(url)
100 home = info.get("home_page") if isinstance(info, dict) else None
101 if isinstance(home, str):
102 rest.append(home)
103 return preferred + rest
104 
105 
106def github_repo_from_pypi(metadata: Any) -> RepoRef | None:
107 """Extract the GitHub repo from PyPI ``info`` project URLs, if any."""
108 info = metadata.get("info") if isinstance(metadata, dict) else None
109 for url in _pypi_candidate_urls(info):
110 repo = _github_repo(url)
111 if repo is not None:
112 return repo
113 return None
โ‹ฏ 68 lines hidden (lines 114โ€“181)
114 
115 
116class HttpChangelogSource:
117 """A :class:`~froot.ports.protocols.ChangelogSource` over HTTP."""
118 
119 async def fetch(self, candidate: PatchCandidate) -> Changelog | None:
120 """Fetch the target version's changelog, or ``None`` (best-effort)."""
121 try:
122 async with httpx.AsyncClient(
123 timeout=_TIMEOUT, follow_redirects=True
124 ) as client:
125 return await self._fetch(client, candidate)
126 except (httpx.HTTPError, json.JSONDecodeError):
127 # Best-effort: a network error or a malformed 200 body both mean
128 # "no usable changelog", which the judge maps to UnknownVerdict.
129 return None
130 
131 async def _fetch(
132 self, client: httpx.AsyncClient, candidate: PatchCandidate
133 ) -> Changelog | None:
134 repo = await self._discover_repo(client, candidate)
135 if repo is None:
136 return None
137 target = str(candidate.target)
138 text = await self._release_notes(client, repo, target)
139 if text is None:
140 return None
141 return Changelog(
142 package=candidate.package,
143 version=candidate.target,
144 text=text,
145 source_url=f"https://github.com/{repo.slug}/releases/tag/v{target}",
146 )
147 
148 @staticmethod
149 async def _discover_repo(
150 client: httpx.AsyncClient, candidate: PatchCandidate
151 ) -> RepoRef | None:
152 """Find the package's GitHub repo from its registry metadata."""
153 match candidate.ecosystem:
154 case Ecosystem.NPM:
155 meta = await client.get(f"{_REGISTRY}/{candidate.package}")
156 resolve = github_repo_from_registry
157 case Ecosystem.UV:
158 meta = await client.get(
159 f"{_PYPI_JSON}/{candidate.package}/json"
160 )
161 resolve = github_repo_from_pypi
162 case _:
163 assert_never(candidate.ecosystem)
164 if meta.status_code != 200:
165 return None
166 return resolve(meta.json())
167 
168 @staticmethod
169 async def _release_notes(
170 client: httpx.AsyncClient, repo: RepoRef, target: str
171 ) -> str | None:
172 """GitHub release notes for the tag (``vX.Y.Z`` or ``X.Y.Z``)."""
173 for tag in (f"v{target}", target):
174 resp = await client.get(
175 f"{_GITHUB_API}/repos/{repo.slug}/releases/tags/{tag}"
176 )
177 if resp.status_code == 200:
178 body = resp.json().get("body")
179 if isinstance(body, str) and body.strip():
180 return body
181 return None
src/froot/adapters/changelog_http.py ยท 181 lines
src/froot/adapters/changelog_http.py181 lines ยท Python
โ‹ฏ 43 lines hidden (lines 1โ€“43)
1"""Best-effort changelog source: registry metadata + GitHub release notes.
2 
3There is no universal changelog format, so this fetches the one cheap, reliable
4signal of *what changed*: the linked GitHub repo's release notes for the version
5tag. It returns ``None`` when there are none. A package's registry *description*
6is deliberately NOT used as a fallback โ€” it describes what the package does, not
7what changed between versions, and feeding it to the judge produces misleading
8risk verdicts. The judge activity treats ``None`` as an ``UnknownVerdict``
9without spending a model call (spine-heavy: never ask the model to assess a
10non-changelog).
11 
12The GitHub repo is discovered per ecosystem โ€” npm's ``repository.url`` or
13PyPI's ``info.project_urls`` / ``home_page`` โ€” and the release-notes fetch is
14shared from there, since both ecosystems tag GitHub releases the same way. The
15registry-URL parsing is pure and tested offline.
16"""
17 
18from __future__ import annotations
19 
20import json
21import re
22from typing import TYPE_CHECKING, Any, assert_never
23 
24import httpx
25 
26from froot.domain.changelog import Changelog
27from froot.domain.ecosystem import Ecosystem
28from froot.domain.repo import RepoRef
29from froot.result import Ok
30 
31if TYPE_CHECKING:
32 from froot.domain.candidate import PatchCandidate
33 
34_REGISTRY = "https://registry.npmjs.org"
35_PYPI_JSON = "https://pypi.org/pypi"
36_GITHUB_API = "https://api.github.com"
37# The name group stops at '.', '#', and '?' so a ``.git`` suffix, a ``#readme``
38# fragment, or a ``?tab=`` query never bleeds into the captured repo name.
39_GITHUB_URL = re.compile(r"github\.com[/:]([^/]+)/([^/.#?]+)")
40_TIMEOUT = 15.0
41# PyPI ``project_urls`` labels that name the source repo, preferred over a
42# homepage that may point somewhere other than the code. Deliberately the
43# source-*role* words only: "github" is a platform name, not a role, and would
44# wrongly promote a "GitHub Sponsors" funding link over the real "Source".
45_SOURCE_HINTS = ("source", "repository", "code")
46# First path segment of a github.com URL that is a reserved namespace, never a
47# repo owner โ€” so a funding/profile link like github.com/sponsors/<org> is not
48# mistaken for the repo ``sponsors/<org>`` (which would 404 and suppress a real
49# changelog).
50_RESERVED_OWNERS = frozenset(
51 {
52 "sponsors",
53 "orgs",
54 "users",
55 "marketplace",
56 "apps",
57 "topics",
58 "collections",
59 "about",
60 "settings",
61 "pricing",
62 "features",
63 }
65 
66 
67def _github_repo(url: str) -> RepoRef | None:
68 """Parse an ``owner/name`` GitHub repo out of a URL, if it is one."""
69 found = _GITHUB_URL.search(url)
70 if found is None or found.group(1).lower() in _RESERVED_OWNERS:
71 return None
72 match RepoRef.parse(f"{found.group(1)}/{found.group(2)}"):
73 case Ok(ref):
74 return ref
75 case _:
76 return None
โ‹ฏ 105 lines hidden (lines 77โ€“181)
77 
78 
79def github_repo_from_registry(metadata: Any) -> RepoRef | None:
80 """Extract the GitHub repo from npm registry ``repository.url``, if any."""
81 repository = (
82 metadata.get("repository") if isinstance(metadata, dict) else None
83 )
84 url = repository.get("url") if isinstance(repository, dict) else repository
85 return _github_repo(url) if isinstance(url, str) else None
86 
87 
88def _pypi_candidate_urls(info: Any) -> list[str]:
89 """PyPI ``info`` URLs, source/repo links first, then the homepage."""
90 project_urls = info.get("project_urls") if isinstance(info, dict) else None
91 preferred: list[str] = []
92 rest: list[str] = []
93 if isinstance(project_urls, dict):
94 for label, url in project_urls.items():
95 if not isinstance(url, str):
96 continue
97 key = label.lower() if isinstance(label, str) else ""
98 target = preferred if any(h in key for h in _SOURCE_HINTS) else rest
99 target.append(url)
100 home = info.get("home_page") if isinstance(info, dict) else None
101 if isinstance(home, str):
102 rest.append(home)
103 return preferred + rest
104 
105 
106def github_repo_from_pypi(metadata: Any) -> RepoRef | None:
107 """Extract the GitHub repo from PyPI ``info`` project URLs, if any."""
108 info = metadata.get("info") if isinstance(metadata, dict) else None
109 for url in _pypi_candidate_urls(info):
110 repo = _github_repo(url)
111 if repo is not None:
112 return repo
113 return None
114 
115 
116class HttpChangelogSource:
117 """A :class:`~froot.ports.protocols.ChangelogSource` over HTTP."""
118 
119 async def fetch(self, candidate: PatchCandidate) -> Changelog | None:
120 """Fetch the target version's changelog, or ``None`` (best-effort)."""
121 try:
122 async with httpx.AsyncClient(
123 timeout=_TIMEOUT, follow_redirects=True
124 ) as client:
125 return await self._fetch(client, candidate)
126 except (httpx.HTTPError, json.JSONDecodeError):
127 # Best-effort: a network error or a malformed 200 body both mean
128 # "no usable changelog", which the judge maps to UnknownVerdict.
129 return None
130 
131 async def _fetch(
132 self, client: httpx.AsyncClient, candidate: PatchCandidate
133 ) -> Changelog | None:
134 repo = await self._discover_repo(client, candidate)
135 if repo is None:
136 return None
137 target = str(candidate.target)
138 text = await self._release_notes(client, repo, target)
139 if text is None:
140 return None
141 return Changelog(
142 package=candidate.package,
143 version=candidate.target,
144 text=text,
145 source_url=f"https://github.com/{repo.slug}/releases/tag/v{target}",
146 )
147 
148 @staticmethod
149 async def _discover_repo(
150 client: httpx.AsyncClient, candidate: PatchCandidate
151 ) -> RepoRef | None:
152 """Find the package's GitHub repo from its registry metadata."""
153 match candidate.ecosystem:
154 case Ecosystem.NPM:
155 meta = await client.get(f"{_REGISTRY}/{candidate.package}")
156 resolve = github_repo_from_registry
157 case Ecosystem.UV:
158 meta = await client.get(
159 f"{_PYPI_JSON}/{candidate.package}/json"
160 )
161 resolve = github_repo_from_pypi
162 case _:
163 assert_never(candidate.ecosystem)
164 if meta.status_code != 200:
165 return None
166 return resolve(meta.json())
167 
168 @staticmethod
169 async def _release_notes(
170 client: httpx.AsyncClient, repo: RepoRef, target: str
171 ) -> str | None:
172 """GitHub release notes for the tag (``vX.Y.Z`` or ``X.Y.Z``)."""
173 for tag in (f"v{target}", target):
174 resp = await client.get(
175 f"{_GITHUB_API}/repos/{repo.slug}/releases/tags/{tag}"
176 )
177 if resp.status_code == 200:
178 body = resp.json().get("body")
179 if isinstance(body, str) and body.strip():
180 return body
181 return None
Reaching the loop7๐ŸŽฌ The bug the review caught

The PR body has to match the diff

src/froot/policy/compose.py

froot 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
src/froot/policy/compose.py103 lines ยท Python
โ‹ฏ 38 lines hidden (lines 1โ€“38)
1"""Compose the PR content and the PR labels โ€” pure, no model call.
2 
3Spine-heavy: the PR title/body are deterministic templates over the candidate
4and the model's changelog verdict (the model already did its one job โ€” the
5verdict โ€” so rendering the text costs no model round-trip). froot tags every PR
6with one fixed pair of labels; the per-run changelog/CI signal lives in the
7durable workflow history (and the structured outcome log), not as accumulating
8labels on the PR.
9"""
10 
11from __future__ import annotations
12 
13from typing import TYPE_CHECKING, assert_never
14 
15from froot.domain.changelog import CleanVerdict, RiskyVerdict, UnknownVerdict
16from froot.domain.ecosystem import (
17 Ecosystem,
18 lockfile_filename,
19 manifest_filename,
21from froot.domain.pull_request import PullRequestDraft
22from froot.policy.naming import branch_name
23 
24if TYPE_CHECKING:
25 from froot.domain.candidate import PatchCandidate
26 from froot.domain.changelog import ChangelogVerdict
27 from froot.domain.repo import TargetRepo
28 
29_LABEL_NAMESPACE = "froot"
30 
31# The fixed labels froot puts on every PR it opens. Deliberately just these two:
32# they mark the PR as froot's dependency-patch work, nothing more. How the
33# proposal fared (the changelog verdict, the CI result) is recorded durably in
34# the workflow history, not layered onto the PR as labels that pile up across
35# re-runs.
36PR_LABELS: tuple[str, str] = (_LABEL_NAMESPACE, "dependency-patch")
37 
38 
39def _changed_files(ecosystem: Ecosystem) -> str:
40 """Describe which files a bump rewrites, for the PR body.
41 
42 The phrase must match the diff the human approver actually sees. npm
43 rewrites both the manifest and the lockfile (``npm install
44 --package-lock-only`` updates the dependency spec too); uv rewrites only the
45 lockfile, because a patch stays within the existing ``pyproject.toml``
46 constraint, so the manifest is left untouched.
47 """
48 match ecosystem:
49 case Ecosystem.NPM:
50 return f"{manifest_filename(ecosystem)} + lockfile"
51 case Ecosystem.UV:
52 return (
53 f"{lockfile_filename(ecosystem)} only; "
54 f"{manifest_filename(ecosystem)} unchanged"
55 )
56 assert_never(ecosystem)
โ‹ฏ 47 lines hidden (lines 57โ€“103)
57 
58 
59def _verdict_summary(verdict: ChangelogVerdict) -> str:
60 """Render the model's changelog framing for the human reviewer."""
61 match verdict:
62 case CleanVerdict():
63 return f"Changelog reads clean. {verdict.rationale}"
64 case RiskyVerdict():
65 concerns = "".join(f"\n- {concern}" for concern in verdict.concerns)
66 return f"Review carefully. {verdict.rationale}{concerns}"
67 case UnknownVerdict():
68 return f"Changelog unavailable. {verdict.rationale}"
69 assert_never(verdict)
70 
71 
72def pull_request_draft(
73 target: TargetRepo,
74 candidate: PatchCandidate,
75 verdict: ChangelogVerdict,
76) -> PullRequestDraft:
77 """Build the deterministic PR content for a bump (no model call).
78 
79 Args:
80 target: The repo the PR is opened against (gives the base branch).
81 candidate: The bump being proposed.
82 verdict: The model's changelog framing, surfaced for the reviewer.
83 
84 Returns:
85 A :class:`PullRequestDraft` ready for the forge to open.
86 """
87 body = "\n".join(
88 (
89 f"Bumps `{candidate.package}` from {candidate.current} to "
90 f"{candidate.target} ({_changed_files(candidate.ecosystem)}).",
91 "",
92 _verdict_summary(verdict),
93 "",
94 "---",
95 "Opened by froot. froot does not merge; a human approves.",
96 )
97 )
98 return PullRequestDraft(
99 branch=branch_name(candidate),
100 base=target.default_branch,
101 title=f"deps: bump {candidate.package} to {candidate.target}",
102 body=body,
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
src/froot/policy/compose.py103 lines ยท Python
โ‹ฏ 86 lines hidden (lines 1โ€“86)
1"""Compose the PR content and the PR labels โ€” pure, no model call.
2 
3Spine-heavy: the PR title/body are deterministic templates over the candidate
4and the model's changelog verdict (the model already did its one job โ€” the
5verdict โ€” so rendering the text costs no model round-trip). froot tags every PR
6with one fixed pair of labels; the per-run changelog/CI signal lives in the
7durable workflow history (and the structured outcome log), not as accumulating
8labels on the PR.
9"""
10 
11from __future__ import annotations
12 
13from typing import TYPE_CHECKING, assert_never
14 
15from froot.domain.changelog import CleanVerdict, RiskyVerdict, UnknownVerdict
16from froot.domain.ecosystem import (
17 Ecosystem,
18 lockfile_filename,
19 manifest_filename,
21from froot.domain.pull_request import PullRequestDraft
22from froot.policy.naming import branch_name
23 
24if TYPE_CHECKING:
25 from froot.domain.candidate import PatchCandidate
26 from froot.domain.changelog import ChangelogVerdict
27 from froot.domain.repo import TargetRepo
28 
29_LABEL_NAMESPACE = "froot"
30 
31# The fixed labels froot puts on every PR it opens. Deliberately just these two:
32# they mark the PR as froot's dependency-patch work, nothing more. How the
33# proposal fared (the changelog verdict, the CI result) is recorded durably in
34# the workflow history, not layered onto the PR as labels that pile up across
35# re-runs.
36PR_LABELS: tuple[str, str] = (_LABEL_NAMESPACE, "dependency-patch")
37 
38 
39def _changed_files(ecosystem: Ecosystem) -> str:
40 """Describe which files a bump rewrites, for the PR body.
41 
42 The phrase must match the diff the human approver actually sees. npm
43 rewrites both the manifest and the lockfile (``npm install
44 --package-lock-only`` updates the dependency spec too); uv rewrites only the
45 lockfile, because a patch stays within the existing ``pyproject.toml``
46 constraint, so the manifest is left untouched.
47 """
48 match ecosystem:
49 case Ecosystem.NPM:
50 return f"{manifest_filename(ecosystem)} + lockfile"
51 case Ecosystem.UV:
52 return (
53 f"{lockfile_filename(ecosystem)} only; "
54 f"{manifest_filename(ecosystem)} unchanged"
55 )
56 assert_never(ecosystem)
57 
58 
59def _verdict_summary(verdict: ChangelogVerdict) -> str:
60 """Render the model's changelog framing for the human reviewer."""
61 match verdict:
62 case CleanVerdict():
63 return f"Changelog reads clean. {verdict.rationale}"
64 case RiskyVerdict():
65 concerns = "".join(f"\n- {concern}" for concern in verdict.concerns)
66 return f"Review carefully. {verdict.rationale}{concerns}"
67 case UnknownVerdict():
68 return f"Changelog unavailable. {verdict.rationale}"
69 assert_never(verdict)
70 
71 
72def pull_request_draft(
73 target: TargetRepo,
74 candidate: PatchCandidate,
75 verdict: ChangelogVerdict,
76) -> PullRequestDraft:
77 """Build the deterministic PR content for a bump (no model call).
78 
79 Args:
80 target: The repo the PR is opened against (gives the base branch).
81 candidate: The bump being proposed.
82 verdict: The model's changelog framing, surfaced for the reviewer.
83 
84 Returns:
85 A :class:`PullRequestDraft` ready for the forge to open.
86 """
87 body = "\n".join(
88 (
89 f"Bumps `{candidate.package}` from {candidate.current} to "
90 f"{candidate.target} ({_changed_files(candidate.ecosystem)}).",
91 "",
92 _verdict_summary(verdict),
93 "",
94 "---",
95 "Opened by froot. froot does not merge; a human approves.",
96 )
97 )
โ‹ฏ 6 lines hidden (lines 98โ€“103)
98 return PullRequestDraft(
99 branch=branch_name(candidate),
100 base=target.default_branch,
101 title=f"deps: bump {candidate.package} to {candidate.target}",
102 body=body,
103 )
How it's proven8๐ŸŽฌ Evidence

Fixtures for the pure parts, reality for the rest

tests/test_uv_adapter.py

The 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
tests/test_uv_adapter.py103 lines ยท Python
โ‹ฏ 76 lines hidden (lines 1โ€“76)
1from __future__ import annotations
2 
3import json
4 
5from froot.adapters.npm import NpmPackageManager
6from froot.adapters.registry import package_manager_for
7from froot.adapters.uv import (
8 UvPackageManager,
9 normalize_name,
10 parse_available_versions,
11 parse_direct_dependencies,
12 parse_locked_versions,
14from froot.domain.ecosystem import Ecosystem
15from tests.support import ver
16 
17 
18def test_normalize_name_pep503():
19 assert normalize_name("Pydantic-Settings") == "pydantic-settings"
20 assert normalize_name("Foo.Bar_Baz") == "foo-bar-baz"
21 assert normalize_name("requests") == "requests"
22 
23 
24def test_parse_direct_dependencies_all_sections_normalized():
25 pyproject = """
26 [project]
27 dependencies = [
28 "Requests>=2.0",
29 "pydantic-ai-slim[openai]>=0.0.20",
30 "tomli ; python_version < '3.11'",
31 ]
32 [project.optional-dependencies]
33 dev = ["pytest>=8", "Mypy"]
34 [dependency-groups]
35 lint = ["ruff>=0.8", {include-group = "dev"}]
36 """
37 assert parse_direct_dependencies(pyproject) == frozenset(
38 {"requests", "pydantic-ai-slim", "tomli", "pytest", "mypy", "ruff"}
39 )
40 
41 
42def test_parse_direct_dependencies_empty_and_malformed():
43 assert parse_direct_dependencies("[project]\n") == frozenset()
44 assert parse_direct_dependencies("not = valid = toml") == frozenset()
45 
46 
47def test_parse_locked_versions_keys_on_normalized_name():
48 lock = """
49 version = 1
50 [[package]]
51 name = "froot"
52 version = "0.1.0"
53 [[package]]
54 name = "Pydantic-Settings"
55 version = "2.4.0"
56 [[package]]
57 name = "idna"
58 version = "3.6"
59 [[package]]
60 name = "no-version-entry"
61 """
62 locked = parse_locked_versions(lock)
63 assert locked["pydantic-settings"] == "2.4.0"
64 assert locked["idna"] == "3.6"
65 assert locked["froot"] == "0.1.0"
66 assert "no-version-entry" not in locked
67 
68 
69def test_parse_locked_versions_malformed():
70 assert parse_locked_versions("not = valid = toml") == {}
71 
72 
73def _pypi(releases: dict[str, list[dict[str, object]]]) -> str:
74 return json.dumps({"releases": releases})
75 
76 
77def test_parse_available_versions_filters_and_parses():
78 payload = _pypi(
79 {
80 "1.2.3": [{"filename": "x.whl", "yanked": False}],
81 "1.2.4": [{"filename": "y.whl"}], # no yanked key == not yanked
82 "1.3.0rc1": [{"yanked": False}], # prerelease: not semver, dropped
83 "0.9.0": [{"yanked": True}], # fully yanked: dropped
84 "1.2.5": [], # no files: dropped
85 "not-a-version": [{"yanked": False}], # unparseable: dropped
86 }
87 )
88 assert set(parse_available_versions(payload)) == {
89 ver("1.2.3"),
90 ver("1.2.4"),
91 }
โ‹ฏ 12 lines hidden (lines 92โ€“103)
92 
93 
94def test_parse_available_versions_empty_or_garbage():
95 assert parse_available_versions("") == ()
96 assert parse_available_versions(" ") == ()
97 assert parse_available_versions("not json") == ()
98 assert parse_available_versions(json.dumps({"info": {}})) == ()
99 
100 
101def test_package_manager_for_dispatch():
102 assert isinstance(package_manager_for(Ecosystem.NPM), NpmPackageManager)
103 assert isinstance(package_manager_for(Ecosystem.UV), UvPackageManager)

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.

WhatHow it is checked
PEP 503 normalization, PEP 621/735 parsing, uv.lock parsingfixture unit tests
PyPI filtering (yanked, fileless, non-semver)fixture unit test
ecosystem โ†’ adapter dispatchtest_package_manager_for_dispatch
activities pick the adapter by target.ecosystemactivity test over fakes
@<ecosystem> config parsing, unknown rejectedsettings tests
uv PR body says uv.lock onlycompose test
repo discovery, sponsors guard, fragment URLschangelog tests
the real uv + PyPI griptwo live smoke runs
The whole change, and where each claim is backed. 116 tests pass; ruff and strict mypy are clean.
The verdict9๐ŸŽฌ Evidence table + what did not change

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.

ClaimUpheld byWhere
A new ecosystem is one adapter behind the existing portUvPackageManager satisfies PackageManager structurally; the spine names only the protocoladapters/uv.py, ports/protocols.py
The seam was prepared, and the compiler enforces itassert_never on every Ecosystem match; one enum member addeddomain/ecosystem.py
Patch selection is shared, not reimplementedboth adapters feed the same select_patch_candidatespolicy/candidates.py (unchanged)
The bump runs no third-party code in the workeruv lock --upgrade-package, lockfile-only; CI does the installadapters/uv.py, Dockerfile
Conservative versions: never a wrong bumpnon-semver versions dropped at parse; the live skip of 0.63b1adapters/uv.py, domain/version.py
The human approver is told the truth_changed_files matches the body to the diff per ecosystempolicy/compose.py
Six claims, six pieces of code-level evidence.

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.