Skip to content

Commit cbdd819

Browse files
authored
Merge pull request #193 from pharmaverse/sort-deps
Add uv-based dependency sorting script and sort dependencies
2 parents 4f36c07 + ba2f4b3 commit cbdd819

File tree

4 files changed

+309
-12
lines changed

4 files changed

+309
-12
lines changed

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## rtflite (development version)
4+
5+
### Maintenance
6+
7+
- Add a script to automate sorting dependencies in `pyproject.toml` and
8+
re-sort pyproject dependencies using the script (#193).
9+
310
## rtflite 2.5.3
411

512
### Documentation

docs/changelog.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,12 @@
11
# Changelog
22

3+
## rtflite (development version)
4+
5+
### Maintenance
6+
7+
- Add a script to automate sorting dependencies in `pyproject.toml` and
8+
re-sort pyproject dependencies using the script (#193).
9+
310
## rtflite 2.5.3
411

512
### Documentation

pyproject.toml

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ authors = [
77
{ name = "Nan Xiao", email = "me@nanx.me" },
88
]
99
dependencies = [
10-
"pydantic>=2.0.0",
11-
"pillow>=8.0.0",
1210
"narwhals>=1.0.0",
11+
"pillow>=8.0.0",
1312
"polars>=1.0.0",
13+
"pydantic>=2.0.0",
1414
]
1515
readme = "README.md"
1616
requires-python = ">= 3.10"
@@ -100,20 +100,20 @@ exclude = [
100100

101101
[dependency-groups]
102102
dev = [
103-
"pytest>=8.3.3",
104-
"pytest-cov>=6.0.0",
105-
"pytest-r-snapshot>=0.1.0",
106-
"zensical>=0.0.15",
107-
"mkdocstrings-python>=1.12.2",
108-
"markdown-exec[ansi]>=1.11.0",
109103
"griffe-pydantic>=1.1.8",
110-
"ruff>=0.7.4",
111104
"isort>=5.13.2",
112-
"mypy>=1.17.1",
105+
"markdown-exec[ansi]>=1.11.0",
113106
"matplotlib>=3.10.5",
107+
"mkdocstrings-python>=1.12.2",
108+
"mypy>=1.17.1",
114109
"pandas>=2.2.3",
115-
"pyarrow >= 21.0.0; python_version < '3.14'",
116-
"pyarrow >= 22.0.0; python_version >= '3.14'",
110+
"pyarrow>=21.0.0 ; python_version < '3.14'",
111+
"pyarrow>=22.0.0 ; python_version >= '3.14'",
112+
"pytest>=8.3.3",
113+
"pytest-cov>=6.0.0",
114+
"pytest-r-snapshot>=0.1.0",
115+
"ruff>=0.7.4",
116+
"zensical>=0.0.15",
117117
]
118118

119119
[tool.ruff.lint]

scripts/sort_deps.py

Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
"""Sort pyproject.toml dependencies by re-adding them with uv.
2+
3+
This script reads dependency declarations from pyproject.toml, removes them with
4+
`uv remove`, and re-adds them with `uv add` so uv's automatic sorting is applied.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import argparse
10+
import re
11+
import shlex
12+
import subprocess
13+
import sys
14+
from pathlib import Path
15+
16+
try:
17+
import tomllib
18+
except ImportError as exc: # pragma: no cover - tomllib is Python 3.11+
19+
print("This script requires Python 3.11+ (tomllib).", file=sys.stderr)
20+
raise SystemExit(1) from exc
21+
22+
23+
NAME_RE = re.compile(r"^\s*([A-Za-z0-9][A-Za-z0-9._-]*)")
24+
25+
26+
def load_pyproject(path: Path) -> dict:
27+
"""Load a pyproject.toml file into a dict.
28+
29+
Args:
30+
path: Path to pyproject.toml.
31+
32+
Returns:
33+
Parsed TOML content.
34+
"""
35+
if not path.is_file():
36+
raise FileNotFoundError(f"pyproject.toml not found: {path}")
37+
with path.open("rb") as handle:
38+
return tomllib.load(handle)
39+
40+
41+
def ensure_string_list(value: object, location: str) -> list[str]:
42+
"""Validate a TOML value is a list of strings.
43+
44+
Args:
45+
value: Value to validate.
46+
location: Friendly location string for error messages.
47+
48+
Returns:
49+
The original list, typed as list[str].
50+
51+
Raises:
52+
TypeError: If the value is not a list of strings.
53+
"""
54+
if value is None:
55+
return []
56+
if not isinstance(value, list):
57+
raise TypeError(f"Expected {location} to be a list of strings.")
58+
for item in value:
59+
if not isinstance(item, str):
60+
raise TypeError(f"Expected {location} to contain only strings.")
61+
return value
62+
63+
64+
def ensure_mapping(value: object, location: str) -> dict[str, list[str]]:
65+
"""Validate a TOML table of dependency groups.
66+
67+
Args:
68+
value: Value to validate.
69+
location: Friendly location string for error messages.
70+
71+
Returns:
72+
Mapping of group name to dependency list.
73+
74+
Raises:
75+
TypeError: If the value is not a string-keyed mapping of lists.
76+
"""
77+
if value is None:
78+
return {}
79+
if not isinstance(value, dict):
80+
raise TypeError(f"Expected {location} to be a table of lists.")
81+
result: dict[str, list[str]] = {}
82+
for key, entries in value.items():
83+
if not isinstance(key, str):
84+
raise TypeError(f"Expected {location} keys to be strings.")
85+
result[key] = ensure_string_list(entries, f"{location}.{key}")
86+
return result
87+
88+
89+
def extract_name(requirement: str) -> str:
90+
"""Extract the package name from a requirement string.
91+
92+
Args:
93+
requirement: PEP 508-style requirement string.
94+
95+
Returns:
96+
The package name.
97+
98+
Raises:
99+
ValueError: If the requirement cannot be parsed.
100+
"""
101+
match = NAME_RE.match(requirement)
102+
if not match:
103+
raise ValueError(f"Unable to parse dependency name from: {requirement!r}")
104+
return match.group(1)
105+
106+
107+
def unique_in_order(items: list[str]) -> list[str]:
108+
"""Return unique items while preserving first-seen order.
109+
110+
Args:
111+
items: Items to de-duplicate.
112+
113+
Returns:
114+
Unique items in their original order.
115+
"""
116+
seen: set[str] = set()
117+
result: list[str] = []
118+
for item in items:
119+
if item in seen:
120+
continue
121+
seen.add(item)
122+
result.append(item)
123+
return result
124+
125+
126+
def run_uv(command: list[str], cwd: Path, dry_run: bool) -> None:
127+
"""Run a uv command, or print it when in dry-run mode.
128+
129+
Args:
130+
command: Command and arguments to execute.
131+
cwd: Working directory for the uv invocation.
132+
dry_run: When true, print the command without executing it.
133+
"""
134+
printable = shlex.join(command)
135+
if dry_run:
136+
print(f"[dry-run] {printable}")
137+
return
138+
print(f"[run] {printable}")
139+
subprocess.run(command, cwd=cwd, check=True)
140+
141+
142+
def remove_and_add(
143+
*,
144+
requirements: list[str],
145+
remove_flags: list[str],
146+
add_flags_prefix: list[str],
147+
add_flags_suffix: list[str],
148+
cwd: Path,
149+
dry_run: bool,
150+
) -> None:
151+
"""Remove and re-add requirements using uv with the given flags.
152+
153+
Args:
154+
requirements: Requirement strings to manage.
155+
remove_flags: Flags passed to `uv remove`.
156+
add_flags_prefix: Flags placed before requirements in `uv add`.
157+
add_flags_suffix: Flags placed after requirements in `uv add`.
158+
cwd: Working directory for uv commands.
159+
dry_run: When true, print commands without executing them.
160+
"""
161+
if not requirements:
162+
return
163+
names = unique_in_order([extract_name(req) for req in requirements])
164+
if names:
165+
run_uv(["uv", "remove", *remove_flags, *names], cwd=cwd, dry_run=dry_run)
166+
run_uv(
167+
["uv", "add", *add_flags_prefix, *requirements, *add_flags_suffix],
168+
cwd=cwd,
169+
dry_run=dry_run,
170+
)
171+
172+
173+
def reset_python_version_markers(path: Path, *, dry_run: bool) -> None:
174+
"""Replace python_full_version markers with python_version in pyproject.toml.
175+
176+
Args:
177+
path: Path to pyproject.toml.
178+
dry_run: When true, print the change without writing the file.
179+
"""
180+
content = path.read_text(encoding="utf-8")
181+
updated = content.replace("python_full_version", "python_version")
182+
if updated == content:
183+
return
184+
if dry_run:
185+
print(f"[dry-run] replace python_full_version markers in {path}")
186+
return
187+
path.write_text(updated, encoding="utf-8")
188+
189+
190+
def parse_args() -> argparse.Namespace:
191+
"""Parse CLI arguments for the script."""
192+
default_pyproject = Path(__file__).resolve().parents[1] / "pyproject.toml"
193+
parser = argparse.ArgumentParser(
194+
description="Re-add dependencies with uv to sort them in pyproject.toml."
195+
)
196+
parser.add_argument(
197+
"--pyproject",
198+
type=Path,
199+
default=default_pyproject,
200+
help="Path to pyproject.toml (default: repo root).",
201+
)
202+
parser.add_argument(
203+
"--no-sync",
204+
action="store_true",
205+
help="Pass --no-sync to uv commands.",
206+
)
207+
parser.add_argument(
208+
"--dry-run",
209+
action="store_true",
210+
help="Print uv commands without executing them.",
211+
)
212+
return parser.parse_args()
213+
214+
215+
def main() -> int:
216+
"""Entry point for sorting dependencies via uv."""
217+
args = parse_args()
218+
pyproject_path = args.pyproject
219+
if pyproject_path.is_dir():
220+
pyproject_path = pyproject_path / "pyproject.toml"
221+
data = load_pyproject(pyproject_path)
222+
root = pyproject_path.parent
223+
no_sync = ["--no-sync"] if args.no_sync else []
224+
225+
project = data.get("project", {})
226+
dependencies = ensure_string_list(
227+
project.get("dependencies"), "project.dependencies"
228+
)
229+
optional_dependencies = ensure_mapping(
230+
project.get("optional-dependencies"), "project.optional-dependencies"
231+
)
232+
dependency_groups = ensure_mapping(
233+
data.get("dependency-groups"), "dependency-groups"
234+
)
235+
236+
remove_and_add(
237+
requirements=dependencies,
238+
remove_flags=no_sync,
239+
add_flags_prefix=no_sync,
240+
add_flags_suffix=[],
241+
cwd=root,
242+
dry_run=args.dry_run,
243+
)
244+
245+
for group, requirements in dependency_groups.items():
246+
add_suffix: list[str]
247+
if group == "dev":
248+
remove_flags = [*no_sync, "--dev"]
249+
add_prefix = [*no_sync, "--dev"]
250+
add_suffix = []
251+
else:
252+
remove_flags = [*no_sync, "--group", group]
253+
add_prefix = [*no_sync, "--group", group]
254+
add_suffix = []
255+
remove_and_add(
256+
requirements=requirements,
257+
remove_flags=remove_flags,
258+
add_flags_prefix=add_prefix,
259+
add_flags_suffix=add_suffix,
260+
cwd=root,
261+
dry_run=args.dry_run,
262+
)
263+
264+
for group, requirements in optional_dependencies.items():
265+
remove_flags = [*no_sync, "--optional", group]
266+
add_prefix = [*no_sync]
267+
add_suffix = ["--optional", group]
268+
remove_and_add(
269+
requirements=requirements,
270+
remove_flags=remove_flags,
271+
add_flags_prefix=add_prefix,
272+
add_flags_suffix=add_suffix,
273+
cwd=root,
274+
dry_run=args.dry_run,
275+
)
276+
277+
reset_python_version_markers(pyproject_path, dry_run=args.dry_run)
278+
279+
return 0
280+
281+
282+
if __name__ == "__main__":
283+
raise SystemExit(main())

0 commit comments

Comments
 (0)