-
-
Save yhoiseth/c80c1e44a7036307e424fce616eed25e to your computer and use it in GitHub Desktop.
#!/usr/bin/env python | |
# https://gist.github.com/yhoiseth/c80c1e44a7036307e424fce616eed25e | |
from typing import Any | |
from re import match, Match | |
import toml | |
import subprocess | |
def main() -> None: | |
with open("pyproject.toml", "r") as file: | |
pyproject: dict[str, Any] = toml.load(file) | |
dependencies: list[str] = pyproject["project"]["dependencies"] | |
package_name_pattern = r"^[a-zA-Z0-9\-]+" | |
for dependency in dependencies: | |
package_match = match(package_name_pattern, dependency) | |
assert isinstance(package_match, Match) | |
package = package_match.group(0) | |
uv("remove", package) | |
uv("add", package) | |
def uv(command: str, package: str) -> None: | |
subprocess.run(["uv", command, package]) | |
if __name__ == "__main__": | |
main() |
Thanks for the script. Just a note that if you have a ==
dependency, it will throw a ValueError on split. Might be best to just catch that.
Good point, thank you. Fixed :)
script discards extras, e.g. whitenoise[brotli]
, here an adjusted version
import subprocess
from re import Match, match
from typing import Any
import toml
def main() -> None:
with open("pyproject.toml", "r") as file:
pyproject: dict[str, Any] = toml.load(file)
dependencies: list[str] = pyproject["project"]["dependencies"]
package_pattern = r"^([a-zA-Z0-9\-]+)(?:\[([\w,\s-]+)\])?"
for dependency in dependencies:
package_match = match(package_pattern, dependency)
assert isinstance(package_match, Match)
package = package_match.group(1)
extra = package_match.group(2)
uv("remove", package)
uv("add", package, extra)
def uv(command: str, package: str, extra: str | None = None) -> None:
args = ["uv", command, package]
if extra:
args.append("--extra")
args.append(extra)
subprocess.run(args)
if __name__ == "__main__":
main()
this doesn't account for dependency-groups
or extras, also it could be written a little more succinctly:
- use
pathlib
- use
chech_call
- use
re.compile
- assert the
Match | None
directly instead ofisinstance
- use
tomllib
- do it in bulk, instead of per dependency
import re
import subprocess
import tomllib
from pathlib import Path
def uv(subcommand: str, packages: list[str], group: str | None):
extra_arguments = []
if group:
extra_arguments.extend(["--group", group])
subprocess.check_call(["uv", subcommand, *packages, "--no-sync"] + extra_arguments)
def main():
"""WARNING:
from the `pyproject.toml` file, this may delete:
- comments
- upper bounds etc
- markers
- ordering of dependencies
- tool.uv.sources
"""
pyproject = tomllib.loads(Path("pyproject.toml").read_text())
package_name_pattern = re.compile(r"^([-a-zA-Z\d]+)(\[[-a-zA-Z\d,]+])?")
for group, dependencies in {
None: pyproject["project"]["dependencies"],
**pyproject["dependency-groups"],
}.items():
to_remove = []
to_add = []
for dependency in dependencies:
package_match = package_name_pattern.match(dependency)
assert package_match, f"invalid package name '{dependency}'"
package, extras = package_match.groups()
to_remove.append(package)
to_add.append(f"{package}{extras or ''}")
uv("remove", to_remove, group=group)
uv("add", to_add, group=group)
subprocess.check_call(["uv", "sync"])
if __name__ == "__main__":
main()
@KotlinIsland
I really like your code; it perfectly achieved my goal of updating the pyproject.toml
.
However, there's a small issue. After executing the update, the packages seem to be displayed in alphabetical order, but my original sorting was custom. Is there a good way to keep the original order after the update?
true, It's because uv will add the batched results in alphabetical order, but if they are added one by one then it would appended on the end, i think most likely preserving original order
I considered preserving the original order before starting, and then restoring the sequence after completing the batch execution.
However, this involves the issue of reading the original different groups and extras. I wanted to ask if there are any good solutions for this.
Please add tool.uv.sources to "this may delete" as it will, if only one package is from a source
I wrote a command-line program using typer
.
It can update the version in pyproject.toml
while preserving the original order.
# ruff: noqa: S603
import functools
import subprocess # noqa: S404
import tomllib
from dataclasses import dataclass
from pathlib import Path
from typing import Annotated, Literal, Self
import tomlkit
import typer
from tomlkit.items import Array as TomlArray
type DependencyMap = dict[str | None, list[str]] # [GroupName, Dependencies]
@dataclass
class PackageSpec:
name: str
extras: str | None
@classmethod
def from_dependency(cls, dependency: str) -> Self:
"""parse package spec from dependency string
examples:
- requests>=1.0
- fastapi[all]>=0.115.4
- pandas[excel,plot]>=2.2.2
- sqlacodegen==3.0.0rc5
"""
for separator in (">=", "==", "<=", "~=", ">", "<", "!="):
if separator in dependency:
name_part = dependency.split(separator)[0].strip()
break
else:
name_part = dependency.strip()
if "[" in name_part:
name, extras = name_part.split("[", 1)
extras = f"[{extras}"
else:
name = name_part
extras = None
return cls(name=name, extras=extras)
def __str__(self) -> str:
"""convert to dependency string"""
return f"{self.name}{self.extras or ''}"
def get_original_order(pyproject_path: Path) -> DependencyMap:
with pyproject_path.open("rb") as f:
pyproject = tomllib.load(f)
return {
None: pyproject["project"]["dependencies"],
**pyproject.get("dependency-groups", {}),
}
def restore_order(pyproject_path: Path, original_orders: DependencyMap) -> None:
"""restore the order of dependencies in pyproject.toml"""
def create_toml_array(dependencies: list[str]) -> TomlArray:
array = tomlkit.array()
array.multiline(True)
array.extend(dependencies)
return array
with pyproject_path.open("rb") as updated_file:
updated_data = tomllib.load(updated_file)
with pyproject_path.open("r", encoding="utf-8") as f:
doc = tomlkit.load(f)
# update main dependencies
updated_deps = {
PackageSpec.from_dependency(dep).name: dep
for dep in updated_data["project"]["dependencies"]
}
new_deps = create_toml_array(
[
updated_deps[PackageSpec.from_dependency(orig_dep).name]
for orig_dep in original_orders[None]
if PackageSpec.from_dependency(orig_dep).name in updated_deps
]
)
doc["project"]["dependencies"] = new_deps # type: ignore[index]
# update dependency groups
if "dependency-groups" in updated_data:
for group, orig_deps in original_orders.items():
if group is None or group not in updated_data["dependency-groups"]:
continue
updated_group_deps = {
PackageSpec.from_dependency(dep).name: dep
for dep in updated_data["dependency-groups"][group]
}
new_group_deps = create_toml_array(
[
updated_group_deps[PackageSpec.from_dependency(orig_dep).name]
for orig_dep in orig_deps
if PackageSpec.from_dependency(orig_dep).name in updated_group_deps
]
)
doc["dependency-groups"][group] = new_group_deps # type: ignore[index]
with pyproject_path.open("w", encoding="utf-8") as f:
tomlkit.dump(doc, f)
def print_format_command(command: list[str]) -> None:
cmd = command.copy()
cmd[0] = typer.style(cmd[0], fg=typer.colors.GREEN) # uv
cmd[1] = typer.style(cmd[1], fg=typer.colors.YELLOW) # action
flag_idx = next((i for i, v in enumerate(cmd) if v.startswith("--")), len(cmd))
# package name
cmd[2:flag_idx] = [typer.style(t, bold=True) for t in cmd[2:flag_idx]]
# action options
cmd[flag_idx:] = [typer.style(t, fg=typer.colors.CYAN) for t in cmd[flag_idx:]]
print(" ".join(cmd))
def uv_action(
action: Literal["add", "remove", "sync", "lock"],
verbose: bool = False,
*,
package_spec: list[PackageSpec] | None = None,
group: str | None = None,
) -> None:
if action == "sync":
command = ["uv", "sync", "--all-groups"]
print("=" * 40)
elif action == "lock":
command = ["uv", "lock", "--upgrade"]
else:
if package_spec is None:
raise ValueError("package_spec is required")
packages = [str(pkg) if action == "add" else pkg.name for pkg in package_spec]
group_arg = ["--group", group] if group else []
command = ["uv", action, *packages, "--no-sync", *group_arg]
if verbose:
print_format_command(command)
subprocess.check_call(command)
elif action in {"sync", "lock"}:
subprocess.check_call(command)
else:
subprocess.check_call(command, stderr=subprocess.DEVNULL)
def run_uv_command(all_dependencies: DependencyMap, verbose: bool = False) -> None:
run_uv_action = functools.partial(uv_action, verbose=verbose)
run_uv_action("lock")
for group, dependencies in all_dependencies.items():
# filter out packages with pinned versions
packages = [
PackageSpec.from_dependency(dep) for dep in dependencies if "==" not in dep
]
run_uv_action("remove", package_spec=packages, group=group)
run_uv_action("add", package_spec=packages, group=group)
run_uv_action("sync")
def main(
path: Annotated[Path, typer.Argument()] = Path("./pyproject.toml"),
sort_deps: Annotated[
bool,
typer.Option("--sort", "-S", help="sort dependencies in pyproject.toml"),
] = False,
verbose: Annotated[bool, typer.Option("--verbose", "-V")] = False,
) -> None:
if path.name != "pyproject.toml":
raise typer.BadParameter("file must be pyproject.toml")
original_orders = get_original_order(path)
run_uv_command(original_orders, verbose)
if not sort_deps:
restore_order(path, original_orders)
if __name__ == "__main__":
typer.run(main)
Great!!
See astral-sh/uv#1419 and astral-sh/uv#6794. By the time you read this, uv might have it built-in 🤞