Rebuilds a uv project's pyproject.toml so every dependency gets freshly
resolved instead of dragging along old version pins.
- Read
pyproject.toml, collect the dependencies without their version specifiers (extras and environment markers are kept;include-groupentries independency-groupsare expanded for uv, and stay as they are in the file) - Back up
pyproject.tomlanduv.lockinto.uv-refresh-backup/<timestamp>/ - Run
uv init --bare+uv add <packages>in a temp directory next to the project, with the project's[tool.uv]settings (indexes, sources, constraints) and.python-version-- the realpyproject.tomlstays untouched the whole time - Merge only
dependencies/optional-dependencies/dependency-groupsfrom the result into a copy of the ORIGINALpyproject.toml-- everything else stays untouched - Atomically swap the result in place of the old
pyproject.toml/uv.lock - Report which version bounds actually moved
If any step fails -- including Ctrl+C -- the real pyproject.toml was never
touched, since the whole build happened in the temp directory. The backup
is kept around as an extra reference regardless. Should something go wrong
right after the swap, the tool says the file was already replaced instead.
uv tool install uv-refresh
Makes uv-refresh permanently available as a command (globally on PATH).
For a one-off test run, without installing anything:
uvx uv-refresh --dry-run
Or straight from the repo, e.g. to try an unreleased version:
uv tool install git+https://github.com/Zorakidd/uv-refresh
uv-refresh --dry-run # just show what would happen, change nothing
uv-refresh # runs with a confirmation prompt
uv-refresh -y # no confirmation prompt
| Flag | Effect |
|---|---|
--path PATH |
different project directory (default: current) |
--dry-run |
only show what would happen, touch nothing |
-y, --yes |
run without asking for confirmation |
-v, --verbose |
print the full new pyproject.toml at the end |
-q, --quiet |
only print warnings/errors -- also quiets uv itself |
--timeout SECONDS |
timeout per uv call, default 300s, at most one day |
--raw |
add packages with no version bound at all |
--bounds {lower,major,minor,exact} |
kind of version bound uv add sets |
--keep-lock |
keep uv.lock (uv will then prefer the old versions!) |
--keep-backups N |
how many past backups to keep, oldest deleted first (default: 5, 0 keeps all) |
--no-groups |
ignore optional-dependencies and dependency-groups |
--full |
also bump requires-python and re-pin .python-version to the newest installed Python |
--drop-extras |
shrink fastapi[standard] down to fastapi |
--drop-markers |
drop environment markers |
--version |
show the uv-refresh version |
A run ends with the bounds it actually changed, so there's no need to diff the backup by hand:
Updated 2 of 3 dependencies:
iniconfig >=1.0 -> >=2.3.0
packaging (none) -> >=26.3
(1 unchanged)
Done.
A package listed more than once in a section, e.g. once per marker, gets
one row with all of its bounds (numpy >=1.26, >=2 -> >=1.26.4, >=2.3). If
nothing moved, it says so instead -- also when --no-groups removed
packages at the same time. --verbose additionally prints the whole new
pyproject.toml; --quiet prints neither.
Only dependencies, optional-dependencies and dependency-groups are
rewritten. Everything else -- description, readme, license,
authors, keywords, [project.urls], [project.scripts],
[build-system], [tool.*] and so on -- stays unchanged, because it's
never deleted: the tool only builds a minimal pyproject.toml temporarily
to resolve versions, then takes just the freshly resolved dependency lists
from it and writes those back into a copy of the original file. Within those
lists, each entry only gets its new bound in place: comments (above an entry,
after it, or between two groups) and the order of the entries stay as they
were, and so does your spelling of the package name, extras and markers --
uv writes its own (Typing_Extensions as typing-extensions,
python_version < '3.12' as python_full_version < '3.12'), but only the
bound is taken from it. A package listed several times with different
markers keeps each bound with the marker it belongs to. include-group
entries stay as they are. Only --drop-extras/--drop-markers change an
entry beyond its bound, since that's what they're for.
Direct references (pkg @ git+https://...) have no bound to refresh and are
kept exactly as written -- also as pkg @ https://...; marker without a
space before the ;, which uv accepts although PEP 508 doesn't. The file
keeps its line endings and a UTF-8 BOM, if it had one.
Direct references may carry credentials (https://user:token@...). The
output masks them as https://***@..., and in a git repository (including
worktrees, submodules and projects in a subdirectory) the backup and temp
directories are added to .gitignore -- unless git already ignores them,
e.g. through the repository's root .gitignore.
One exception: with --no-groups, any existing
optional-dependencies/dependency-groups are intentionally removed (the
tool warns beforehand). If [tool.uv] still names one of them (a
default-groups list, or a source limited to an extra/group),
--no-groups is refused up front instead, since uv rejects references to
extras/groups that no longer exist. Individual entries that can't be
interpreted as a PEP 508 string are skipped and reported per entry.
--full additionally bumps requires-python to the newest already
installed Python it can find (uv python list --only-installed -- it
never triggers a download on its own), e.g. >=3.11 becomes >=3.13. That
bump is part of the same atomic pyproject.toml rebuild as the dependency
refresh, so it's covered by the same backup/all-or-nothing guarantee.
It only ever goes up: pre-release Pythons (e.g. 3.15.0rc2) are ignored,
a requires-python that already starts at that minor version or above is
kept as is, and if the newest installed Python doesn't satisfy a kept
requires-python (older than its floor, or e.g. an exact ==3.13 with
3.13.5 installed), --full leaves both requires-python and
.python-version alone (with a warning) and just does the normal refresh.
Only once that rebuild has landed does --full re-pin .python-version via
uv python pin to that same version. This runs after the rebuild on
purpose: uv python pin refuses to write anything if the target version
doesn't satisfy requires-python, and by pinning after the bump above, it's
checked against the new requires-python -- so jumping to a newer Python
than the project previously allowed still works. If the pin itself then
fails, the dependency refresh and requires-python bump are kept regardless
(they already succeeded); only .python-version is left as it was.
A bare uv init --python=<requires-python floor> only writes that floor
(e.g. >=3.11) into requires-python, not an exact interpreter pin. Left
alone, the temp uv add would then run against the newest installed
Python satisfying that floor, even if the real project's own
.python-version pins an older one -- and fail on any dependency (e.g.
torch) that has no wheel for that newer version, despite the real project
working fine on its actual pinned interpreter. So uv-refresh copies the pin
into the temp directory before running uv add. --full is the one
exception: when it re-pins, the temp build already uses the new version
instead of the old pin.
The temp directory only ever holds pyproject.toml (plus uv.lock and
.python-version), one level below the project. So uv-refresh refuses these
projects up front, before any backup is made, instead of failing halfway with
a uv or build-backend error:
- uv workspaces (
[tool.uv.workspace]) andworkspace = truesources pathsources with a relative path, and${PROJECT_ROOT}references (absolute paths work fine)- a relative local path in
[[tool.uv.index]],index-url,extra-index-urlorfind-links(absolute paths and URLs work fine) - a dynamic
version,dependencies,optional-dependenciesorrequires-python(e.g. setuptools-scm, hatch-vcs), because uv would have to build the project to lock it, and its files aren't in the temp directory
An exact requires-python = "==3.14" can make the refresh fail: uv may pick
a newer 3.14.x interpreter to lock with and then reject it. Plain uv lock
fails the same way on such a project, so that's not something uv-refresh can
fix.