-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpcu.py
More file actions
172 lines (143 loc) · 6.35 KB
/
Copy pathpcu.py
File metadata and controls
172 lines (143 loc) · 6.35 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
"""
pcu = "pip check updates", similar to ncu (npm check updates in Node.js)
"""
from __future__ import annotations
from typing import TYPE_CHECKING, NamedTuple
if TYPE_CHECKING:
from tomlkit.toml_document import TOMLDocument
import asyncio
from packaging.requirements import Requirement
from afterpython.version import Version
class Dependency(NamedTuple):
requirement: Requirement
min_version: Version | None
max_version: Version | None = None
latest_version: Version | None = None
type DependencyName = str
type ExtrasName = str
type GroupName = str
type FakeCategoryName = str
# dependency type -> category -> dependencies, where the dependency types are:
# "dependencies" (category: FakeCategoryName), "optional-dependencies" (ExtrasName),
# "dependency-groups" (GroupName), "build-system" (category: "requires")
# (a plain dict, not a TypedDict, since the code loops over the dependency types as str keys)
type Dependencies = dict[str, dict[str, list[Dependency]]]
def parse_min_max_versions_from_requirement(
req: Requirement,
) -> dict[str, Version | None]:
min_ver: Version | None = None
max_ver: Version | None = None
if req.specifier:
# req.specifier is like ">=8.3.0" or ">=1.0,<2.0"
versions = sorted(Version(spec.version) for spec in req.specifier)
if len(versions) == 1:
min_ver = versions[0]
else:
min_ver, max_ver = versions[0], versions[-1]
return {
"min_version": min_ver,
"max_version": max_ver,
}
async def get_latest_versions(
requirements: list[Requirement],
) -> dict[str, Version | None]:
"""Get latest versions for a list of dependencies from PyPI."""
import httpx2
from afterpython.utils import fetch_pypi_json
async def fetch_version(
client: httpx2.AsyncClient, package_name: str
) -> Version | None:
"""Fetch the latest version of a package from PyPI."""
data = await fetch_pypi_json(client, package_name)
return Version(data["info"]["version"]) if data else None
async with httpx2.AsyncClient(timeout=10.0) as client:
tasks = [fetch_version(client, req.name) for req in requirements]
results = await asyncio.gather(*tasks, return_exceptions=True)
# a failed request (e.g. timeout) means no latest version, like a package not found
return {
req.name: None if isinstance(result, BaseException) else result
for req, result in zip(requirements, results, strict=False)
}
def get_dependencies() -> Dependencies:
"""Get dependencies from pyproject.toml"""
from afterpython.tools.pyproject import read_pyproject
doc: TOMLDocument = read_pyproject()
build_system = dict(doc.get("build-system", {}))
# only keep the "requires" key
if "requires" in build_system:
build_system = {"requires": build_system["requires"]}
dependency_strings: dict[str, dict[str, list[str]]] = {
# add "fake_category" to "dependencies" to have the same structure as "optional-dependencies" and "dependency-groups"
"dependencies": {"fake_category": list(doc["project"].get("dependencies", []))},
"optional-dependencies": dict(doc["project"].get("optional-dependencies", {})),
"dependency-groups": dict(doc.get("dependency-groups", {})),
"build-system": build_system,
}
# convert all dependency strings to type "Requirement"
requirements: dict[str, dict[str, list[Requirement]]] = {
dep_type: {
category: [Requirement(dep) for dep in deps]
for category, deps in categories.items()
}
for dep_type, categories in dependency_strings.items()
}
# flatten the dependencies to a list of type "Requirement"
all_reqs = [
req
for reqs_dict in requirements.values()
for req_list in reqs_dict.values()
for req in req_list
]
# Fetch ALL latest versions in ONE async call
latest_versions = asyncio.run(get_latest_versions(all_reqs))
# convert the requirements to type "Dependency"
return {
dep_type: {
category: [
Dependency(
**parse_min_max_versions_from_requirement(req),
requirement=req,
latest_version=latest_versions.get(req.name),
)
for req in reqs
]
for category, reqs in categories.items()
}
for dep_type, categories in requirements.items()
}
def update_dependencies(dependencies: Dependencies):
"""Update dependency versions in pyproject.toml"""
from afterpython.tools.pyproject import read_pyproject, write_pyproject
doc: TOMLDocument = read_pyproject()
for dep_type in dependencies:
# category = extras or group name
for category, deps in dependencies[dep_type].items():
if dep_type == "dependencies":
doc_deps = doc["project"][dep_type]
elif dep_type == "optional-dependencies":
doc_deps = doc["project"][dep_type][category]
elif dep_type in ["dependency-groups", "build-system"]:
doc_deps = doc[dep_type][category]
else:
raise ValueError(f"Invalid dependency type: {dep_type}")
# Update in place
for i, (dep, package) in enumerate(zip(deps, doc_deps, strict=False)):
package: str # package = e.g. "click>=8.3.0"
req: Requirement = dep.requirement
min_ver = str(dep.min_version) if dep.min_version else None
max_ver = str(dep.max_version) if dep.max_version else None
latest_version = dep.latest_version
if (
req.name in package
and min_ver
and latest_version is not None
and min_ver in package
):
latest_ver = str(latest_version)
doc_deps[i] = package
# Update this first because latest_ver may equal max_ver.
if max_ver and latest_version not in req.specifier:
next_max_ver = str(latest_version.next_breaking())
doc_deps[i] = doc_deps[i].replace(max_ver, next_max_ver, 1)
doc_deps[i] = doc_deps[i].replace(min_ver, latest_ver, 1)
write_pyproject(doc)