lockfloor 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lockfloor/__init__.py +3 -0
- lockfloor/__main__.py +5 -0
- lockfloor/cli.py +213 -0
- lockfloor/lockfile.py +146 -0
- lockfloor/manifest.py +145 -0
- lockfloor/plan.py +68 -0
- lockfloor/resolver.py +95 -0
- lockfloor/specifier.py +127 -0
- lockfloor-0.1.0.dist-info/METADATA +142 -0
- lockfloor-0.1.0.dist-info/RECORD +13 -0
- lockfloor-0.1.0.dist-info/WHEEL +4 -0
- lockfloor-0.1.0.dist-info/entry_points.txt +2 -0
- lockfloor-0.1.0.dist-info/licenses/LICENSE +21 -0
lockfloor/__init__.py
ADDED
lockfloor/__main__.py
ADDED
lockfloor/cli.py
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"""lockfloor: raise pyproject.toml lower bounds to the versions uv's resolver locks together."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import logging
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from packaging.requirements import InvalidRequirement, Requirement
|
|
11
|
+
from packaging.utils import canonicalize_name
|
|
12
|
+
|
|
13
|
+
from lockfloor import __version__
|
|
14
|
+
from lockfloor.lockfile import LockIndex
|
|
15
|
+
from lockfloor.manifest import (
|
|
16
|
+
Manifest,
|
|
17
|
+
ProjectError,
|
|
18
|
+
Slot,
|
|
19
|
+
find_workspace_root,
|
|
20
|
+
load_manifest,
|
|
21
|
+
requirement_slots,
|
|
22
|
+
workspace_manifests,
|
|
23
|
+
)
|
|
24
|
+
from lockfloor.plan import Row, plan_rows
|
|
25
|
+
from lockfloor.resolver import ResolveError, UvResolver
|
|
26
|
+
from lockfloor.specifier import is_pin, loosen_pin, split_name
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("lockfloor")
|
|
29
|
+
|
|
30
|
+
EXIT_OK = 0
|
|
31
|
+
EXIT_UPGRADES_PENDING = 1
|
|
32
|
+
EXIT_ERROR = 2
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
36
|
+
parser = argparse.ArgumentParser(
|
|
37
|
+
prog="lockfloor",
|
|
38
|
+
description=(
|
|
39
|
+
"Raise pyproject.toml lower bounds to the versions uv's resolver locks together. "
|
|
40
|
+
"Dry run by default."
|
|
41
|
+
),
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument("names", nargs="*", metavar="DEPENDENCY", help="only these dependencies (default: all)")
|
|
44
|
+
parser.add_argument("--project", type=Path, default=Path.cwd(), help="project or workspace directory")
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"--package", action="append", default=[], metavar="MEMBER", help="only this workspace member (repeatable)"
|
|
47
|
+
)
|
|
48
|
+
parser.add_argument(
|
|
49
|
+
"--group",
|
|
50
|
+
action="append",
|
|
51
|
+
default=[],
|
|
52
|
+
metavar="GROUP",
|
|
53
|
+
help="only this group: main, a dependency-group name, or extra:NAME (repeatable)",
|
|
54
|
+
)
|
|
55
|
+
parser.add_argument("--pins", action="store_true", help="also move single == pins to the resolved version")
|
|
56
|
+
mode = parser.add_mutually_exclusive_group()
|
|
57
|
+
mode.add_argument("--write", action="store_true", help="rewrite pyproject.toml files and re-lock")
|
|
58
|
+
mode.add_argument("--check", action="store_true", help="exit 1 when any floor can be raised (for CI)")
|
|
59
|
+
parser.add_argument("--timeout", type=float, default=300.0, help="seconds allowed per uv call (default 300)")
|
|
60
|
+
parser.add_argument("-v", "--verbose", action="store_true", help="log the uv commands")
|
|
61
|
+
parser.add_argument("--version", action="version", version=f"lockfloor {__version__}")
|
|
62
|
+
return parser
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def main(argv: list[str] | None = None, resolver: object | None = None) -> int:
|
|
66
|
+
args = build_parser().parse_args(argv)
|
|
67
|
+
logging.basicConfig(level=logging.DEBUG if args.verbose else logging.WARNING, format="%(name)s: %(message)s")
|
|
68
|
+
if args.timeout <= 0:
|
|
69
|
+
print("lockfloor: --timeout must be positive", file=sys.stderr)
|
|
70
|
+
return EXIT_ERROR
|
|
71
|
+
try:
|
|
72
|
+
return _run(args, resolver or UvResolver(args.timeout))
|
|
73
|
+
except (ProjectError, ResolveError) as exc:
|
|
74
|
+
print(f"lockfloor: {exc}", file=sys.stderr)
|
|
75
|
+
return EXIT_ERROR
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _run(args: argparse.Namespace, resolver) -> int:
|
|
79
|
+
root = find_workspace_root(args.project)
|
|
80
|
+
manifests = workspace_manifests(root)
|
|
81
|
+
slots = _select(manifests, args)
|
|
82
|
+
if not slots:
|
|
83
|
+
print("lockfloor: no requirements to look at")
|
|
84
|
+
return EXIT_OK
|
|
85
|
+
|
|
86
|
+
filtered = bool(args.names or args.group or args.package)
|
|
87
|
+
upgrade = sorted({_name(s.raw) for s in slots if _name(s.raw)}) if filtered else None
|
|
88
|
+
pins = [s for s in slots if args.pins and is_pin(s.raw)]
|
|
89
|
+
lock_text = resolver.upgraded_lock(root, upgrade, _pin_loosener(root, pins), _paths_escape(root, manifests))
|
|
90
|
+
|
|
91
|
+
rows = plan_rows(slots, LockIndex(lock_text), pins=args.pins)
|
|
92
|
+
print(render_table(rows, show_member=len(manifests) > 1))
|
|
93
|
+
raised = [r for r in rows if r.raises]
|
|
94
|
+
skipped = [r for r in rows if r.verdict.skip]
|
|
95
|
+
current = len(rows) - len(raised) - len(skipped)
|
|
96
|
+
print(f"\n{len(raised)} to raise, {len(skipped)} left alone, {current} already at the resolved version.")
|
|
97
|
+
|
|
98
|
+
if args.check:
|
|
99
|
+
return EXIT_UPGRADES_PENDING if raised else EXIT_OK
|
|
100
|
+
if not args.write:
|
|
101
|
+
print("Dry run: no file was written. Pass --write to apply and re-lock.")
|
|
102
|
+
return EXIT_OK
|
|
103
|
+
if not raised:
|
|
104
|
+
return EXIT_OK
|
|
105
|
+
_apply(root, raised, lock_text, resolver)
|
|
106
|
+
files = sorted({r.slot.manifest.path.relative_to(root).as_posix() for r in raised})
|
|
107
|
+
print(f"Wrote {', '.join(files)} and re-locked uv.lock.")
|
|
108
|
+
return EXIT_OK
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _select(manifests: list[Manifest], args: argparse.Namespace) -> list[Slot]:
|
|
112
|
+
slots = [slot for manifest in manifests for slot in requirement_slots(manifest)]
|
|
113
|
+
if args.package:
|
|
114
|
+
known = {canonicalize_name(m.label): m for m in manifests}
|
|
115
|
+
wanted = {canonicalize_name(p) for p in args.package}
|
|
116
|
+
unknown = wanted - known.keys()
|
|
117
|
+
if unknown:
|
|
118
|
+
raise ProjectError(f"--package {', '.join(sorted(unknown))} is not a workspace member; members: {', '.join(m.label for m in manifests)}")
|
|
119
|
+
slots = [s for s in slots if canonicalize_name(s.manifest.label) in wanted]
|
|
120
|
+
if args.group:
|
|
121
|
+
labels = {s.group_label for s in slots}
|
|
122
|
+
unknown = set(args.group) - labels
|
|
123
|
+
if unknown:
|
|
124
|
+
raise ProjectError(f"--group {', '.join(sorted(unknown))} not found; groups here: {', '.join(sorted(labels))}")
|
|
125
|
+
slots = [s for s in slots if s.group_label in args.group]
|
|
126
|
+
if args.names:
|
|
127
|
+
wanted_names = {canonicalize_name(n) for n in args.names}
|
|
128
|
+
slots = [s for s in slots if _name(s.raw) in wanted_names]
|
|
129
|
+
if not slots:
|
|
130
|
+
raise ProjectError(f"no requirement named {', '.join(args.names)} in the selected groups")
|
|
131
|
+
return slots
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _name(raw: str) -> str | None:
|
|
135
|
+
try:
|
|
136
|
+
return canonicalize_name(Requirement(raw).name)
|
|
137
|
+
except InvalidRequirement:
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _pin_loosener(root: Path, pins: list[Slot]):
|
|
142
|
+
"""Return a callback that rewrites `==X` to `>=X` in the scratch copy, so uv may move the pin."""
|
|
143
|
+
|
|
144
|
+
def loosen(copy: Path) -> None:
|
|
145
|
+
by_file: dict[Path, list[Slot]] = {}
|
|
146
|
+
for slot in pins:
|
|
147
|
+
by_file.setdefault(slot.manifest.path, []).append(slot)
|
|
148
|
+
for path, file_pins in by_file.items():
|
|
149
|
+
scratch_manifest = load_manifest(copy / path.relative_to(root), copy)
|
|
150
|
+
scratch_slots = {(s.section, s.group, s.index): s for s in requirement_slots(scratch_manifest)}
|
|
151
|
+
for pin in file_pins:
|
|
152
|
+
scratch_slots[(pin.section, pin.group, pin.index)].replace(loosen_pin(pin.raw))
|
|
153
|
+
scratch_manifest.path.write_bytes(scratch_manifest.dump())
|
|
154
|
+
|
|
155
|
+
return loosen
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _paths_escape(root: Path, manifests: list[Manifest]) -> bool:
|
|
159
|
+
"""True when a [tool.uv.sources] path points outside the workspace root."""
|
|
160
|
+
for manifest in manifests:
|
|
161
|
+
sources = ((manifest.document.get("tool") or {}).get("uv") or {}).get("sources") or {}
|
|
162
|
+
for source in sources.values():
|
|
163
|
+
for entry in source if isinstance(source, list) else [source]:
|
|
164
|
+
path = entry.get("path") if hasattr(entry, "get") else None
|
|
165
|
+
if path and not (manifest.path.parent / str(path)).resolve().is_relative_to(root):
|
|
166
|
+
return True
|
|
167
|
+
return False
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _apply(root: Path, raised: list[Row], lock_text: str, resolver) -> None:
|
|
171
|
+
"""Rewrite manifests, adopt the resolved lock, re-lock; restore every byte if any step fails."""
|
|
172
|
+
lock_path = root / "uv.lock"
|
|
173
|
+
lock_before = lock_path.read_bytes() if lock_path.exists() else None
|
|
174
|
+
touched = {id(r.slot.manifest): r.slot.manifest for r in raised}.values()
|
|
175
|
+
done = False
|
|
176
|
+
try:
|
|
177
|
+
for row in raised:
|
|
178
|
+
row.slot.replace(row.verdict.new)
|
|
179
|
+
for manifest in touched:
|
|
180
|
+
manifest.path.write_bytes(manifest.dump())
|
|
181
|
+
lock_path.write_bytes(lock_text.encode("utf-8"))
|
|
182
|
+
resolver.relock(root)
|
|
183
|
+
done = True
|
|
184
|
+
finally:
|
|
185
|
+
if not done:
|
|
186
|
+
for manifest in touched:
|
|
187
|
+
manifest.path.write_bytes(manifest.original)
|
|
188
|
+
if lock_before is None:
|
|
189
|
+
lock_path.unlink(missing_ok=True)
|
|
190
|
+
else:
|
|
191
|
+
lock_path.write_bytes(lock_before)
|
|
192
|
+
print("lockfloor: write failed; pyproject.toml files and uv.lock restored", file=sys.stderr)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def render_table(rows: list[Row], *, show_member: bool) -> str:
|
|
196
|
+
header = (["member"] if show_member else []) + ["group", "package", "old", "new", "note"]
|
|
197
|
+
lines = [header]
|
|
198
|
+
for row in rows:
|
|
199
|
+
if not row.raises and not row.verdict.skip:
|
|
200
|
+
continue
|
|
201
|
+
old = split_name(row.slot.raw)[1].strip()
|
|
202
|
+
new = split_name(row.verdict.new)[1].strip() if row.verdict.new else "-"
|
|
203
|
+
note = row.verdict.skip or row.note
|
|
204
|
+
if row.verdict.skip:
|
|
205
|
+
note = f"skipped: {note}"
|
|
206
|
+
cells = ([row.slot.manifest.label] if show_member else []) + [row.slot.group_label, row.package, old, new, note]
|
|
207
|
+
lines.append(cells)
|
|
208
|
+
if len(lines) == 1:
|
|
209
|
+
return "Every floor already matches the resolved lock."
|
|
210
|
+
widths = [max(len(line[i]) for line in lines) for i in range(len(header))]
|
|
211
|
+
rendered = [" ".join(cell.ljust(w) for cell, w in zip(line, widths)).rstrip() for line in lines]
|
|
212
|
+
rendered.insert(1, " ".join("-" * w for w in widths))
|
|
213
|
+
return "\n".join(rendered)
|
lockfloor/lockfile.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Read the versions uv's resolver chose from uv.lock, per project and dependency group.
|
|
2
|
+
|
|
3
|
+
A package can appear in uv.lock at several versions when the resolver forks on
|
|
4
|
+
markers (numpy 2.0.2 for Python 3.9, numpy 2.3.x for 3.11+). Collapsing the lock
|
|
5
|
+
into one version per name, as the copy-paste scripts do, raises the floor to a
|
|
6
|
+
version some supported environment cannot install.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import tomllib
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from itertools import product
|
|
14
|
+
|
|
15
|
+
from packaging.markers import Marker
|
|
16
|
+
from packaging.specifiers import SpecifierSet
|
|
17
|
+
from packaging.utils import canonicalize_name
|
|
18
|
+
from packaging.version import Version
|
|
19
|
+
|
|
20
|
+
_PYTHON_MINORS = range(8, 16)
|
|
21
|
+
_PLATFORMS = (
|
|
22
|
+
{"sys_platform": "linux", "platform_system": "Linux", "os_name": "posix", "platform_machine": "x86_64"},
|
|
23
|
+
{"sys_platform": "linux", "platform_system": "Linux", "os_name": "posix", "platform_machine": "aarch64"},
|
|
24
|
+
{"sys_platform": "darwin", "platform_system": "Darwin", "os_name": "posix", "platform_machine": "arm64"},
|
|
25
|
+
{"sys_platform": "darwin", "platform_system": "Darwin", "os_name": "posix", "platform_machine": "x86_64"},
|
|
26
|
+
{"sys_platform": "win32", "platform_system": "Windows", "os_name": "nt", "platform_machine": "AMD64"},
|
|
27
|
+
)
|
|
28
|
+
_PROJECT_SOURCES = ("editable", "virtual")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Locked:
|
|
33
|
+
"""One version the resolver picked for a dependency edge."""
|
|
34
|
+
|
|
35
|
+
version: Version | None
|
|
36
|
+
marker: str | None
|
|
37
|
+
source_kind: str
|
|
38
|
+
|
|
39
|
+
@property
|
|
40
|
+
def from_registry(self) -> bool:
|
|
41
|
+
return self.source_kind == "registry"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class LockIndex:
|
|
45
|
+
def __init__(self, text: str) -> None:
|
|
46
|
+
data = tomllib.loads(text)
|
|
47
|
+
self._by_name: dict[str, list[dict]] = {}
|
|
48
|
+
for package in data.get("package", []):
|
|
49
|
+
self._by_name.setdefault(canonicalize_name(package["name"]), []).append(package)
|
|
50
|
+
requires_python = SpecifierSet(data.get("requires-python", ""))
|
|
51
|
+
self._environments = [
|
|
52
|
+
env for env in _sample_environments() if requires_python.contains(env["python_full_version"])
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
def resolved(self, project: str | None, section: str, group: str | None, dependency: str) -> list[Locked]:
|
|
56
|
+
"""Versions locked for `dependency` along the edges of one project's group.
|
|
57
|
+
|
|
58
|
+
`section` is "main", "extra" or "group". Falls back to every locked version of
|
|
59
|
+
the name when the project or edge is absent (a virtual workspace root keeps its
|
|
60
|
+
groups in [manifest], which carries specifiers, not resolved edges).
|
|
61
|
+
"""
|
|
62
|
+
name = canonicalize_name(dependency)
|
|
63
|
+
edges = self._edges(project, section, group, name)
|
|
64
|
+
if not edges:
|
|
65
|
+
return [_locked(package, None) for package in self._by_name.get(name, [])]
|
|
66
|
+
picked: list[Locked] = []
|
|
67
|
+
for edge in edges:
|
|
68
|
+
candidates = self._by_name.get(name, [])
|
|
69
|
+
if "version" in edge:
|
|
70
|
+
candidates = [p for p in candidates if p.get("version") == edge["version"]]
|
|
71
|
+
picked.extend(_locked(package, edge.get("marker")) for package in candidates)
|
|
72
|
+
return picked
|
|
73
|
+
|
|
74
|
+
def applicable(self, locked: list[Locked], requirement_marker: Marker | None) -> list[Locked]:
|
|
75
|
+
"""Keep the forks whose marker can hold together with the requirement's marker.
|
|
76
|
+
|
|
77
|
+
Overlap is tested on a grid of real environments (CPython 3.8 to 3.15 on five
|
|
78
|
+
platforms) inside the lock's requires-python. When the grid proves nothing,
|
|
79
|
+
every fork is kept: the caller takes the minimum, so keeping more only lowers
|
|
80
|
+
the floor, never raises it past what an environment can install.
|
|
81
|
+
"""
|
|
82
|
+
if requirement_marker is None or len(locked) < 2:
|
|
83
|
+
return locked
|
|
84
|
+
kept = [
|
|
85
|
+
entry
|
|
86
|
+
for entry in locked
|
|
87
|
+
if any(
|
|
88
|
+
requirement_marker.evaluate(env) and (entry.marker is None or Marker(entry.marker).evaluate(env))
|
|
89
|
+
for env in self._environments
|
|
90
|
+
)
|
|
91
|
+
]
|
|
92
|
+
return kept or locked
|
|
93
|
+
|
|
94
|
+
def _edges(self, project: str | None, section: str, group: str | None, name: str) -> list[dict]:
|
|
95
|
+
if project is None:
|
|
96
|
+
return []
|
|
97
|
+
owners = [
|
|
98
|
+
p
|
|
99
|
+
for p in self._by_name.get(canonicalize_name(project), [])
|
|
100
|
+
if any(kind in p.get("source", {}) for kind in _PROJECT_SOURCES)
|
|
101
|
+
]
|
|
102
|
+
if not owners:
|
|
103
|
+
return []
|
|
104
|
+
owner = owners[0]
|
|
105
|
+
if section == "main":
|
|
106
|
+
edges = owner.get("dependencies", [])
|
|
107
|
+
elif section == "extra":
|
|
108
|
+
edges = _group_table(owner.get("optional-dependencies", {}), group)
|
|
109
|
+
else:
|
|
110
|
+
edges = _group_table(owner.get("dev-dependencies", {}), group)
|
|
111
|
+
return [edge for edge in edges if canonicalize_name(edge["name"]) == name]
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _group_table(table: dict, group: str | None) -> list[dict]:
|
|
115
|
+
wanted = canonicalize_name(group or "")
|
|
116
|
+
for key, edges in table.items():
|
|
117
|
+
if canonicalize_name(key) == wanted:
|
|
118
|
+
return edges
|
|
119
|
+
return []
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _locked(package: dict, marker: str | None) -> Locked:
|
|
123
|
+
source = package.get("source", {})
|
|
124
|
+
kind = next(iter(source), "unknown")
|
|
125
|
+
version = package.get("version")
|
|
126
|
+
return Locked(Version(version) if version else None, marker, kind)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _sample_environments() -> list[dict[str, str]]:
|
|
130
|
+
environments = []
|
|
131
|
+
for minor, platform in product(_PYTHON_MINORS, _PLATFORMS):
|
|
132
|
+
full = f"3.{minor}.0"
|
|
133
|
+
environments.append(
|
|
134
|
+
{
|
|
135
|
+
**platform,
|
|
136
|
+
"python_version": f"3.{minor}",
|
|
137
|
+
"python_full_version": full,
|
|
138
|
+
"implementation_name": "cpython",
|
|
139
|
+
"implementation_version": full,
|
|
140
|
+
"platform_python_implementation": "CPython",
|
|
141
|
+
"platform_release": "",
|
|
142
|
+
"platform_version": "",
|
|
143
|
+
"extra": "",
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
return environments
|
lockfloor/manifest.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Find the workspace's pyproject.toml files and every requirement array in them.
|
|
2
|
+
|
|
3
|
+
Files are parsed with tomlkit and kept as documents, so a write changes only the
|
|
4
|
+
strings that were raised; comments, ordering and quote style survive.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import fnmatch
|
|
10
|
+
import tomllib
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import tomlkit
|
|
15
|
+
from tomlkit.items import Array, String, StringType
|
|
16
|
+
from tomlkit.toml_document import TOMLDocument
|
|
17
|
+
|
|
18
|
+
MAX_MANIFEST_BYTES = 1 << 20
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ProjectError(Exception):
|
|
22
|
+
"""The directory is not a project lockfloor can work on."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Manifest:
|
|
27
|
+
path: Path
|
|
28
|
+
original: bytes
|
|
29
|
+
document: TOMLDocument
|
|
30
|
+
project_name: str | None
|
|
31
|
+
label: str
|
|
32
|
+
|
|
33
|
+
def dump(self) -> bytes:
|
|
34
|
+
return tomlkit.dumps(self.document).encode("utf-8")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class Slot:
|
|
39
|
+
"""One requirement string at a fixed position inside a manifest array."""
|
|
40
|
+
|
|
41
|
+
manifest: Manifest
|
|
42
|
+
section: str # "main", "extra" or "group"
|
|
43
|
+
group: str | None
|
|
44
|
+
array: Array
|
|
45
|
+
index: int
|
|
46
|
+
raw: str = field(init=False)
|
|
47
|
+
|
|
48
|
+
def __post_init__(self) -> None:
|
|
49
|
+
self.raw = str(self.array[self.index])
|
|
50
|
+
|
|
51
|
+
@property
|
|
52
|
+
def group_label(self) -> str:
|
|
53
|
+
if self.section == "main":
|
|
54
|
+
return "main"
|
|
55
|
+
if self.section == "extra":
|
|
56
|
+
return f"extra:{self.group}"
|
|
57
|
+
return self.group or ""
|
|
58
|
+
|
|
59
|
+
def replace(self, new_raw: str) -> None:
|
|
60
|
+
old = self.array[self.index]
|
|
61
|
+
literal = isinstance(old, String) and old.type in (StringType.SLL, StringType.MLL)
|
|
62
|
+
self.array[self.index] = tomlkit.string(new_raw, literal=literal)
|
|
63
|
+
self.raw = new_raw
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_manifest(path: Path, root: Path) -> Manifest:
|
|
67
|
+
size = path.stat().st_size
|
|
68
|
+
if size > MAX_MANIFEST_BYTES:
|
|
69
|
+
raise ProjectError(f"{path}: {size} bytes exceeds the {MAX_MANIFEST_BYTES}-byte limit for pyproject.toml")
|
|
70
|
+
original = path.read_bytes()
|
|
71
|
+
try:
|
|
72
|
+
document = tomlkit.parse(original.decode("utf-8"))
|
|
73
|
+
except ValueError as exc: # tomlkit's ParseError and UnicodeDecodeError both derive from it
|
|
74
|
+
raise ProjectError(f"{path}: not valid TOML ({exc})") from exc
|
|
75
|
+
project = document.get("project") or {}
|
|
76
|
+
name = project.get("name")
|
|
77
|
+
relative = path.parent.relative_to(root).as_posix()
|
|
78
|
+
label = str(name) if name else ("(root)" if relative == "." else relative)
|
|
79
|
+
return Manifest(path, original, document, str(name) if name else None, label)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def find_workspace_root(start: Path) -> Path:
|
|
83
|
+
"""The directory holding uv.lock: the nearest workspace that lists `start`, or `start`."""
|
|
84
|
+
start = start.resolve()
|
|
85
|
+
for directory in (start, *start.parents):
|
|
86
|
+
workspace = _workspace_table(directory / "pyproject.toml")
|
|
87
|
+
if workspace is not None and (directory == start or start in _member_dirs(directory, workspace)):
|
|
88
|
+
return directory
|
|
89
|
+
if not (start / "pyproject.toml").is_file():
|
|
90
|
+
raise ProjectError(f"no pyproject.toml in {start}")
|
|
91
|
+
return start
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def workspace_manifests(root: Path) -> list[Manifest]:
|
|
95
|
+
paths = [root / "pyproject.toml"]
|
|
96
|
+
workspace = _workspace_table(root / "pyproject.toml")
|
|
97
|
+
if workspace is not None:
|
|
98
|
+
paths += [d / "pyproject.toml" for d in _member_dirs(root, workspace) if d != root]
|
|
99
|
+
return [load_manifest(p, root) for p in paths]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def requirement_slots(manifest: Manifest) -> list[Slot]:
|
|
103
|
+
document = manifest.document
|
|
104
|
+
project = document.get("project") or {}
|
|
105
|
+
tool_uv = (document.get("tool") or {}).get("uv") or {}
|
|
106
|
+
arrays: list[tuple[str, str | None, object]] = [("main", None, project.get("dependencies"))]
|
|
107
|
+
arrays += [("extra", k, v) for k, v in (project.get("optional-dependencies") or {}).items()]
|
|
108
|
+
arrays += [("group", k, v) for k, v in (document.get("dependency-groups") or {}).items()]
|
|
109
|
+
# uv's pre-PEP 735 spelling; the lock files it under the same "dev" group.
|
|
110
|
+
arrays.append(("group", "dev", tool_uv.get("dev-dependencies")))
|
|
111
|
+
|
|
112
|
+
slots = []
|
|
113
|
+
for section, group, array in arrays:
|
|
114
|
+
if not isinstance(array, Array):
|
|
115
|
+
continue
|
|
116
|
+
for index, item in enumerate(array):
|
|
117
|
+
# dependency-groups may hold {include-group = "..."} tables; those are not requirements.
|
|
118
|
+
if isinstance(item, str):
|
|
119
|
+
slots.append(Slot(manifest, section, group, array, index))
|
|
120
|
+
return slots
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _workspace_table(pyproject: Path) -> dict | None:
|
|
124
|
+
if not pyproject.is_file():
|
|
125
|
+
return None
|
|
126
|
+
try:
|
|
127
|
+
data = tomllib.loads(pyproject.read_text(encoding="utf-8"))
|
|
128
|
+
except (tomllib.TOMLDecodeError, UnicodeDecodeError) as exc:
|
|
129
|
+
raise ProjectError(f"{pyproject}: not valid TOML ({exc})") from exc
|
|
130
|
+
return data.get("tool", {}).get("uv", {}).get("workspace")
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _member_dirs(root: Path, workspace: dict) -> list[Path]:
|
|
134
|
+
excludes = workspace.get("exclude", [])
|
|
135
|
+
members: list[Path] = []
|
|
136
|
+
if (root / "pyproject.toml").is_file():
|
|
137
|
+
members.append(root)
|
|
138
|
+
for pattern in workspace.get("members", []):
|
|
139
|
+
for candidate in sorted(root.glob(pattern)):
|
|
140
|
+
relative = candidate.relative_to(root).as_posix()
|
|
141
|
+
if any(fnmatch.fnmatch(relative, ex) for ex in excludes):
|
|
142
|
+
continue
|
|
143
|
+
if (candidate / "pyproject.toml").is_file() and candidate.resolve() not in members:
|
|
144
|
+
members.append(candidate.resolve())
|
|
145
|
+
return members
|
lockfloor/plan.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Pair every selected requirement with the version uv locked for it and decide its fate."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from packaging.requirements import Requirement
|
|
8
|
+
|
|
9
|
+
from lockfloor.lockfile import LockIndex
|
|
10
|
+
from lockfloor.manifest import Slot
|
|
11
|
+
from lockfloor.specifier import Verdict, raise_floor, static_skip
|
|
12
|
+
|
|
13
|
+
_SOURCE_REASONS = {
|
|
14
|
+
"editable": "workspace member or editable path; not a registry version",
|
|
15
|
+
"virtual": "workspace member; not a registry version",
|
|
16
|
+
"git": "locked from a git source",
|
|
17
|
+
"path": "locked from a local path",
|
|
18
|
+
"directory": "locked from a local directory",
|
|
19
|
+
"url": "locked from a direct url",
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Row:
|
|
25
|
+
slot: Slot
|
|
26
|
+
package: str
|
|
27
|
+
verdict: Verdict
|
|
28
|
+
note: str = ""
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def raises(self) -> bool:
|
|
32
|
+
return self.verdict.new is not None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def plan_rows(slots: list[Slot], index: LockIndex, *, pins: bool) -> list[Row]:
|
|
36
|
+
return [_plan_one(slot, index, pins=pins) for slot in slots]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _plan_one(slot: Slot, index: LockIndex, *, pins: bool) -> Row:
|
|
40
|
+
reason = static_skip(slot.raw, pins=pins)
|
|
41
|
+
if reason:
|
|
42
|
+
return Row(slot, _display_name(slot.raw), Verdict(skip=reason))
|
|
43
|
+
requirement = Requirement(slot.raw)
|
|
44
|
+
locked = index.resolved(slot.manifest.project_name, slot.section, slot.group, requirement.name)
|
|
45
|
+
locked = index.applicable(locked, requirement.marker)
|
|
46
|
+
if not locked:
|
|
47
|
+
return Row(slot, requirement.name, Verdict(skip="not in uv.lock (marker matches no environment?)"))
|
|
48
|
+
foreign = next((entry for entry in locked if not entry.from_registry), None)
|
|
49
|
+
if foreign is not None:
|
|
50
|
+
reason = _SOURCE_REASONS.get(foreign.source_kind, f"locked from a {foreign.source_kind} source")
|
|
51
|
+
return Row(slot, requirement.name, Verdict(skip=reason))
|
|
52
|
+
|
|
53
|
+
versions = sorted({entry.version for entry in locked if entry.version is not None})
|
|
54
|
+
if not versions:
|
|
55
|
+
return Row(slot, requirement.name, Verdict(skip="uv.lock records no version for it"))
|
|
56
|
+
# Every fork must still satisfy the new floor, so the floor is the lowest fork.
|
|
57
|
+
floor = versions[0]
|
|
58
|
+
note = ""
|
|
59
|
+
if len(versions) > 1:
|
|
60
|
+
note = f"resolver forked ({', '.join(map(str, versions))}); floor set to the lowest"
|
|
61
|
+
return Row(slot, requirement.name, raise_floor(slot.raw, floor, pins=pins), note)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _display_name(raw: str) -> str:
|
|
65
|
+
try:
|
|
66
|
+
return Requirement(raw).name
|
|
67
|
+
except ValueError:
|
|
68
|
+
return raw.strip()
|
lockfloor/resolver.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Run uv's resolver without touching the user's files.
|
|
2
|
+
|
|
3
|
+
The upgrade lock is produced in a scratch copy of the workspace, so a dry run is
|
|
4
|
+
byte-identical even if the process is killed half way. The copy is a sibling of the
|
|
5
|
+
workspace when any path source climbs out of it (`../shared`), so those relative
|
|
6
|
+
paths still resolve from the copy.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
import os
|
|
13
|
+
import shutil
|
|
14
|
+
import subprocess
|
|
15
|
+
import tempfile
|
|
16
|
+
from collections.abc import Callable
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
MAX_COPY_BYTES = 512 << 20
|
|
22
|
+
_NOT_COPIED = shutil.ignore_patterns(
|
|
23
|
+
".git", ".hg", ".venv", "venv", "node_modules", "__pycache__",
|
|
24
|
+
".mypy_cache", ".pytest_cache", ".ruff_cache", ".tox", ".nox",
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ResolveError(Exception):
|
|
29
|
+
"""uv could not produce a lock; the message carries uv's own explanation."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class UvResolver:
|
|
33
|
+
def __init__(self, timeout: float) -> None:
|
|
34
|
+
self.timeout = timeout
|
|
35
|
+
self.exe = os.environ.get("UV") or shutil.which("uv")
|
|
36
|
+
if not self.exe:
|
|
37
|
+
raise ResolveError("uv was not found on PATH; install it from https://docs.astral.sh/uv/")
|
|
38
|
+
|
|
39
|
+
def upgraded_lock(
|
|
40
|
+
self,
|
|
41
|
+
root: Path,
|
|
42
|
+
upgrade: list[str] | None,
|
|
43
|
+
prepare: Callable[[Path], None],
|
|
44
|
+
escapes_root: bool,
|
|
45
|
+
) -> str:
|
|
46
|
+
"""Lock a scratch copy of `root` with upgrades allowed and return the new uv.lock text.
|
|
47
|
+
|
|
48
|
+
`upgrade` None means every package; otherwise only those names move.
|
|
49
|
+
`prepare` edits the copy before locking (loosening pins for --pins).
|
|
50
|
+
"""
|
|
51
|
+
parent = root.parent if escapes_root else None
|
|
52
|
+
# The scratch directory is the copy itself, so it sits at the workspace's depth
|
|
53
|
+
# and `../shared` from the copy names the same directory as from the original.
|
|
54
|
+
scratch = Path(tempfile.mkdtemp(prefix=".lockfloor-", dir=parent))
|
|
55
|
+
try:
|
|
56
|
+
_copy_capped(root, scratch)
|
|
57
|
+
prepare(scratch)
|
|
58
|
+
flags = ["--upgrade"] if upgrade is None else [f"--upgrade-package={name}" for name in upgrade]
|
|
59
|
+
self._run(["lock", *flags], scratch)
|
|
60
|
+
return (scratch / "uv.lock").read_bytes().decode("utf-8")
|
|
61
|
+
finally:
|
|
62
|
+
shutil.rmtree(scratch, ignore_errors=True)
|
|
63
|
+
|
|
64
|
+
def relock(self, root: Path) -> None:
|
|
65
|
+
"""Bring uv.lock's recorded requirements in line with the rewritten manifests."""
|
|
66
|
+
self._run(["lock"], root)
|
|
67
|
+
|
|
68
|
+
def _run(self, args: list[str], cwd: Path) -> None:
|
|
69
|
+
command = [self.exe, *args, "--no-progress", "--color", "never"]
|
|
70
|
+
logger.debug("running %s in %s", command, cwd)
|
|
71
|
+
try:
|
|
72
|
+
completed = subprocess.run(
|
|
73
|
+
command, cwd=cwd, capture_output=True, text=True, encoding="utf-8", timeout=self.timeout
|
|
74
|
+
)
|
|
75
|
+
except subprocess.TimeoutExpired as exc:
|
|
76
|
+
raise ResolveError(f"`uv {' '.join(args)}` exceeded the {self.timeout:g}s timeout") from exc
|
|
77
|
+
if completed.returncode != 0:
|
|
78
|
+
tail = "\n".join(completed.stderr.strip().splitlines()[-25:])
|
|
79
|
+
raise ResolveError(f"`uv {' '.join(args)}` failed:\n{tail}")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _copy_capped(source: Path, destination: Path) -> None:
|
|
83
|
+
copied = 0
|
|
84
|
+
|
|
85
|
+
def copy_file(src: str, dst: str) -> str:
|
|
86
|
+
nonlocal copied
|
|
87
|
+
copied += os.path.getsize(src)
|
|
88
|
+
if copied > MAX_COPY_BYTES:
|
|
89
|
+
raise ResolveError(
|
|
90
|
+
f"workspace copy passed {MAX_COPY_BYTES >> 20} MiB; move large data out of {source} "
|
|
91
|
+
"or into an ignored directory (.venv, node_modules)"
|
|
92
|
+
)
|
|
93
|
+
return shutil.copy2(src, dst)
|
|
94
|
+
|
|
95
|
+
shutil.copytree(source, destination, ignore=_NOT_COPIED, copy_function=copy_file, symlinks=True, dirs_exist_ok=True)
|
lockfloor/specifier.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Rewrite one requirement string so its lower bound equals a resolved version.
|
|
2
|
+
|
|
3
|
+
The rewrite is textual: only the version digits of the lower-bound clause change,
|
|
4
|
+
so spacing, extras, markers and quoting inside the string stay as the author wrote them.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
|
|
12
|
+
from packaging.requirements import InvalidRequirement, Requirement
|
|
13
|
+
from packaging.specifiers import Specifier, SpecifierSet
|
|
14
|
+
from packaging.version import Version
|
|
15
|
+
|
|
16
|
+
_NAME_AND_EXTRAS = re.compile(
|
|
17
|
+
r"^\s*[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?\s*(?:\[[^\]]*\])?"
|
|
18
|
+
)
|
|
19
|
+
_CLAUSE = re.compile(r"(~=|===|==|!=|<=|>=|<|>)(\s*)([^\s,;()]+)")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class Verdict:
|
|
24
|
+
"""What lockfloor will do with one requirement.
|
|
25
|
+
|
|
26
|
+
Exactly one of `new` (raise to this text) and `skip` (reason it is left alone) is
|
|
27
|
+
set, or neither when the lower bound already equals the resolved version.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
new: str | None = None
|
|
31
|
+
skip: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def split_name(raw: str) -> tuple[str, str]:
|
|
35
|
+
"""Split a requirement into (name with extras, everything after it)."""
|
|
36
|
+
match = _NAME_AND_EXTRAS.match(raw)
|
|
37
|
+
if match is None:
|
|
38
|
+
return raw, ""
|
|
39
|
+
return raw[: match.end()], raw[match.end() :]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def static_skip(raw: str, *, pins: bool) -> str | None:
|
|
43
|
+
"""Reason to leave a requirement alone that does not depend on the lock, or None."""
|
|
44
|
+
try:
|
|
45
|
+
req = Requirement(raw)
|
|
46
|
+
except InvalidRequirement:
|
|
47
|
+
return "not a valid PEP 508 requirement"
|
|
48
|
+
if req.url:
|
|
49
|
+
return "direct reference (url, git or path); not a registry version"
|
|
50
|
+
clauses = list(req.specifier)
|
|
51
|
+
if not clauses:
|
|
52
|
+
return "no version specifier; lockfloor does not invent one"
|
|
53
|
+
if any(c.operator == "===" for c in clauses):
|
|
54
|
+
return "=== arbitrary-equality pin"
|
|
55
|
+
pinned = [c for c in clauses if c.operator == "=="]
|
|
56
|
+
if pinned:
|
|
57
|
+
if not pins:
|
|
58
|
+
return "pinned with ==; pass --pins to move pins"
|
|
59
|
+
if len(clauses) != 1 or "*" in pinned[0].version:
|
|
60
|
+
return "== wildcard or == combined with other clauses"
|
|
61
|
+
return None
|
|
62
|
+
lower = [c for c in clauses if c.operator in (">=", "~=", ">")]
|
|
63
|
+
if not lower:
|
|
64
|
+
return "no lower bound to raise (only upper bounds or exclusions)"
|
|
65
|
+
if len(lower) > 1:
|
|
66
|
+
return "more than one lower-bound clause"
|
|
67
|
+
if lower[0].operator == ">":
|
|
68
|
+
return "strict > bound; write it as >= to let lockfloor manage it"
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def is_pin(raw: str) -> bool:
|
|
73
|
+
"""True for a single exact `==` clause that --pins is allowed to move."""
|
|
74
|
+
try:
|
|
75
|
+
clauses = list(Requirement(raw).specifier)
|
|
76
|
+
except InvalidRequirement:
|
|
77
|
+
return False
|
|
78
|
+
return len(clauses) == 1 and clauses[0].operator == "==" and "*" not in clauses[0].version
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def loosen_pin(raw: str) -> str:
|
|
82
|
+
"""Turn `pkg==1.2` into `pkg>=1.2` so the resolver may move it (temp copy only)."""
|
|
83
|
+
head, tail = split_name(raw)
|
|
84
|
+
return head + tail.replace("==", ">=", 1)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def raise_floor(raw: str, resolved: Version, *, pins: bool) -> Verdict:
|
|
88
|
+
"""Decide how `raw` changes when uv resolved its package to `resolved`."""
|
|
89
|
+
reason = static_skip(raw, pins=pins)
|
|
90
|
+
if reason:
|
|
91
|
+
return Verdict(skip=reason)
|
|
92
|
+
spec = Requirement(raw).specifier
|
|
93
|
+
floor = Version(resolved.public)
|
|
94
|
+
clauses = list(spec)
|
|
95
|
+
target = next(c for c in clauses if c.operator in ("==", ">=", "~="))
|
|
96
|
+
# A pin is checked against the loosened (>=) form the resolver actually saw.
|
|
97
|
+
declared = SpecifierSet(f">={target.version}") if target.operator == "==" else spec
|
|
98
|
+
if not declared.contains(floor, prereleases=True):
|
|
99
|
+
return Verdict(skip=f"locked {floor} is outside the declared range (stale lock?)")
|
|
100
|
+
|
|
101
|
+
old = Version(target.version)
|
|
102
|
+
if target.operator == "~=":
|
|
103
|
+
width = len(old.release)
|
|
104
|
+
new_version = ".".join(str(part) for part in floor.release[:width])
|
|
105
|
+
else:
|
|
106
|
+
new_version = str(floor)
|
|
107
|
+
if Version(new_version) <= old:
|
|
108
|
+
return Verdict()
|
|
109
|
+
|
|
110
|
+
new_raw = _replace_clause(raw, target, new_version)
|
|
111
|
+
new_spec = Requirement(new_raw).specifier
|
|
112
|
+
if not new_spec.contains(floor, prereleases=True):
|
|
113
|
+
return Verdict(skip=f"raising would exclude the locked {floor}")
|
|
114
|
+
return Verdict(new=new_raw)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _replace_clause(raw: str, target: Specifier, new_version: str) -> str:
|
|
118
|
+
head, tail = split_name(raw)
|
|
119
|
+
marker_at = tail.find(";")
|
|
120
|
+
region, marker = (tail, "") if marker_at < 0 else (tail[:marker_at], tail[marker_at:])
|
|
121
|
+
for match in _CLAUSE.finditer(region):
|
|
122
|
+
op, gap, version = match.groups()
|
|
123
|
+
if op == target.operator and version == target.version:
|
|
124
|
+
region = region[: match.start(3)] + new_version + region[match.end(3) :]
|
|
125
|
+
return head + region + marker
|
|
126
|
+
# packaging normalises nothing in .version, so the clause text is always found.
|
|
127
|
+
raise AssertionError(f"clause {target} not found in {raw!r}")
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: lockfloor
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Raise pyproject.toml lower bounds to the versions uv's resolver locks together
|
|
5
|
+
Project-URL: Repository, https://github.com/zaidwhy/lockfloor
|
|
6
|
+
Project-URL: Issues, https://github.com/zaidwhy/lockfloor/issues
|
|
7
|
+
Project-URL: uv issue, https://github.com/astral-sh/uv/issues/6794
|
|
8
|
+
Author-email: Zaid Ali Syed <sidzaid72@gmail.com>
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: dependencies,lockfile,pyproject,upgrade,uv
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Software Development :: Build Tools
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Requires-Dist: packaging>=24.0
|
|
17
|
+
Requires-Dist: tomlkit>=0.12.0
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# lockfloor
|
|
21
|
+
|
|
22
|
+
Raise the lower bounds in `pyproject.toml` to the versions uv's resolver actually locks together.
|
|
23
|
+
|
|
24
|
+
**The new floor comes from `uv lock --upgrade`, never from "latest on PyPI".** The latest release of each package, picked one by one, is a set nobody has resolved; it can be uninstallable as a whole, or uninstallable on a Python version the project still supports. lockfloor upgrades the lock in a scratch copy of the workspace, reads what uv chose from that `uv.lock`, and moves each `>=` (or `~=`) up to it. If uv cannot resolve it, lockfloor does not write it.
|
|
25
|
+
|
|
26
|
+
This answers [astral-sh/uv#6794](https://github.com/astral-sh/uv/issues/6794) ("Upgrade dependencies in pyproject.toml") until uv ships its own command.
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
uvx lockfloor # dry run: print the plan, write nothing
|
|
30
|
+
uvx lockfloor --write # rewrite pyproject.toml files, then re-lock
|
|
31
|
+
uvx lockfloor --check # exit 1 if any floor lags the lock (CI)
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## What a run looks like
|
|
35
|
+
|
|
36
|
+
The fixture in [`examples/forked-workspace`](examples/forked-workspace) is a two-member workspace with `requires-python = ">=3.9"`, an extra, a dependency group, a pin, a marker and a workspace dependency. After `uv lock`, `lockfloor` printed:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
member group package old new note
|
|
40
|
+
--------- ---------- --------- -------------------------------- ---------------------------------- ---------------------------------------------------------------------
|
|
41
|
+
demo-app main httpx >=0.25,<1 >=0.28.1,<1
|
|
42
|
+
demo-app main numpy >=1.24 >=2.0.2 resolver forked (2.0.2, 2.2.6, 2.4.6, 2.5.4); floor set to the lowest
|
|
43
|
+
demo-app main rich == 13.7.0 - skipped: pinned with ==; pass --pins to move pins
|
|
44
|
+
demo-app main click >=8.0 ; python_version >= '3.10' >=8.5.1 ; python_version >= '3.10'
|
|
45
|
+
demo-app main demo-core - skipped: no version specifier; lockfloor does not invent one
|
|
46
|
+
demo-app extra:yaml pyyaml >=6.0 >=6.0.3
|
|
47
|
+
demo-app dev pytest >=7.0 >=8.4.2 resolver forked (8.4.2, 9.2.0); floor set to the lowest
|
|
48
|
+
demo-app dev packaging - skipped: no version specifier; lockfloor does not invent one
|
|
49
|
+
demo-core main attrs >=22.1 >=26.1.0
|
|
50
|
+
demo-core main idna ~=3.4 ~=3.20
|
|
51
|
+
|
|
52
|
+
7 to raise, 3 left alone, 0 already at the resolved version.
|
|
53
|
+
Dry run: no file was written. Pass --write to apply and re-lock.
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
The three files hashed the same before and after. `--write` then changed only the version digits on seven lines; the comments on those lines stayed:
|
|
57
|
+
|
|
58
|
+
```diff
|
|
59
|
+
- "numpy>=1.24", # the resolver forks this one across Python versions
|
|
60
|
+
+ "numpy>=2.0.2", # the resolver forks this one across Python versions
|
|
61
|
+
- "click>=8.0 ; python_version >= '3.10'",
|
|
62
|
+
+ "click>=8.5.1 ; python_version >= '3.10'",
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
`uv lock --check` passed afterwards, and `lockfloor --check` exited 0.
|
|
66
|
+
|
|
67
|
+
## Why the lowest fork
|
|
68
|
+
|
|
69
|
+
With `requires-python = ">=3.9"` uv locks numpy four times: 2.0.2 for 3.9, 2.2.6 for 3.10, 2.4.6 for 3.11, 2.5.4 for 3.12+. A tool that keeps one version per package name writes whichever it saw last. Writing `numpy>=2.5.4` into that fixture gives:
|
|
70
|
+
|
|
71
|
+
```
|
|
72
|
+
× No solution found when resolving dependencies for split (markers:
|
|
73
|
+
│ python_full_version == '3.11.*'):
|
|
74
|
+
╰─▶ Because the requested Python version (>=3.9) does not satisfy
|
|
75
|
+
Python>=3.12 and numpy==2.5.4 depends on Python>=3.12, we can conclude
|
|
76
|
+
that numpy==2.5.4 cannot be used.
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
lockfloor reads the per-edge versions uv records for each project and group, keeps the forks whose marker can hold together with the requirement's own marker (a requirement guarded by `python_version >= '3.12'` gets 2.5.4), and raises the floor to the lowest of those. Every supported environment can still install it.
|
|
80
|
+
|
|
81
|
+
## What it leaves alone, and says so
|
|
82
|
+
|
|
83
|
+
Every requirement it does not raise appears in the table with the reason:
|
|
84
|
+
|
|
85
|
+
| Requirement | Why it is skipped |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `rich==13.7.0` | A pin is a decision. `--pins` moves single `==` pins: the scratch copy sees `>=`, the result is written back as `==`. |
|
|
88
|
+
| `rich==13.*`, `x===1.0` | Wildcard and arbitrary-equality pins are never moved. |
|
|
89
|
+
| `attrs<24`, `numpy!=1.25` | No lower bound to raise. Upper bounds and exclusions are kept in every rewrite. |
|
|
90
|
+
| `attrs>22` | Strict `>`; rewrite it as `>=` if lockfloor should manage it. |
|
|
91
|
+
| `packaging` | No specifier. lockfloor does not invent one. |
|
|
92
|
+
| `tool @ git+https://...` | Direct reference. |
|
|
93
|
+
| `[tool.uv.sources]` path, git, url or workspace | Detected from the lock entry's source, not guessed from the manifest. |
|
|
94
|
+
|
|
95
|
+
`~=` keeps its width so its upper edge holds: `idna~=3.4` becomes `~=3.20`, still below 4. Local labels such as `+cpu` are dropped from the floor because PEP 440 forbids them in `>=`.
|
|
96
|
+
|
|
97
|
+
## Workspaces and groups
|
|
98
|
+
|
|
99
|
+
lockfloor finds the workspace root from any member, reads every member's `pyproject.toml`, and covers `[project.dependencies]`, every `[project.optional-dependencies]` extra, every PEP 735 `[dependency-groups]` group (skipping `{include-group = ...}` entries) and the older `[tool.uv] dev-dependencies`.
|
|
100
|
+
|
|
101
|
+
```
|
|
102
|
+
lockfloor httpx numpy # only these dependencies
|
|
103
|
+
lockfloor --package demo-core # only this workspace member
|
|
104
|
+
lockfloor --group dev --group extra:yaml
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
A filtered run asks uv to upgrade only the selected names (`--upgrade-package`), so the lock moves no further than the rows shown. An unfiltered run is `uv lock --upgrade`, which also moves transitive dependencies.
|
|
108
|
+
|
|
109
|
+
## How a write stays safe
|
|
110
|
+
|
|
111
|
+
1. The workspace is copied to a scratch directory (without `.venv`, `.git`, caches; capped at 512 MiB) and `uv lock --upgrade` runs there. When a path source climbs out of the workspace (`../shared`), the copy is made beside the workspace so that path still resolves.
|
|
112
|
+
2. Manifests are edited with tomlkit: comments, ordering, quote style and line endings survive.
|
|
113
|
+
3. The scratch `uv.lock` is adopted and `uv lock` re-records the new requirements.
|
|
114
|
+
4. If any step fails, every `pyproject.toml` and `uv.lock` is restored byte for byte.
|
|
115
|
+
|
|
116
|
+
lockfloor itself makes no network calls; uv does all the fetching. Each uv call has a timeout (`--timeout`, default 300 s).
|
|
117
|
+
|
|
118
|
+
## Exit codes
|
|
119
|
+
|
|
120
|
+
`0` done or nothing to raise, `1` with `--check` when a floor can be raised, `2` on an error (uv failed, unknown `--group` or `--package`, no `pyproject.toml`).
|
|
121
|
+
|
|
122
|
+
## Compared with uv-upx
|
|
123
|
+
|
|
124
|
+
[uv-upx](https://github.com/Alirex/uv_upgrade) also takes versions from `uv.lock` and preserves comments. From its source at the time of writing: its lock reader keeps [one version per package name](https://github.com/Alirex/uv_upgrade/blob/main/src/uv_upx/services/dependencies_from_project/parse_from_uv_lock_file.py), so a forked package gets the last version listed, the case shown above; it raises `>=` only and [ignores `~=`](https://github.com/Alirex/uv_upgrade/blob/main/src/uv_upx/services/dependency_up/constants/operators.py); it adds `>=locked` to bare names unless told not to; and its `--help` lists no CI exit-code mode. It has an interactive accept/reject mode, which lockfloor does not.
|
|
125
|
+
|
|
126
|
+
## Limits
|
|
127
|
+
|
|
128
|
+
- Raising floors is what an application wants. A library that should keep supporting old releases should not run `--write` blindly; read the table.
|
|
129
|
+
- Fork overlap is tested on a grid of CPython 3.8 to 3.15 on Linux (x86_64, aarch64), macOS (arm64, x86_64) and Windows. A marker on anything else (PyPy, an exotic platform) keeps every fork, which only lowers the floor.
|
|
130
|
+
- `[tool.uv] constraint-dependencies` and `override-dependencies`, and `requirements.txt` files, are not touched.
|
|
131
|
+
- A member whose metadata is built dynamically from git (setuptools-scm, hatch-vcs) may fail to lock in the scratch copy, which has no `.git`.
|
|
132
|
+
- There is no interactive mode and no major/minor/patch filter.
|
|
133
|
+
|
|
134
|
+
## Development
|
|
135
|
+
|
|
136
|
+
```
|
|
137
|
+
py -3.12 -m venv .venv && .venv/Scripts/python -m pip install -e . pytest
|
|
138
|
+
.venv/Scripts/python -m pytest # offline suite, fake resolver
|
|
139
|
+
.venv/Scripts/python -m pytest -m network # real uv against PyPI
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
MIT licensed.
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
lockfloor/__init__.py,sha256=up8ClEO0WIuyiOJyVmCojTVGhsk9LU8R7N7JeyFCGtU,109
|
|
2
|
+
lockfloor/__main__.py,sha256=qwuXyEG_eNN_spVWiHPASApHT5-ye76nEGCWDYBeWXA,61
|
|
3
|
+
lockfloor/cli.py,sha256=a8hGgDBf88VKZU_GvTJX3gsCfkXcGXszMs8_UDRkPlw,9440
|
|
4
|
+
lockfloor/lockfile.py,sha256=zvEvqqQvPM2zPH4LPgKDvg4j4Lzd_-nqm6D3rAaSB3Q,5938
|
|
5
|
+
lockfloor/manifest.py,sha256=pJ5kjm93a_MNLuzZEwV1XRIMibNUqIX7aXBKD12XSco,5542
|
|
6
|
+
lockfloor/plan.py,sha256=8Ib3p6uFmnuTmw2phLuyx8rMDTRM1mG01ZAdfHsTESw,2486
|
|
7
|
+
lockfloor/resolver.py,sha256=XN22HPPB2anscvgnmn4K3iYOG8hOvp-DDiMKqYISfOw,3744
|
|
8
|
+
lockfloor/specifier.py,sha256=Z2chzgrKxZgtVSt7ApK5UMu24PBmMIWdhEpMjh1RH_4,4941
|
|
9
|
+
lockfloor-0.1.0.dist-info/METADATA,sha256=HvzQBqKZOcraca-7IzCbTKfO0kH9wKnoKe9QlEa4MjY,9260
|
|
10
|
+
lockfloor-0.1.0.dist-info/WHEEL,sha256=2iZrJkDIkeDh0k9SikKMqFuRP-ua6mb4Ir1AsER4BEw,87
|
|
11
|
+
lockfloor-0.1.0.dist-info/entry_points.txt,sha256=-xEh4GXyIGuL2Hw3llqN-i5oRLeMqXWCOpESDzlvSds,49
|
|
12
|
+
lockfloor-0.1.0.dist-info/licenses/LICENSE,sha256=B3roCCeZDO06exjk5EZUsN6DaDNEXHFd8B54pONKgXQ,1070
|
|
13
|
+
lockfloor-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Zaid Ali Syed
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|