Skip to content

Instantly share code, notes, and snippets.

@yhoiseth
Last active March 22, 2025 15:57
Show Gist options
  • Save yhoiseth/c80c1e44a7036307e424fce616eed25e to your computer and use it in GitHub Desktop.
Save yhoiseth/c80c1e44a7036307e424fce616eed25e to your computer and use it in GitHub Desktop.
Upgrade all dependencies to their latest version using uv
#!/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()
@yhoiseth
Copy link
Author

yhoiseth commented Dec 4, 2024

See astral-sh/uv#1419 and astral-sh/uv#6794. By the time you read this, uv might have it built-in 🤞

@valankar
Copy link

valankar commented Dec 5, 2024

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.

@yhoiseth
Copy link
Author

yhoiseth commented Dec 5, 2024

Good point, thank you. Fixed :)

@noxan
Copy link

noxan commented Dec 12, 2024

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()

@KotlinIsland
Copy link

KotlinIsland commented Dec 12, 2024

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 of isinstance
  • 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()

@gbaian10
Copy link

@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?

@KotlinIsland
Copy link

KotlinIsland commented Dec 22, 2024

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

@gbaian10
Copy link

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.

@KalleDK
Copy link

KalleDK commented Jan 7, 2025

Please add tool.uv.sources to "this may delete" as it will, if only one package is from a source

@gbaian10
Copy link

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)

@rosmur
Copy link

rosmur commented Jan 21, 2025

Great!!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment