lockfloor 0.1.0__tar.gz
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-0.1.0/.gitignore +5 -0
- lockfloor-0.1.0/HANDOFF.md +12 -0
- lockfloor-0.1.0/LICENSE +21 -0
- lockfloor-0.1.0/PKG-INFO +142 -0
- lockfloor-0.1.0/README.md +123 -0
- lockfloor-0.1.0/examples/forked-workspace/packages/core/pyproject.toml +12 -0
- lockfloor-0.1.0/examples/forked-workspace/pyproject.toml +27 -0
- lockfloor-0.1.0/pyproject.toml +40 -0
- lockfloor-0.1.0/src/lockfloor/__init__.py +3 -0
- lockfloor-0.1.0/src/lockfloor/__main__.py +5 -0
- lockfloor-0.1.0/src/lockfloor/cli.py +213 -0
- lockfloor-0.1.0/src/lockfloor/lockfile.py +146 -0
- lockfloor-0.1.0/src/lockfloor/manifest.py +145 -0
- lockfloor-0.1.0/src/lockfloor/plan.py +68 -0
- lockfloor-0.1.0/src/lockfloor/resolver.py +95 -0
- lockfloor-0.1.0/src/lockfloor/specifier.py +127 -0
- lockfloor-0.1.0/tests/conftest.py +73 -0
- lockfloor-0.1.0/tests/test_cli.py +291 -0
- lockfloor-0.1.0/tests/test_lockfile.py +60 -0
- lockfloor-0.1.0/tests/test_real_uv.py +141 -0
- lockfloor-0.1.0/tests/test_specifier.py +65 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# lockfloor handoff (2026-11-10)
|
|
2
|
+
|
|
3
|
+
State: pushed to private zaidwhy/lockfloor. 34 offline + 3 network tests. Targets uv#6794: raises pyproject lower bounds to uv-resolved versions, lowest version across resolver forks.
|
|
4
|
+
|
|
5
|
+
Next: PyPI publish and any comment on uv#6794 are Zaid's call (outward-facing).
|
|
6
|
+
|
|
7
|
+
## Release (prepared 2026-11-10, not uploaded)
|
|
8
|
+
`uv build` produces lockfloor-0.1.0 sdist + wheel; `twine check` passes; the wheel installs and runs `lockfloor --help` in a clean env. The name is free on PyPI (JSON API 404 on 2026-11-10).
|
|
9
|
+
Recommended first of the four Python packages (uv#6794 has the most demand). Steps for Zaid, in order:
|
|
10
|
+
1. Done 2026-11-10: repo public, `[project.urls]` added.
|
|
11
|
+
2. `cd "C:/Users/Asus/projects/lockfloor" && uv build && uvx twine upload dist/*` (asks for a PyPI API token).
|
|
12
|
+
3. Comment on astral-sh/uv#6794 in his own words.
|
lockfloor-0.1.0/LICENSE
ADDED
|
@@ -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.
|
lockfloor-0.1.0/PKG-INFO
ADDED
|
@@ -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,123 @@
|
|
|
1
|
+
# lockfloor
|
|
2
|
+
|
|
3
|
+
Raise the lower bounds in `pyproject.toml` to the versions uv's resolver actually locks together.
|
|
4
|
+
|
|
5
|
+
**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.
|
|
6
|
+
|
|
7
|
+
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.
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
uvx lockfloor # dry run: print the plan, write nothing
|
|
11
|
+
uvx lockfloor --write # rewrite pyproject.toml files, then re-lock
|
|
12
|
+
uvx lockfloor --check # exit 1 if any floor lags the lock (CI)
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## What a run looks like
|
|
16
|
+
|
|
17
|
+
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:
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
member group package old new note
|
|
21
|
+
--------- ---------- --------- -------------------------------- ---------------------------------- ---------------------------------------------------------------------
|
|
22
|
+
demo-app main httpx >=0.25,<1 >=0.28.1,<1
|
|
23
|
+
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
|
|
24
|
+
demo-app main rich == 13.7.0 - skipped: pinned with ==; pass --pins to move pins
|
|
25
|
+
demo-app main click >=8.0 ; python_version >= '3.10' >=8.5.1 ; python_version >= '3.10'
|
|
26
|
+
demo-app main demo-core - skipped: no version specifier; lockfloor does not invent one
|
|
27
|
+
demo-app extra:yaml pyyaml >=6.0 >=6.0.3
|
|
28
|
+
demo-app dev pytest >=7.0 >=8.4.2 resolver forked (8.4.2, 9.2.0); floor set to the lowest
|
|
29
|
+
demo-app dev packaging - skipped: no version specifier; lockfloor does not invent one
|
|
30
|
+
demo-core main attrs >=22.1 >=26.1.0
|
|
31
|
+
demo-core main idna ~=3.4 ~=3.20
|
|
32
|
+
|
|
33
|
+
7 to raise, 3 left alone, 0 already at the resolved version.
|
|
34
|
+
Dry run: no file was written. Pass --write to apply and re-lock.
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
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:
|
|
38
|
+
|
|
39
|
+
```diff
|
|
40
|
+
- "numpy>=1.24", # the resolver forks this one across Python versions
|
|
41
|
+
+ "numpy>=2.0.2", # the resolver forks this one across Python versions
|
|
42
|
+
- "click>=8.0 ; python_version >= '3.10'",
|
|
43
|
+
+ "click>=8.5.1 ; python_version >= '3.10'",
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`uv lock --check` passed afterwards, and `lockfloor --check` exited 0.
|
|
47
|
+
|
|
48
|
+
## Why the lowest fork
|
|
49
|
+
|
|
50
|
+
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:
|
|
51
|
+
|
|
52
|
+
```
|
|
53
|
+
× No solution found when resolving dependencies for split (markers:
|
|
54
|
+
│ python_full_version == '3.11.*'):
|
|
55
|
+
╰─▶ Because the requested Python version (>=3.9) does not satisfy
|
|
56
|
+
Python>=3.12 and numpy==2.5.4 depends on Python>=3.12, we can conclude
|
|
57
|
+
that numpy==2.5.4 cannot be used.
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
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.
|
|
61
|
+
|
|
62
|
+
## What it leaves alone, and says so
|
|
63
|
+
|
|
64
|
+
Every requirement it does not raise appears in the table with the reason:
|
|
65
|
+
|
|
66
|
+
| Requirement | Why it is skipped |
|
|
67
|
+
|---|---|
|
|
68
|
+
| `rich==13.7.0` | A pin is a decision. `--pins` moves single `==` pins: the scratch copy sees `>=`, the result is written back as `==`. |
|
|
69
|
+
| `rich==13.*`, `x===1.0` | Wildcard and arbitrary-equality pins are never moved. |
|
|
70
|
+
| `attrs<24`, `numpy!=1.25` | No lower bound to raise. Upper bounds and exclusions are kept in every rewrite. |
|
|
71
|
+
| `attrs>22` | Strict `>`; rewrite it as `>=` if lockfloor should manage it. |
|
|
72
|
+
| `packaging` | No specifier. lockfloor does not invent one. |
|
|
73
|
+
| `tool @ git+https://...` | Direct reference. |
|
|
74
|
+
| `[tool.uv.sources]` path, git, url or workspace | Detected from the lock entry's source, not guessed from the manifest. |
|
|
75
|
+
|
|
76
|
+
`~=` 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 `>=`.
|
|
77
|
+
|
|
78
|
+
## Workspaces and groups
|
|
79
|
+
|
|
80
|
+
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`.
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
lockfloor httpx numpy # only these dependencies
|
|
84
|
+
lockfloor --package demo-core # only this workspace member
|
|
85
|
+
lockfloor --group dev --group extra:yaml
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
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.
|
|
89
|
+
|
|
90
|
+
## How a write stays safe
|
|
91
|
+
|
|
92
|
+
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.
|
|
93
|
+
2. Manifests are edited with tomlkit: comments, ordering, quote style and line endings survive.
|
|
94
|
+
3. The scratch `uv.lock` is adopted and `uv lock` re-records the new requirements.
|
|
95
|
+
4. If any step fails, every `pyproject.toml` and `uv.lock` is restored byte for byte.
|
|
96
|
+
|
|
97
|
+
lockfloor itself makes no network calls; uv does all the fetching. Each uv call has a timeout (`--timeout`, default 300 s).
|
|
98
|
+
|
|
99
|
+
## Exit codes
|
|
100
|
+
|
|
101
|
+
`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`).
|
|
102
|
+
|
|
103
|
+
## Compared with uv-upx
|
|
104
|
+
|
|
105
|
+
[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.
|
|
106
|
+
|
|
107
|
+
## Limits
|
|
108
|
+
|
|
109
|
+
- Raising floors is what an application wants. A library that should keep supporting old releases should not run `--write` blindly; read the table.
|
|
110
|
+
- 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.
|
|
111
|
+
- `[tool.uv] constraint-dependencies` and `override-dependencies`, and `requirements.txt` files, are not touched.
|
|
112
|
+
- 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`.
|
|
113
|
+
- There is no interactive mode and no major/minor/patch filter.
|
|
114
|
+
|
|
115
|
+
## Development
|
|
116
|
+
|
|
117
|
+
```
|
|
118
|
+
py -3.12 -m venv .venv && .venv/Scripts/python -m pip install -e . pytest
|
|
119
|
+
.venv/Scripts/python -m pytest # offline suite, fake resolver
|
|
120
|
+
.venv/Scripts/python -m pytest -m network # real uv against PyPI
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
MIT licensed.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "demo-app"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
requires-python = ">=3.9"
|
|
5
|
+
dependencies = [
|
|
6
|
+
# HTTP client; stay below 1.0 until the migration lands
|
|
7
|
+
"httpx>=0.25,<1",
|
|
8
|
+
"numpy>=1.24", # the resolver forks this one across Python versions
|
|
9
|
+
'rich == 13.7.0', # pinned on purpose
|
|
10
|
+
"click>=8.0 ; python_version >= '3.10'",
|
|
11
|
+
"demo-core",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.optional-dependencies]
|
|
15
|
+
yaml = ["pyyaml>=6.0"]
|
|
16
|
+
|
|
17
|
+
[dependency-groups]
|
|
18
|
+
dev = [
|
|
19
|
+
"pytest>=7.0",
|
|
20
|
+
"packaging",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[tool.uv.sources]
|
|
24
|
+
demo-core = { workspace = true }
|
|
25
|
+
|
|
26
|
+
[tool.uv.workspace]
|
|
27
|
+
members = ["packages/*"]
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "lockfloor"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Raise pyproject.toml lower bounds to the versions uv's resolver locks together"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [{ name = "Zaid Ali Syed", email = "sidzaid72@gmail.com" }]
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
keywords = ["uv", "pyproject", "dependencies", "upgrade", "lockfile"]
|
|
11
|
+
classifiers = [
|
|
12
|
+
"Environment :: Console",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Topic :: Software Development :: Build Tools",
|
|
15
|
+
]
|
|
16
|
+
dependencies = [
|
|
17
|
+
"packaging>=24.0",
|
|
18
|
+
"tomlkit>=0.12.0",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Repository = "https://github.com/zaidwhy/lockfloor"
|
|
23
|
+
Issues = "https://github.com/zaidwhy/lockfloor/issues"
|
|
24
|
+
"uv issue" = "https://github.com/astral-sh/uv/issues/6794"
|
|
25
|
+
|
|
26
|
+
[project.scripts]
|
|
27
|
+
lockfloor = "lockfloor.cli:main"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
[dependency-groups]
|
|
31
|
+
dev = ["pytest>=8.0"]
|
|
32
|
+
|
|
33
|
+
[build-system]
|
|
34
|
+
requires = ["hatchling>=1.27"]
|
|
35
|
+
build-backend = "hatchling.build"
|
|
36
|
+
|
|
37
|
+
[tool.pytest.ini_options]
|
|
38
|
+
testpaths = ["tests"]
|
|
39
|
+
markers = ["network: runs the real uv resolver against PyPI"]
|
|
40
|
+
addopts = "-m 'not network'"
|
|
@@ -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)
|