16
16
import textwrap
17
17
import urllib .parse
18
18
import zipfile
19
- from collections .abc import Mapping
19
+ from collections .abc import Iterator , Mapping
20
20
from dataclasses import dataclass
21
21
from pathlib import Path
22
- from typing import Any , TypeVar
22
+ from typing import Annotated , Any , TypeVar
23
+ from typing_extensions import TypeAlias
23
24
24
25
import aiohttp
25
26
import packaging .specifiers
@@ -71,32 +72,51 @@ def read_typeshed_stub_metadata(stub_path: Path) -> StubInfo:
71
72
72
73
73
74
@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
76
79
version : packaging .version .Version
77
80
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 ]]
81
92
info : dict [str , Any ]
82
93
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
+
83
112
84
113
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 :
87
117
response .raise_for_status ()
88
118
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" ])
100
120
101
121
102
122
@dataclass
@@ -132,21 +152,28 @@ def __str__(self) -> str:
132
152
return f"Skipping { self .distribution } : { self .reason } "
133
153
134
154
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 :
137
157
body = io .BytesIO (await response .read ())
138
158
139
- packagetype = release_to_download [ " packagetype" ]
159
+ packagetype = release_to_download . packagetype
140
160
if packagetype == "bdist_wheel" :
141
- assert release_to_download [ " filename" ] .endswith (".whl" )
161
+ assert release_to_download . filename .endswith (".whl" )
142
162
with zipfile .ZipFile (body ) as zf :
143
163
return any (Path (f ).name == "py.typed" for f in zf .namelist ())
144
164
elif packagetype == "sdist" :
145
- assert release_to_download [ " filename" ] .endswith (".tar.gz" )
165
+ assert release_to_download . filename .endswith (".tar.gz" )
146
166
with tarfile .open (fileobj = body , mode = "r:gz" ) as zf :
147
167
return any (Path (f ).name == "py.typed" for f in zf .getnames ())
148
168
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
150
177
151
178
152
179
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
215
242
return None
216
243
217
244
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 :
219
248
"""Return a link giving the diff between two releases, if possible.
220
249
221
250
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
237
266
curr_specifier = packaging .specifiers .SpecifierSet (f"=={ stub_info .version_spec } " )
238
267
239
268
try :
240
- new_tag = versions_to_tags [pypi_info . version ]
269
+ new_tag = versions_to_tags [pypi_version ]
241
270
except KeyError :
242
271
return None
243
272
@@ -263,33 +292,42 @@ async def determine_action(stub_path: Path, session: aiohttp.ClientSession) -> U
263
292
return NoUpdate (stub_info .distribution , "no longer updated" )
264
293
265
294
pypi_info = await fetch_pypi_info (stub_info .distribution , session )
295
+ latest_release = pypi_info .get_latest_release ()
296
+ latest_version = latest_release .version
266
297
spec = packaging .specifiers .SpecifierSet (f"=={ stub_info .version_spec } " )
267
- if pypi_info . version in spec :
298
+ if latest_version in spec :
268
299
return NoUpdate (stub_info .distribution , "up to date" )
269
300
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
+
270
308
project_urls = pypi_info .info ["project_urls" ] or {}
271
309
maybe_links : dict [str , str | None ] = {
272
- "Release" : pypi_info .info [ "release_url" ] ,
310
+ "Release" : f" { pypi_info .pypi_root } / { relevant_version } " ,
273
311
"Homepage" : project_urls .get ("Homepage" ),
274
312
"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 ),
276
314
}
277
315
links = {k : v for k , v in maybe_links .items () if v is not None }
278
316
279
- if await package_contains_py_typed ( pypi_info . release_to_download , session ) :
317
+ if is_obsolete :
280
318
return Obsolete (
281
319
stub_info .distribution ,
282
320
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 ,
285
323
links = links ,
286
324
)
287
325
288
326
return Update (
289
327
distribution = stub_info .distribution ,
290
328
stub_path = stub_path ,
291
329
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 ),
293
331
links = links ,
294
332
)
295
333
0 commit comments