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.
@@ -0,0 +1,5 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.egg-info/
4
+ dist/
5
+ .pytest_cache/
@@ -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.
@@ -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.
@@ -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,12 @@
1
+ [project]
2
+ name = "demo-core"
3
+ version = "0.1.0"
4
+ requires-python = ">=3.9"
5
+ dependencies = [
6
+ "attrs>=22.1",
7
+ "idna~=3.4",
8
+ ]
9
+
10
+ [build-system]
11
+ requires = ["hatchling"]
12
+ build-backend = "hatchling.build"
@@ -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,3 @@
1
+ """Raise pyproject.toml lower bounds to the versions uv's resolver locks together."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from lockfloor.cli import main
4
+
5
+ sys.exit(main())
@@ -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)