uv-upx 0.2.0__py3-none-any.whl

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.
uv_upx/__init__.py ADDED
@@ -0,0 +1,5 @@
1
+ from .main import main
2
+
3
+ __all__ = [
4
+ "main",
5
+ ]
uv_upx/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from uv_upx.main import main
2
+
3
+ if __name__ == "__main__":
4
+ main()
uv_upx/cli/__init__.py ADDED
File without changes
uv_upx/cli/main.py ADDED
@@ -0,0 +1,37 @@
1
+ import pathlib # noqa: TC003
2
+ from typing import Annotated
3
+
4
+ import typer
5
+
6
+ from uv_upx.services.normalize_and_check_path_to_pyproject import normalize_and_check_path_to_project_root
7
+ from uv_upx.services.run_updater import run_updater
8
+
9
+ app = typer.Typer(
10
+ pretty_exceptions_enable=False,
11
+ )
12
+
13
+
14
+ @app.command()
15
+ def run(
16
+ *,
17
+ project_root_path: Annotated[
18
+ pathlib.Path | None,
19
+ typer.Option(
20
+ "--project",
21
+ "-p",
22
+ help="Path to project root directory. Use current working directory if not specified.",
23
+ ),
24
+ ] = None,
25
+ #
26
+ dry_run: Annotated[bool, typer.Option("--dry-run", help="Show changes without writing file")] = False,
27
+ #
28
+ verbose: Annotated[bool, typer.Option("--verbose", help="Show more output")] = False,
29
+ ) -> None:
30
+ """Update pyproject.toml dependencies to latest PyPI releases."""
31
+ path = normalize_and_check_path_to_project_root(project_root_path)
32
+
33
+ run_updater(
34
+ project_root_path=path,
35
+ dry_run=dry_run,
36
+ verbose=verbose,
37
+ )
@@ -0,0 +1,5 @@
1
+ from .init_logging import init_logging
2
+
3
+ __all__ = [
4
+ "init_logging",
5
+ ]
@@ -0,0 +1,8 @@
1
+ import logging
2
+
3
+
4
+ def init_logging() -> None:
5
+ logging.basicConfig(
6
+ level=logging.DEBUG,
7
+ format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
8
+ )
uv_upx/main.py ADDED
@@ -0,0 +1,7 @@
1
+ from uv_upx.cli.main import app
2
+ from uv_upx.logging_custom import init_logging
3
+
4
+
5
+ def main() -> None:
6
+ init_logging()
7
+ app()
File without changes
@@ -0,0 +1,36 @@
1
+ from pydantic import BaseModel
2
+
3
+ from uv_upx.services.get_deps_from_project import DependencyName
4
+
5
+
6
+ class VersionConstraint(BaseModel):
7
+ operator: str
8
+ version: str
9
+
10
+
11
+ class DependencyParsed(BaseModel):
12
+ # https://peps.python.org/pep-0508/
13
+
14
+ dependency_name: DependencyName
15
+ """Dependency name (e.g., requests)"""
16
+
17
+ extras: list[str] | None
18
+ """Extras (e.g., [dev])"""
19
+
20
+ version_constraints: list[VersionConstraint]
21
+ """Version constraints (e.g., >=1.2.3, ==4.5.6)"""
22
+
23
+ marker: str | None
24
+ """Environment marker (after ;)"""
25
+
26
+ def get_full_spec(self) -> str:
27
+ parts: list[str] = [self.dependency_name]
28
+ if self.extras:
29
+ extras_str = ",".join(self.extras)
30
+ parts.append(f"[{extras_str}]")
31
+ if self.version_constraints:
32
+ vc_strs = [f"{vc.operator}{vc.version}" for vc in self.version_constraints]
33
+ parts.append(",".join(vc_strs))
34
+ if self.marker:
35
+ parts.append(f"; {self.marker}")
36
+ return "".join(parts)
uv_upx/py.typed ADDED
File without changes
File without changes
@@ -0,0 +1,62 @@
1
+ import pathlib
2
+ from typing import Any, cast
3
+
4
+ from pydantic import BaseModel, ConfigDict
5
+ from tomlkit import TOMLDocument
6
+
7
+ from uv_upx.services.get_pyproject_paths_by_globs import get_pyproject_paths_by_globs
8
+ from uv_upx.services.normalize_and_check_path_to_pyproject import get_and_check_path_to_pyproject
9
+ from uv_upx.services.save_load_toml import load_toml
10
+
11
+
12
+ class PyProjectWrapper(BaseModel):
13
+ path: pathlib.Path
14
+ data: TOMLDocument
15
+
16
+ model_config = ConfigDict(
17
+ arbitrary_types_allowed=True,
18
+ )
19
+
20
+
21
+ class PyProjectsRegistry(BaseModel):
22
+ items: list[PyProjectWrapper]
23
+
24
+
25
+ def get_all_pyprojects(
26
+ project_root_path: pathlib.Path,
27
+ ) -> PyProjectsRegistry:
28
+ """Find all pyproject.toml files in the project tree.
29
+
30
+ Use `workspaces` from uv.
31
+ """
32
+ items: list[PyProjectWrapper] = []
33
+
34
+ root_pyproject_path = get_and_check_path_to_pyproject(project_root_path)
35
+ root_pyproject_data = load_toml(root_pyproject_path)
36
+ items.append(PyProjectWrapper(path=root_pyproject_path, data=root_pyproject_data))
37
+
38
+ # Get workspaces_config
39
+ # https://docs.astral.sh/uv/concepts/projects/workspaces/
40
+
41
+ # Example:
42
+ # [tool.uv.workspace]
43
+ # members = ["packages/*"]
44
+ # exclude = ["packages/seeds"]
45
+
46
+ # TODO: (?) Improve this place for efficiency
47
+
48
+ workspaces_config: dict[str, Any] = root_pyproject_data.get("tool", {}).get("uv", {}).get("workspace", {}) # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
49
+ members_i_relative_glob_based: list[str] = cast("list[str]", workspaces_config.get("members", [])) # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
50
+ exclude_i_relative_glob_based: list[str] = cast("list[str]", workspaces_config.get("exclude", [])) # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
51
+
52
+ # Use glob-based pattern matching to find all pyproject.toml files in the workspace
53
+ members_paths_set = get_pyproject_paths_by_globs(project_root_path, members_i_relative_glob_based)
54
+ exclude_paths_set = get_pyproject_paths_by_globs(project_root_path, exclude_i_relative_glob_based)
55
+
56
+ result_paths_set = members_paths_set - exclude_paths_set
57
+
58
+ for path in result_paths_set:
59
+ data = load_toml(path)
60
+ items.append(PyProjectWrapper(path=path, data=data))
61
+
62
+ return PyProjectsRegistry(items=items)
@@ -0,0 +1,141 @@
1
+ import subprocess
2
+ from typing import TYPE_CHECKING, NewType
3
+
4
+ from uv_upx.services.normalize_and_check_path_to_pyproject import get_and_check_path_to_uv_lock
5
+ from uv_upx.services.save_load_toml import load_toml
6
+
7
+ if TYPE_CHECKING:
8
+ import pathlib
9
+
10
+ DependencyName = NewType("DependencyName", str)
11
+ Version = NewType("Version", str)
12
+
13
+ type DependenciesRegistry = dict[DependencyName, Version]
14
+
15
+
16
+ def parse_from_pip(
17
+ workdir: pathlib.Path,
18
+ ) -> DependenciesRegistry:
19
+ data = subprocess.run(
20
+ # uv pip list --outdated
21
+ ["uv", "pip", "list", "--outdated"], # noqa: S607
22
+ check=True,
23
+ cwd=workdir,
24
+ capture_output=True,
25
+ text=True,
26
+ )
27
+
28
+ # Note: It doesn't provide all necessary dependencies.
29
+
30
+ # Example output:
31
+ # Package Version Latest Type
32
+ # -------------- ------- ------ -----
33
+ # anyio 4.11.0 4.12.0 wheel
34
+ # basedpyright 1.34.0 1.35.0 wheel
35
+ # beautifulsoup4 4.14.2 4.14.3 wheel
36
+
37
+ dependencies: DependenciesRegistry = {}
38
+ # Note: skip the header line
39
+ for line in data.stdout.splitlines()[2:]:
40
+ package, _current_version, latest, _type = line.split()
41
+ dependencies[DependencyName(package)] = Version(latest)
42
+
43
+ return dependencies
44
+
45
+
46
+ def parse_from_uv_tree(
47
+ workdir: pathlib.Path,
48
+ ) -> DependenciesRegistry:
49
+ # Note: use this because it has better checks for outdated.
50
+ # For example, `uv pip list --outdated` may miss some packages.
51
+ # But we need extra parsing.
52
+
53
+ data = subprocess.run(
54
+ # uv tree --outdated --depth 1 --color never
55
+ [ # noqa: S607
56
+ "uv",
57
+ "tree",
58
+ "--outdated",
59
+ "--depth",
60
+ "1",
61
+ "--color",
62
+ "never",
63
+ ],
64
+ check=True,
65
+ cwd=workdir,
66
+ capture_output=True,
67
+ text=True,
68
+ )
69
+
70
+ # Example output:
71
+ # Resolved 129 packages in 0.61ms
72
+ # python-lab v0.1.0
73
+ # ├── jupyter v1.0.0 (latest: v1.1.1)
74
+ # ├── matplotlib v3.10.7
75
+ # ├── pydantic v2.12.5
76
+ # └── pytest v9.0.2 (group: dev)
77
+
78
+ # Parse
79
+
80
+ dependencies: DependenciesRegistry = {}
81
+ for line in data.stdout.splitlines():
82
+ line = line.strip() # noqa: PLW2901
83
+ if not line:
84
+ continue
85
+
86
+ if line[0].isalpha():
87
+ # Skip the root package line
88
+ continue
89
+
90
+ parts = line.split(maxsplit=3)
91
+ # Skip decorative prefix
92
+ parts.pop(0)
93
+
94
+ dependency_name = DependencyName(parts.pop(0))
95
+
96
+ # Get version
97
+ version_candidate = parts.pop(0)
98
+
99
+ if len(parts) == 1:
100
+ version_parts = parts.pop(0).split(") (")
101
+
102
+ # Check another version with the "latest:" marker
103
+ for part in version_parts:
104
+ part = part.strip("(").rstrip(")") # noqa: PLW2901
105
+
106
+ sub_parts = part.split(":", 1)
107
+
108
+ if len(sub_parts) == 2 and sub_parts[0] == "latest": # noqa: PLR2004
109
+ version_candidate = sub_parts[1]
110
+
111
+ elif len(parts) > 1:
112
+ msg = f"Unexpected line format: {line}"
113
+ raise ValueError(msg)
114
+
115
+ # Strip "v" prefix
116
+ version_candidate = version_candidate.strip().lstrip("v")
117
+
118
+ dependencies[dependency_name] = Version(version_candidate)
119
+
120
+ return dependencies
121
+
122
+
123
+ def parse_from_uv_lock(
124
+ workdir: pathlib.Path,
125
+ ) -> DependenciesRegistry:
126
+ uv_lock_path = get_and_check_path_to_uv_lock(workdir)
127
+
128
+ data = load_toml(uv_lock_path)
129
+
130
+ dependencies: DependenciesRegistry = {}
131
+ for package in data.get("package", []): # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
132
+ dependencies[DependencyName(package["name"])] = Version(package["version"]) # pyright: ignore[reportUnknownArgumentType] # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
133
+
134
+ return dependencies
135
+
136
+
137
+ def get_deps_from_project(
138
+ workdir: pathlib.Path,
139
+ ) -> DependenciesRegistry:
140
+ """Get dependencies info."""
141
+ return parse_from_uv_lock(workdir=workdir)
@@ -0,0 +1,32 @@
1
+ import logging
2
+ from typing import TYPE_CHECKING
3
+
4
+ from uv_upx.services.normalize_and_check_path_to_pyproject import NAME_OF_PYPROJECT_FILE
5
+
6
+ if TYPE_CHECKING:
7
+ import pathlib
8
+
9
+
10
+ def get_pyproject_paths_by_globs(
11
+ base_path: pathlib.Path,
12
+ patterns: list[str],
13
+ ) -> set[pathlib.Path]:
14
+ paths: set[pathlib.Path] = set()
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ for pattern in patterns:
19
+ for path in base_path.glob(pattern):
20
+ if path.is_file():
21
+ if path.name == NAME_OF_PYPROJECT_FILE:
22
+ paths.add(path)
23
+ elif path.is_dir():
24
+ path_candidate = path / NAME_OF_PYPROJECT_FILE
25
+ if path_candidate.exists():
26
+ paths.add(path_candidate)
27
+ else:
28
+ # sourcery skip: remove-redundant-pass
29
+ # Explicit skip for other cases
30
+ logger.warning(f"Skipping {path.as_uri()}: not a file or directory.")
31
+
32
+ return paths
@@ -0,0 +1,189 @@
1
+ import logging
2
+ from typing import TYPE_CHECKING, Any
3
+
4
+ from uv_upx.services.save_load_toml import save_toml
5
+ from uv_upx.services.update_dependencies import ChangesList, IncludedDependencyGroup, update_dependencies
6
+
7
+ if TYPE_CHECKING:
8
+ from tomlkit import TOMLDocument
9
+
10
+ from uv_upx.services.get_all_pyprojects import PyProjectsRegistry, PyProjectWrapper
11
+ from uv_upx.services.get_deps_from_project import DependenciesRegistry
12
+
13
+
14
+ # https://docs.astral.sh/uv/concepts/projects/dependencies/
15
+
16
+
17
+ def handle_main_dependency_group(
18
+ *,
19
+ data: TOMLDocument,
20
+ dependencies_registry: DependenciesRegistry,
21
+ verbose: bool = False,
22
+ ) -> ChangesList:
23
+ """Check the main dependencies group."""
24
+ logger = logging.getLogger(__name__)
25
+
26
+ project: dict[Any, Any] = data.get("project", {}) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
27
+ if not isinstance(project, dict):
28
+ logger.warning("No [project] table found in pyproject.toml")
29
+ return []
30
+
31
+ dependencies: list[str] = project.get("dependencies", []) # pyright: ignore[reportUnknownMemberType, reportUnknownVariableType]
32
+ if not isinstance(dependencies, list):
33
+ logger.warning("No [project].dependencies found in pyproject.toml")
34
+ return []
35
+
36
+ return update_dependencies(
37
+ deps_sequence_from_config=dependencies, # pyright: ignore[reportUnknownArgumentType]
38
+ dependencies_registry=dependencies_registry,
39
+ verbose=verbose,
40
+ )
41
+
42
+
43
+ def handle_dependency_groups(
44
+ *,
45
+ data: TOMLDocument,
46
+ dependencies_registry: DependenciesRegistry,
47
+ verbose: bool = False,
48
+ ) -> ChangesList:
49
+ """Check the dependency-groups table.
50
+
51
+ https://docs.astral.sh/uv/concepts/projects/dependencies/#development-dependencies
52
+ """
53
+ return handle_dependency_groups_inner(
54
+ groups=data.get("dependency-groups", {}), # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
55
+ dependencies_registry=dependencies_registry,
56
+ verbose=verbose,
57
+ )
58
+
59
+
60
+ def handle_optional_dependencies(
61
+ *,
62
+ data: TOMLDocument,
63
+ dependencies_registry: DependenciesRegistry,
64
+ verbose: bool = False,
65
+ ) -> ChangesList:
66
+ """Check the project.optional-dependencies table.
67
+
68
+ https://docs.astral.sh/uv/concepts/projects/dependencies/#optional-dependencies
69
+ """
70
+ return handle_dependency_groups_inner(
71
+ groups=data.get("project", {}).get("optional-dependencies", {}), # pyright: ignore[reportUnknownVariableType, reportUnknownMemberType]
72
+ dependencies_registry=dependencies_registry,
73
+ verbose=verbose,
74
+ )
75
+
76
+
77
+ def handle_dependency_groups_inner(
78
+ *,
79
+ groups: Any, # noqa: ANN401
80
+ dependencies_registry: DependenciesRegistry,
81
+ verbose: bool = False,
82
+ ) -> ChangesList:
83
+ """Check the dependency-groups table.
84
+
85
+ https://docs.astral.sh/uv/concepts/projects/dependencies/#development-dependencies
86
+
87
+ https://docs.astral.sh/uv/concepts/projects/dependencies/#optional-dependencies
88
+ """
89
+ logger = logging.getLogger(__name__)
90
+
91
+ if not isinstance(groups, dict):
92
+ return []
93
+
94
+ dependency_groups: dict[str, Any] = groups # pyright: ignore[reportUnknownVariableType]
95
+
96
+ changes: ChangesList = []
97
+ for _group_name, _group_val in dependency_groups.items():
98
+ if verbose:
99
+ logger.info(f"Checking group: {_group_name}")
100
+
101
+ if not isinstance(_group_val, list):
102
+ continue
103
+
104
+ group_val: list[str | IncludedDependencyGroup] = _group_val # pyright: ignore[reportUnknownVariableType]
105
+
106
+ changes_local = update_dependencies(
107
+ deps_sequence_from_config=group_val,
108
+ dependencies_registry=dependencies_registry,
109
+ verbose=verbose,
110
+ )
111
+ changes.extend(changes_local)
112
+
113
+ return changes
114
+
115
+
116
+ def handle_py_project(
117
+ *,
118
+ dependencies_registry: DependenciesRegistry,
119
+ pyproject_wrapper: PyProjectWrapper,
120
+ #
121
+ dry_run: bool,
122
+ verbose: bool,
123
+ ) -> ChangesList:
124
+ """Handle a single pyproject.toml file."""
125
+ logger = logging.getLogger(__name__)
126
+
127
+ data = pyproject_wrapper.data
128
+
129
+ changes: ChangesList = []
130
+
131
+ changes.extend(
132
+ handle_main_dependency_group(
133
+ data=data,
134
+ dependencies_registry=dependencies_registry,
135
+ verbose=verbose,
136
+ ),
137
+ )
138
+
139
+ changes.extend(
140
+ handle_dependency_groups(
141
+ data=data,
142
+ dependencies_registry=dependencies_registry,
143
+ verbose=verbose,
144
+ ),
145
+ )
146
+
147
+ changes.extend(
148
+ handle_optional_dependencies(
149
+ data=data,
150
+ dependencies_registry=dependencies_registry,
151
+ verbose=verbose,
152
+ ),
153
+ )
154
+
155
+ if changes:
156
+ if dry_run:
157
+ logger.info(f"[Dry Run] Changes detected in {pyproject_wrapper.path.as_uri()}, but not saving.")
158
+ else:
159
+ save_toml(pyproject_wrapper.path, data)
160
+ logger.info(f"Saved changes to {pyproject_wrapper.path.as_uri()}")
161
+
162
+ for change in changes:
163
+ logger.info(f" {change}")
164
+
165
+ return changes
166
+
167
+
168
+ def handle_py_projects(
169
+ *,
170
+ dependencies_registry: DependenciesRegistry,
171
+ py_projects: PyProjectsRegistry,
172
+ #
173
+ dry_run: bool,
174
+ verbose: bool,
175
+ ) -> ChangesList:
176
+ """Handle multiple pyproject.toml files."""
177
+ changes: ChangesList = []
178
+ for py_project in py_projects.items:
179
+ changes_local = handle_py_project(
180
+ dependencies_registry=dependencies_registry,
181
+ pyproject_wrapper=py_project,
182
+ #
183
+ dry_run=dry_run,
184
+ verbose=verbose,
185
+ )
186
+
187
+ changes.extend(changes_local)
188
+
189
+ return changes
@@ -0,0 +1,41 @@
1
+ import pathlib
2
+ from typing import Final
3
+
4
+ NAME_OF_PYPROJECT_FILE: Final[str] = "pyproject.toml"
5
+
6
+ NAME_OF_UV_LOCK_FILE: Final[str] = "uv.lock"
7
+
8
+
9
+ def normalize_and_check_path_to_project_root(path: pathlib.Path | None) -> pathlib.Path:
10
+ if path is None:
11
+ path = pathlib.Path.cwd()
12
+
13
+ if not path.is_dir():
14
+ msg = f"Path {path} is not a directory."
15
+ raise NotADirectoryError(msg)
16
+
17
+ if not path.exists():
18
+ msg = f"Path {path} does not exist."
19
+ raise FileNotFoundError(msg)
20
+
21
+ return path
22
+
23
+
24
+ def get_and_check_path_to_pyproject(path: pathlib.Path) -> pathlib.Path:
25
+ path = path / NAME_OF_PYPROJECT_FILE
26
+
27
+ if not path.exists():
28
+ msg = f"Path {path} does not exist."
29
+ raise FileNotFoundError(msg)
30
+
31
+ return path
32
+
33
+
34
+ def get_and_check_path_to_uv_lock(path: pathlib.Path) -> pathlib.Path:
35
+ path = path / NAME_OF_UV_LOCK_FILE
36
+
37
+ if not path.exists():
38
+ msg = f"Path {path} does not exist."
39
+ raise FileNotFoundError(msg)
40
+
41
+ return path
@@ -0,0 +1,89 @@
1
+ from uv_upx.models.dependency_parsed import DependencyParsed, VersionConstraint
2
+ from uv_upx.services.get_deps_from_project import DependencyName
3
+
4
+
5
+ def parse_dependency( # noqa: C901, PLR0912, PLR0915
6
+ dependency_string: str,
7
+ ) -> DependencyParsed: # sourcery skip: low-code-quality
8
+ dependency_string = dependency_string.strip()
9
+ if not dependency_string:
10
+ msg = "Empty dependency string"
11
+ raise ValueError(msg)
12
+
13
+ # split off environment marker (after ';')
14
+ parts = dependency_string.split(";", 1)
15
+ main = parts[0].strip()
16
+ marker = parts[1].strip() if len(parts) > 1 else None
17
+
18
+ # parse name and extras
19
+ name = main
20
+ extras: list[str] | None = None
21
+ version_part = ""
22
+
23
+ if "[" in main:
24
+ i = main.find("[")
25
+ j = main.find("]", i)
26
+ if j == -1:
27
+ msg = f"Invalid dependency string: {main}"
28
+ raise ValueError(msg)
29
+ name = main[:i].strip()
30
+ extras_str = main[i + 1 : j].strip()
31
+ extras = [e.strip() for e in extras_str.split(",") if e.strip()] or None
32
+ version_part = main[j + 1 :].strip()
33
+ else:
34
+ # try to locate the start of version/operator tokens
35
+ ops = ["===", "==", "~=", "!=", "<=", ">=", "<", ">", "@"]
36
+ first_pos = None
37
+ for op in ops:
38
+ idx = main.find(op)
39
+ if idx != -1 and (first_pos is None or idx < first_pos):
40
+ first_pos = idx
41
+ if first_pos is not None:
42
+ name = main[:first_pos].strip()
43
+ version_part = main[first_pos:].strip()
44
+ else:
45
+ name = main.strip()
46
+ version_part = ""
47
+
48
+ if not name:
49
+ msg = f"Invalid dependency string: {main}"
50
+ raise ValueError(msg)
51
+
52
+ # parse version constraints (comma separated)
53
+ version_constraints: list[VersionConstraint] = []
54
+ if version_part:
55
+ # do not attempt to parse direct URL / VCS refs (start with '@')
56
+ if version_part.startswith("@"):
57
+ msg = f"Invalid dependency string: {main}"
58
+ raise ValueError(msg)
59
+
60
+ raw_parts = [p.strip() for p in version_part.split(",") if p.strip()]
61
+ op_candidates = ["===", "==", "~=", "!=", "<=", ">=", "<", ">"]
62
+ for rp in raw_parts:
63
+ token = rp.lstrip()
64
+ matched = False
65
+ for op in op_candidates:
66
+ if token.startswith(op):
67
+ ver = token[len(op) :].strip().strip("\"'")
68
+ version_constraints.append(VersionConstraint(operator=op, version=ver))
69
+ matched = True
70
+ break
71
+ if not matched:
72
+ # allow a bit of tolerance for leading space before the operator
73
+ stripped = token.lstrip()
74
+ for op in op_candidates:
75
+ if stripped.startswith(op):
76
+ ver = stripped[len(op) :].strip().strip("\"'")
77
+ version_constraints.append(VersionConstraint(operator=op, version=ver))
78
+ matched = True
79
+ break
80
+ if not matched:
81
+ msg = f"Invalid dependency string: {main}"
82
+ raise ValueError(msg)
83
+
84
+ return DependencyParsed(
85
+ dependency_name=DependencyName(name),
86
+ extras=extras,
87
+ version_constraints=version_constraints,
88
+ marker=marker,
89
+ )
@@ -0,0 +1,25 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ from uv_upx.services.run_uv_lock import run_uv_sync_frozen
4
+ from uv_upx.services.save_load_toml import save_toml
5
+
6
+ if TYPE_CHECKING:
7
+ import pathlib
8
+
9
+ from tomlkit import TOMLDocument
10
+
11
+ from uv_upx.services.get_all_pyprojects import PyProjectsRegistry
12
+
13
+
14
+ def rollback_updater(
15
+ *,
16
+ uv_lock_path: pathlib.Path,
17
+ uv_lock_data: TOMLDocument,
18
+ #
19
+ py_projects: PyProjectsRegistry,
20
+ ) -> None:
21
+ save_toml(uv_lock_path, uv_lock_data)
22
+ for py_project in py_projects.items:
23
+ save_toml(py_project.path, py_project.data)
24
+
25
+ run_uv_sync_frozen(workdir=uv_lock_path.parent)
@@ -0,0 +1,93 @@
1
+ import copy
2
+ import logging
3
+ from typing import TYPE_CHECKING
4
+
5
+ from uv_upx.services.get_all_pyprojects import get_all_pyprojects
6
+ from uv_upx.services.get_deps_from_project import get_deps_from_project
7
+ from uv_upx.services.handle_groups import handle_py_projects
8
+ from uv_upx.services.normalize_and_check_path_to_pyproject import (
9
+ get_and_check_path_to_uv_lock,
10
+ )
11
+ from uv_upx.services.rollback_updater import rollback_updater
12
+ from uv_upx.services.run_uv_lock import (
13
+ run_uv_sync,
14
+ run_uv_sync_upgrade,
15
+ )
16
+ from uv_upx.services.save_load_toml import load_toml
17
+
18
+ if TYPE_CHECKING:
19
+ import pathlib
20
+
21
+
22
+ def run_updater(
23
+ *,
24
+ project_root_path: pathlib.Path,
25
+ dry_run: bool = False,
26
+ verbose: bool = False,
27
+ ) -> None:
28
+ logger = logging.getLogger(__name__)
29
+
30
+ uv_lock_path = get_and_check_path_to_uv_lock(project_root_path)
31
+ uv_lock_data = load_toml(uv_lock_path)
32
+ uv_lock_data_copy = copy.deepcopy(uv_lock_data)
33
+
34
+ py_projects = get_all_pyprojects(project_root_path)
35
+ if verbose:
36
+ logger.info(f"Found {len(py_projects.items)} pyproject.toml files in the workspace.")
37
+ for py_project in py_projects.items:
38
+ logger.info(f" {py_project.path.as_uri()}")
39
+
40
+ py_projects_copy = copy.deepcopy(py_projects)
41
+
42
+ is_rollback_needed = dry_run
43
+ rollback_message = "Rolling back to previous state because dry run is enabled."
44
+
45
+ try:
46
+ # Because we want to check build problems also.
47
+ run_uv_sync_upgrade(workdir=project_root_path)
48
+
49
+ dependencies_registry = get_deps_from_project(workdir=project_root_path)
50
+
51
+ if handle_py_projects(
52
+ py_projects=py_projects,
53
+ dependencies_registry=dependencies_registry,
54
+ #
55
+ dry_run=dry_run,
56
+ verbose=verbose,
57
+ ):
58
+ logger.info("Updated pyproject.toml files successfully.")
59
+
60
+ if dry_run:
61
+ logger.info("Dry run. No changes were made.")
62
+ else:
63
+ # run_uv_lock(workdir=project_root_path)
64
+ # logger.info("Updated dependencies successfully.")
65
+
66
+ # Because we want to re-check that all is ok.
67
+ run_uv_sync(workdir=project_root_path)
68
+ logger.info("Synced dependencies successfully.")
69
+
70
+ else:
71
+ msg = "No important changes detected. Rolling back to previous state."
72
+ logger.info(msg)
73
+ is_rollback_needed = True
74
+ rollback_message = msg
75
+
76
+ except Exception as e: # noqa: BLE001
77
+ msg = f"Failed to update dependencies: '{e}' Rolling back to previous state."
78
+ logger.error(msg) # noqa: TRY400
79
+ is_rollback_needed = True
80
+ rollback_message = msg
81
+
82
+ try:
83
+ if is_rollback_needed:
84
+ rollback_updater(
85
+ uv_lock_path=uv_lock_path,
86
+ uv_lock_data=uv_lock_data_copy,
87
+ #
88
+ py_projects=py_projects_copy,
89
+ )
90
+ logger.info(rollback_message)
91
+ except Exception as e: # noqa: BLE001
92
+ msg = f"Failed to rollback: '{e}'"
93
+ logger.error(msg) # noqa: TRY400
@@ -0,0 +1,86 @@
1
+ import subprocess
2
+ from typing import TYPE_CHECKING
3
+
4
+ if TYPE_CHECKING:
5
+ import pathlib
6
+
7
+
8
+ class UnresolvedDependencyError(Exception):
9
+ pass
10
+
11
+
12
+ def run_uv_lock_upgrade(workdir: pathlib.Path) -> None:
13
+ try:
14
+ subprocess.run(
15
+ # uv lock --upgrade
16
+ ["uv", "lock", "--upgrade"], # noqa: S607
17
+ check=True,
18
+ cwd=workdir,
19
+ )
20
+ except subprocess.CalledProcessError as e:
21
+ msg = "Failed to resolve dependencies with 'uv lock'. Please check your dependency specifications."
22
+ raise UnresolvedDependencyError(
23
+ msg,
24
+ ) from e
25
+
26
+
27
+ def run_uv_lock(workdir: pathlib.Path) -> None:
28
+ try:
29
+ subprocess.run(
30
+ # uv lock
31
+ ["uv", "lock"], # noqa: S607
32
+ check=True,
33
+ cwd=workdir,
34
+ )
35
+ except subprocess.CalledProcessError as e:
36
+ msg = "Failed to resolve dependencies with 'uv lock'. Please check your dependency specifications."
37
+ raise UnresolvedDependencyError(
38
+ msg,
39
+ ) from e
40
+
41
+
42
+ def run_uv_sync(workdir: pathlib.Path) -> None:
43
+ try:
44
+ subprocess.run(
45
+ # uv sync --all-groups --all-extras --all-packages
46
+ ["uv", "sync", "--all-groups", "--all-extras", "--all-packages"], # noqa: S607
47
+ check=True,
48
+ cwd=workdir,
49
+ )
50
+ except subprocess.CalledProcessError as e:
51
+ msg = "Failed to sync dependencies with 'uv sync'. Please check your dependency specifications."
52
+ raise UnresolvedDependencyError(
53
+ msg,
54
+ ) from e
55
+
56
+
57
+ def run_uv_sync_upgrade(workdir: pathlib.Path) -> None:
58
+ # Note: Better to use this for new dependencies.
59
+ # Because it uses a better resolver for conflicts.
60
+ try:
61
+ subprocess.run(
62
+ # uv sync --all-groups --all-extras --all-packages --upgrade
63
+ ["uv", "sync", "--all-groups", "--all-extras", "--all-packages", "--upgrade"], # noqa: S607
64
+ check=True,
65
+ cwd=workdir,
66
+ )
67
+ except subprocess.CalledProcessError as e:
68
+ msg = "Failed to sync dependencies with 'uv sync'. Please check your dependency specifications."
69
+ raise UnresolvedDependencyError(
70
+ msg,
71
+ ) from e
72
+
73
+
74
+ def run_uv_sync_frozen(workdir: pathlib.Path) -> None:
75
+ try:
76
+ subprocess.run(
77
+ # uv sync --all-groups --all-extras --all-packages --frozen
78
+ ["uv", "sync", "--all-groups", "--all-extras", "--all-packages", "--frozen"], # noqa: S607
79
+ check=True,
80
+ cwd=workdir,
81
+ )
82
+ except subprocess.CalledProcessError as e:
83
+ msg = "Failed to sync dependencies with 'uv sync'. Please check your dependency specifications."
84
+ raise UnresolvedDependencyError(
85
+ msg,
86
+ ) from e
@@ -0,0 +1,18 @@
1
+ from typing import TYPE_CHECKING
2
+
3
+ import tomlkit
4
+ from tomlkit import TOMLDocument
5
+
6
+ if TYPE_CHECKING:
7
+ from pathlib import Path
8
+
9
+ # TODO: (?) Use also "https://github.com/galactixx/tomlkit-extras"
10
+
11
+
12
+ def load_toml(path: Path) -> TOMLDocument:
13
+ return tomlkit.parse(path.read_text(encoding="utf-8"))
14
+
15
+
16
+ def save_toml(path: Path, data: TOMLDocument) -> None:
17
+ text = tomlkit.dumps(data) # pyright: ignore[reportUnknownMemberType]
18
+ path.write_text(text, encoding="utf-8")
@@ -0,0 +1,92 @@
1
+ import copy
2
+ import logging
3
+ from typing import TYPE_CHECKING, Final
4
+
5
+ from pydantic import BaseModel
6
+
7
+ from uv_upx.models.dependency_parsed import DependencyParsed
8
+ from uv_upx.services.parse_dependency import parse_dependency
9
+
10
+ if TYPE_CHECKING:
11
+ from uv_upx.services.get_deps_from_project import DependenciesRegistry
12
+
13
+ type IncludedDependencyGroup = dict[str, str]
14
+
15
+
16
+ class ChangesItem(BaseModel):
17
+ from_item: DependencyParsed
18
+ to_item: DependencyParsed
19
+
20
+ def __str__(self) -> str:
21
+ return f"{self.from_item.get_full_spec()} -> {self.to_item.get_full_spec()}"
22
+
23
+
24
+ type ChangesList = list[ChangesItem]
25
+
26
+ VERSION_OPERATORS_I_PUT_IF_DIFFERENT: Final[set[str]] = {">="}
27
+
28
+ VERSION_OPERATORS_I_EXPLICIT_IGNORE: Final[set[str]] = {"=="}
29
+
30
+
31
+ def update_dependencies( # noqa: C901, PLR0912
32
+ *,
33
+ deps_sequence_from_config: list[str] | list[str | IncludedDependencyGroup],
34
+ dependencies_registry: DependenciesRegistry,
35
+ verbose: bool = False,
36
+ ) -> ChangesList:
37
+ """Update the list of dependencies.
38
+
39
+ Update it in-place.
40
+ Because we want to preserve the comments.
41
+ """
42
+ logger = logging.getLogger(__name__)
43
+
44
+ changes: ChangesList = []
45
+ for _index, dep in enumerate(deps_sequence_from_config):
46
+ if verbose:
47
+ logger.info(f"Parsing dependency: {dep}")
48
+
49
+ if not isinstance(dep, str):
50
+ if verbose:
51
+ # https://docs.astral.sh/uv/concepts/projects/dependencies/#nesting-groups
52
+ logger.warning(f"Skipping non-string dependency: {dep}")
53
+ continue
54
+
55
+ parsed = parse_dependency(dep)
56
+
57
+ if verbose:
58
+ logger.info(f"Parsed dependency: {parsed}")
59
+
60
+ try:
61
+ version_new = dependencies_registry[parsed.dependency_name]
62
+ except KeyError as e:
63
+ # Note: raise error, because it now we have all the dependencies in the registry.
64
+ msg = f"Dependency not found in the registry: {parsed.dependency_name}"
65
+ raise ValueError(msg) from e
66
+
67
+ parsed_original = copy.deepcopy(parsed)
68
+
69
+ is_has_changes = False
70
+ for version_constraint in parsed.version_constraints:
71
+ if version_constraint.operator in VERSION_OPERATORS_I_PUT_IF_DIFFERENT:
72
+ # TODO: (?) Implement better version comparison logic here
73
+ if version_new != version_constraint.version:
74
+ version_constraint.version = version_new
75
+ is_has_changes = True
76
+
77
+ elif version_constraint.operator in VERSION_OPERATORS_I_EXPLICIT_IGNORE:
78
+ if verbose:
79
+ msg = f"Explicitly ignoring version constraint {version_constraint}. Dependency: {dep}"
80
+ logger.info(msg)
81
+ else:
82
+ if verbose:
83
+ msg = f"Operator {version_constraint.operator} is not supported yet. Skip. Dependency: {dep}"
84
+ logger.warning(msg)
85
+
86
+ continue
87
+
88
+ if is_has_changes:
89
+ deps_sequence_from_config[_index] = parsed.get_full_spec()
90
+ changes.append(ChangesItem(from_item=parsed_original, to_item=parsed))
91
+
92
+ return changes
@@ -0,0 +1,186 @@
1
+ Metadata-Version: 2.3
2
+ Name: uv-upx
3
+ Version: 0.2.0
4
+ Summary: Upgrade versions of dependencies in pyproject.toml files in uv-managed projects.
5
+ Keywords: uv,upgrade,dependencies,pyproject.toml,automation,cli
6
+ Author: Alirex
7
+ Author-email: Alirex <alirex.prime@gmail.com>
8
+ Requires-Dist: pydantic>=2.12.5
9
+ Requires-Dist: tomlkit>=0.13.3
10
+ Requires-Dist: typer>=0.20.0
11
+ Requires-Python: >=3.14
12
+ Project-URL: Homepage, https://github.com/Alirex/uv_upgrade
13
+ Description-Content-Type: text/markdown
14
+
15
+ # Uv upgrade
16
+
17
+ Upgrade dependencies in `pyproject.toml` files with `uv`.
18
+
19
+ ---
20
+
21
+ # Install
22
+
23
+ For end-users.
24
+
25
+ Note: For developers, see the [dev](docs_extra/dev.md) section.
26
+
27
+ ## Uv install or update
28
+
29
+ You need this for both usage and development.
30
+
31
+ https://docs.astral.sh/uv/getting-started/installation/
32
+
33
+ On Windows it can be better to use `Git Bash` terminal.
34
+
35
+ ```bash
36
+ if ! command -v uv &> /dev/null; then
37
+ curl -LsSf https://astral.sh/uv/install.sh | sh;
38
+ else
39
+ uv self update;
40
+ fi
41
+
42
+ uv --version
43
+ ```
44
+
45
+ ## Install or update a project for system-wide usage
46
+
47
+ Run it from any directory, except the project directory.
48
+
49
+ ```shell
50
+ if ! command -v uv-upgrade &> /dev/null; then
51
+ uv tool install git+https://github.com/Alirex/uv_upgrade
52
+ else
53
+ uv tool upgrade uv-upx
54
+ fi
55
+ ```
56
+
57
+ Or run with uvx:
58
+
59
+ ```shell
60
+ uvx uv-upx
61
+ ```
62
+
63
+ Dev installation: [see here](docs_extra/dev.md#install-app-system-wide-in-development-mode)
64
+
65
+ ## Check that project in the list
66
+
67
+ ```shell
68
+ uv tool list --show-python
69
+ ```
70
+
71
+ ## Remove the project with all data
72
+
73
+ If you need to remove the project with all data, run this command from the project directory:
74
+
75
+ ```shell
76
+ uv tool uninstall uv-upx
77
+ ```
78
+
79
+ ---
80
+
81
+ # Usage
82
+
83
+ ## Run the tool in the folder with the pyproject.toml
84
+
85
+ After installation, you can run the tool from any directory.
86
+
87
+ ```shell
88
+ uv-upgrade
89
+ ```
90
+
91
+ Also, you can run it with the `--help` flag with more details.
92
+
93
+ ## Notes
94
+
95
+ ### Uv as a source of truth
96
+
97
+ Run `uv sync --all-groups --all-extras --all-packages --upgrade`.
98
+
99
+ Get dependencies versions from `uv.lock`.
100
+
101
+ Put them in `pyproject.toml` files in related groups.
102
+
103
+ So, the main responsibility for dependencies resolution is on `uv`.
104
+
105
+ ### Workspace support
106
+
107
+ It works with workspaces. At least for some basic cases.
108
+
109
+ ### Updates simple dependencies
110
+
111
+ Updates `>=` dependencies. Like `bla>=2.0.0`.
112
+
113
+ ### Skip pinned versions
114
+
115
+ It doesn't touch pinned versions. Like `bla==2.0.0`.
116
+
117
+ ### Respect simple ranges
118
+
119
+ It respects simple dependency ranges. Like `bla>=1.0.0,<2.0.0`.
120
+
121
+ It moves the lower bound to the new version.
122
+
123
+ ### Respect extras
124
+
125
+ It respects extras. Like `bla[dev]>=1.0.0;python_version>="3.14"`.
126
+
127
+ ### Skip unhandled constraints
128
+
129
+ It skips unhandled and complex constraints. Like:
130
+
131
+ - `bla<=1.0.0`
132
+ - `bla>=2.0.0,!=1.5.0,<3.0.0`
133
+
134
+ ### Style preservation
135
+
136
+ It preserves comments in the `pyproject.toml` file. Like here:
137
+
138
+ ```toml
139
+ dependencies = [
140
+ # Better classes and data validation
141
+ "pydantic>=2.12.5",
142
+ # TOML parser and writer with preserved formatting
143
+ "tomlkit>=0.13.3",
144
+ # CLI app framework
145
+ "typer>=0.20.0",
146
+ ]
147
+ ```
148
+
149
+ ### Rollback
150
+
151
+ If something goes wrong, it rolls back the changes to the `pyproject.toml` and `uv.lock` files.
152
+
153
+ ### Why?
154
+
155
+ I needed this for my own projects.
156
+
157
+ I know these issues:
158
+
159
+ - [Upgrade dependencies in pyproject.toml (uv upgrade)](https://github.com/astral-sh/uv/issues/6692)
160
+ - [What is the intended workflow for updating dependencies with uv?](ttps://github.com/astral-sh/uv/issues/6794)
161
+
162
+ But I didn't see enough progress.
163
+
164
+ So, I implemented this tool for my own usage.
165
+
166
+ Maybe it will be useful for someone else.
167
+
168
+ ### Why not in Rust?
169
+
170
+ Because it's a temporary Proof-of-Concept.
171
+
172
+ Maybe it will be replaced by Rust in the future.
173
+
174
+ It uses type annotations anyway. For simpler migration.
175
+
176
+ ---
177
+
178
+ # Dev
179
+
180
+ [Dev](docs_extra/dev.md)
181
+
182
+ ---
183
+
184
+ # Other similar tools comparison
185
+
186
+ [Other similar tools comparison](docs_extra/other_similar_tools_comparison.md)
@@ -0,0 +1,26 @@
1
+ uv_upx/__init__.py,sha256=rbBerQaGd0D2iPapShvgQ6lQlt88AQ872iv4obRBOi8,50
2
+ uv_upx/__main__.py,sha256=-BgNoaEyq21dKa_PHEODSvEnAq-IRAexGZR-xehbQNU,68
3
+ uv_upx/cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ uv_upx/cli/main.py,sha256=DGpfzBxi9CP7J9VfnZcX4bg1Ol4H7aT25r7RHKlehg0,1044
5
+ uv_upx/logging_custom/__init__.py,sha256=IF9q4lMvyP24yXnj30ivZzX5RIw48RBRxhcWcQpmin4,74
6
+ uv_upx/logging_custom/init_logging.py,sha256=VlLhtOHd3YxfuDkWIptUYOlcfiyEW877yjqIA3xik60,176
7
+ uv_upx/main.py,sha256=eRHt38xqNaaWWYq-AAy3FXLq7pQF7PeFEjizWzZnVrw,130
8
+ uv_upx/models/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ uv_upx/models/dependency_parsed.py,sha256=z0jiVn_0FLxyjbcvBy1lmbmqcAWyPNk5LWkE-uTnkqQ,1026
10
+ uv_upx/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
11
+ uv_upx/services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
12
+ uv_upx/services/get_all_pyprojects.py,sha256=vwHEKrQ1-QrgQL4L0Mr3BEOaY-BychbLxBhDPT2QHtQ,2346
13
+ uv_upx/services/get_deps_from_project.py,sha256=B5Fx0N2u7tGn-9Zt8qD4MIAdzeDLjQeSMeFJm2aiKuc,4057
14
+ uv_upx/services/get_pyproject_paths_by_globs.py,sha256=Es0rETkVZQUyB5P2SVFRKOll1mp_XOLN9dKvRfi-q44,977
15
+ uv_upx/services/handle_groups.py,sha256=EpRbK2rDo_CN0KS68_k_deLGS8Qhns6uo-9kwUilhpw,5647
16
+ uv_upx/services/normalize_and_check_path_to_pyproject.py,sha256=7VXmR1Sq9I5sTQdRV9pP82nHshqj6vfQTLCLsJWncvA,1007
17
+ uv_upx/services/parse_dependency.py,sha256=_Xgrys9oSyHPgGG3dV0tRWL7ihno1KTnCWkdCB_mpKA,3374
18
+ uv_upx/services/rollback_updater.py,sha256=0kl7uIt16NtYoOv86tmFyFzaaTAU9PlJKOIuKG-2SqE,630
19
+ uv_upx/services/run_updater.py,sha256=dLknXdYqXSaTEAQ2_DHtGTGz2KLUdyNbPjpNvwUCbRQ,3117
20
+ uv_upx/services/run_uv_lock.py,sha256=tPIjOEfYkpBb9RToNn2HHy4ozU0dg-z_Gw8joil6MNM,2786
21
+ uv_upx/services/save_load_toml.py,sha256=5GK0_CsqxAC8uSmqd-hkQGZnHYhuKeZNPi5JeJWgLgY,478
22
+ uv_upx/services/update_dependencies.py,sha256=yM_8mccwiQ42uLlfrgehSO7ROAnWWWTcs1aXTFm-r34,3150
23
+ uv_upx-0.2.0.dist-info/WHEEL,sha256=xDCZ-UyfvkGuEHPeI7BcJzYKIZzdqN8A8o1M5Om8IyA,79
24
+ uv_upx-0.2.0.dist-info/entry_points.txt,sha256=9zUzK7AuGeOfnZbapni_IeaqbXn6c3RfFDE9MHe4HFo,44
25
+ uv_upx-0.2.0.dist-info/METADATA,sha256=-R5INUX-wHGUtFUzir-rLA_PEO3Y5w5Bdh1E2LLLGLQ,3880
26
+ uv_upx-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.9.17
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ uv-upgrade = uv_upx:main
3
+