Skip to content

Commit 2e50b58

Browse files
Link earliest version with py.typed in stubsabot obsoletion PRs (#8775)
Co-authored-by: Shantanu <12621235+hauntsaninja@users.noreply.github.com>
1 parent 1a3ae3e commit 2e50b58

File tree

1 file changed

+73
-35
lines changed

1 file changed

+73
-35
lines changed

scripts/stubsabot.py

Lines changed: 73 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,11 @@
1616
import textwrap
1717
import urllib.parse
1818
import zipfile
19-
from collections.abc import Mapping
19+
from collections.abc import Iterator, Mapping
2020
from dataclasses import dataclass
2121
from pathlib import Path
22-
from typing import Any, TypeVar
22+
from typing import Annotated, Any, TypeVar
23+
from typing_extensions import TypeAlias
2324

2425
import aiohttp
2526
import packaging.specifiers
@@ -71,32 +72,51 @@ def read_typeshed_stub_metadata(stub_path: Path) -> StubInfo:
7172

7273

7374
@dataclass
74-
class PypiInfo:
75-
distribution: str
75+
class PypiReleaseDownload:
76+
url: str
77+
packagetype: Annotated[str, "Should hopefully be either 'bdist_wheel' or 'sdist'"]
78+
filename: str
7679
version: packaging.version.Version
7780
upload_date: datetime.datetime
78-
# https://warehouse.pypa.io/api-reference/json.html#get--pypi--project_name--json
79-
# Corresponds to a single entry from `releases` for the given version
80-
release_to_download: dict[str, Any]
81+
82+
83+
VersionString: TypeAlias = str
84+
ReleaseDownload: TypeAlias = dict[str, Any]
85+
86+
87+
@dataclass
88+
class PypiInfo:
89+
distribution: str
90+
pypi_root: str
91+
releases: dict[VersionString, list[ReleaseDownload]]
8192
info: dict[str, Any]
8293

94+
def get_release(self, *, version: VersionString) -> PypiReleaseDownload:
95+
# prefer wheels, since it's what most users will get / it's pretty easy to mess up MANIFEST
96+
release_info = sorted(self.releases[version], key=lambda x: bool(x["packagetype"] == "bdist_wheel"))[-1]
97+
return PypiReleaseDownload(
98+
url=release_info["url"],
99+
packagetype=release_info["packagetype"],
100+
filename=release_info["filename"],
101+
version=packaging.version.Version(version),
102+
upload_date=datetime.datetime.fromisoformat(release_info["upload_time"]),
103+
)
104+
105+
def get_latest_release(self) -> PypiReleaseDownload:
106+
return self.get_release(version=self.info["version"])
107+
108+
def releases_in_descending_order(self) -> Iterator[PypiReleaseDownload]:
109+
for version in sorted(self.releases, key=packaging.version.Version, reverse=True):
110+
yield self.get_release(version=version)
111+
83112

84113
async def fetch_pypi_info(distribution: str, session: aiohttp.ClientSession) -> PypiInfo:
85-
url = f"https://pypi.org/pypi/{urllib.parse.quote(distribution)}/json"
86-
async with session.get(url) as response:
114+
# Cf. # https://warehouse.pypa.io/api-reference/json.html#get--pypi--project_name--json
115+
pypi_root = f"https://pypi.org/pypi/{urllib.parse.quote(distribution)}"
116+
async with session.get(f"{pypi_root}/json") as response:
87117
response.raise_for_status()
88118
j = await response.json()
89-
version = j["info"]["version"]
90-
# prefer wheels, since it's what most users will get / it's pretty easy to mess up MANIFEST
91-
release_to_download = sorted(j["releases"][version], key=lambda x: bool(x["packagetype"] == "bdist_wheel"))[-1]
92-
date = datetime.datetime.fromisoformat(release_to_download["upload_time"])
93-
return PypiInfo(
94-
distribution=distribution,
95-
version=packaging.version.Version(version),
96-
upload_date=date,
97-
release_to_download=release_to_download,
98-
info=j["info"],
99-
)
119+
return PypiInfo(distribution=distribution, pypi_root=pypi_root, releases=j["releases"], info=j["info"])
100120

101121

102122
@dataclass
@@ -132,21 +152,28 @@ def __str__(self) -> str:
132152
return f"Skipping {self.distribution}: {self.reason}"
133153

134154

135-
async def package_contains_py_typed(release_to_download: dict[str, Any], session: aiohttp.ClientSession) -> bool:
136-
async with session.get(release_to_download["url"]) as response:
155+
async def release_contains_py_typed(release_to_download: PypiReleaseDownload, *, session: aiohttp.ClientSession) -> bool:
156+
async with session.get(release_to_download.url) as response:
137157
body = io.BytesIO(await response.read())
138158

139-
packagetype = release_to_download["packagetype"]
159+
packagetype = release_to_download.packagetype
140160
if packagetype == "bdist_wheel":
141-
assert release_to_download["filename"].endswith(".whl")
161+
assert release_to_download.filename.endswith(".whl")
142162
with zipfile.ZipFile(body) as zf:
143163
return any(Path(f).name == "py.typed" for f in zf.namelist())
144164
elif packagetype == "sdist":
145-
assert release_to_download["filename"].endswith(".tar.gz")
165+
assert release_to_download.filename.endswith(".tar.gz")
146166
with tarfile.open(fileobj=body, mode="r:gz") as zf:
147167
return any(Path(f).name == "py.typed" for f in zf.getnames())
148168
else:
149-
raise AssertionError(f"Unknown package type: {packagetype}")
169+
raise AssertionError(f"Unknown package type: {packagetype!r}")
170+
171+
172+
async def find_first_release_with_py_typed(pypi_info: PypiInfo, *, session: aiohttp.ClientSession) -> PypiReleaseDownload:
173+
release_iter = pypi_info.releases_in_descending_order()
174+
while await release_contains_py_typed(release := next(release_iter), session=session):
175+
first_release_with_py_typed = release
176+
return first_release_with_py_typed
150177

151178

152179
def _check_spec(updated_spec: str, version: packaging.version.Version) -> str:
@@ -215,7 +242,9 @@ async def get_github_repo_info(session: aiohttp.ClientSession, pypi_info: PypiIn
215242
return None
216243

217244

218-
async def get_diff_url(session: aiohttp.ClientSession, stub_info: StubInfo, pypi_info: PypiInfo) -> str | None:
245+
async def get_diff_url(
246+
session: aiohttp.ClientSession, stub_info: StubInfo, pypi_info: PypiInfo, pypi_version: packaging.version.Version
247+
) -> str | None:
219248
"""Return a link giving the diff between two releases, if possible.
220249
221250
Return `None` if the project isn't hosted on GitHub,
@@ -237,7 +266,7 @@ async def get_diff_url(session: aiohttp.ClientSession, stub_info: StubInfo, pypi
237266
curr_specifier = packaging.specifiers.SpecifierSet(f"=={stub_info.version_spec}")
238267

239268
try:
240-
new_tag = versions_to_tags[pypi_info.version]
269+
new_tag = versions_to_tags[pypi_version]
241270
except KeyError:
242271
return None
243272

@@ -263,33 +292,42 @@ async def determine_action(stub_path: Path, session: aiohttp.ClientSession) -> U
263292
return NoUpdate(stub_info.distribution, "no longer updated")
264293

265294
pypi_info = await fetch_pypi_info(stub_info.distribution, session)
295+
latest_release = pypi_info.get_latest_release()
296+
latest_version = latest_release.version
266297
spec = packaging.specifiers.SpecifierSet(f"=={stub_info.version_spec}")
267-
if pypi_info.version in spec:
298+
if latest_version in spec:
268299
return NoUpdate(stub_info.distribution, "up to date")
269300

301+
is_obsolete = await release_contains_py_typed(latest_release, session=session)
302+
if is_obsolete:
303+
first_release_with_py_typed = await find_first_release_with_py_typed(pypi_info, session=session)
304+
relevant_version = version_obsolete_since = first_release_with_py_typed.version
305+
else:
306+
relevant_version = latest_version
307+
270308
project_urls = pypi_info.info["project_urls"] or {}
271309
maybe_links: dict[str, str | None] = {
272-
"Release": pypi_info.info["release_url"],
310+
"Release": f"{pypi_info.pypi_root}/{relevant_version}",
273311
"Homepage": project_urls.get("Homepage"),
274312
"Changelog": project_urls.get("Changelog") or project_urls.get("Changes") or project_urls.get("Change Log"),
275-
"Diff": await get_diff_url(session, stub_info, pypi_info),
313+
"Diff": await get_diff_url(session, stub_info, pypi_info, relevant_version),
276314
}
277315
links = {k: v for k, v in maybe_links.items() if v is not None}
278316

279-
if await package_contains_py_typed(pypi_info.release_to_download, session):
317+
if is_obsolete:
280318
return Obsolete(
281319
stub_info.distribution,
282320
stub_path,
283-
obsolete_since_version=str(pypi_info.version),
284-
obsolete_since_date=pypi_info.upload_date,
321+
obsolete_since_version=str(version_obsolete_since),
322+
obsolete_since_date=first_release_with_py_typed.upload_date,
285323
links=links,
286324
)
287325

288326
return Update(
289327
distribution=stub_info.distribution,
290328
stub_path=stub_path,
291329
old_version_spec=stub_info.version_spec,
292-
new_version_spec=get_updated_version_spec(stub_info.version_spec, pypi_info.version),
330+
new_version_spec=get_updated_version_spec(stub_info.version_spec, latest_version),
293331
links=links,
294332
)
295333

0 commit comments

Comments
 (0)