4
4
import argparse
5
5
import asyncio
6
6
import datetime
7
+ import enum
8
+ import io
7
9
import os
8
10
import re
9
11
import subprocess
10
12
import sys
13
+ import tarfile
11
14
import urllib .parse
15
+ import zipfile
12
16
from dataclasses import dataclass
13
17
from pathlib import Path
18
+ from typing import Any
14
19
15
20
import aiohttp
16
21
import packaging .specifiers
19
24
import tomlkit
20
25
21
26
27
+ class ActionLevel (enum .IntEnum ):
28
+ nothing = 0 # make no changes
29
+ local = 1 # make changes that affect local repo
30
+ everything = 2 # do everything, e.g. open PRs
31
+
32
+
22
33
@dataclass
23
34
class StubInfo :
24
35
distribution : str
@@ -43,6 +54,7 @@ class PypiInfo:
43
54
distribution : str
44
55
version : packaging .version .Version
45
56
upload_date : datetime .datetime
57
+ release_to_download : dict [str , Any ]
46
58
47
59
48
60
async def fetch_pypi_info (distribution : str , session : aiohttp .ClientSession ) -> PypiInfo :
@@ -51,9 +63,14 @@ async def fetch_pypi_info(distribution: str, session: aiohttp.ClientSession) ->
51
63
response .raise_for_status ()
52
64
j = await response .json ()
53
65
version = j ["info" ]["version" ]
54
- date = datetime .datetime .fromisoformat (j ["releases" ][version ][0 ]["upload_time" ])
66
+ # prefer wheels, since it's what most users will get / it's pretty easy to mess up MANIFEST
67
+ release_to_download = sorted (j ["releases" ][version ], key = lambda x : bool (x ["packagetype" ] == "bdist_wheel" ))[- 1 ]
68
+ date = datetime .datetime .fromisoformat (release_to_download ["upload_time" ])
55
69
return PypiInfo (
56
- distribution = distribution , version = packaging .version .Version (version ), upload_date = date
70
+ distribution = distribution ,
71
+ version = packaging .version .Version (version ),
72
+ upload_date = date ,
73
+ release_to_download = release_to_download ,
57
74
)
58
75
59
76
@@ -64,17 +81,47 @@ class Update:
64
81
old_version_spec : str
65
82
new_version_spec : str
66
83
84
+ def __str__ (self ) -> str :
85
+ return f"Updating { self .distribution } from { self .old_version_spec !r} to { self .new_version_spec !r} "
86
+
87
+
88
+ @dataclass
89
+ class Obsolete :
90
+ distribution : str
91
+ stub_path : Path
92
+ obsolete_since_version : str
93
+
94
+ def __str__ (self ) -> str :
95
+ return f"Marking { self .distribution } as obsolete since { self .obsolete_since_version !r} "
96
+
67
97
68
98
@dataclass
69
99
class NoUpdate :
70
100
distribution : str
71
101
reason : str
72
102
103
+ def __str__ (self ) -> str :
104
+ return f"Skipping { self .distribution } : { self .reason } "
105
+
106
+
107
+ async def package_contains_py_typed (release_to_download : dict [str , Any ], session : aiohttp .ClientSession ) -> bool :
108
+ async with session .get (release_to_download ["url" ]) as response :
109
+ body = io .BytesIO (await response .read ())
110
+
111
+ if release_to_download ["packagetype" ] == "bdist_wheel" :
112
+ assert release_to_download ["filename" ].endswith (".whl" )
113
+ with zipfile .ZipFile (body ) as zf :
114
+ return any (Path (f ).name == "py.typed" for f in zf .namelist ())
115
+ elif release_to_download ["packagetype" ] == "sdist" :
116
+ assert release_to_download ["filename" ].endswith (".tar.gz" )
117
+ with tarfile .open (fileobj = body , mode = "r:gz" ) as zf :
118
+ return any (Path (f ).name == "py.typed" for f in zf .getnames ())
119
+ else :
120
+ raise AssertionError
121
+
73
122
74
123
def _check_spec (updated_spec : str , version : packaging .version .Version ) -> str :
75
- assert version in packaging .specifiers .SpecifierSet (
76
- "==" + updated_spec
77
- ), f"{ version } not in { updated_spec } "
124
+ assert version in packaging .specifiers .SpecifierSet ("==" + updated_spec ), f"{ version } not in { updated_spec } "
78
125
return updated_spec
79
126
80
127
@@ -89,7 +136,7 @@ def get_updated_version_spec(spec: str, version: packaging.version.Version) -> s
89
136
return _check_spec ("." .join (rounded_version ) + ".*" , version )
90
137
91
138
92
- async def determine_action (stub_path : Path , session : aiohttp .ClientSession ) -> Update | NoUpdate :
139
+ async def determine_action (stub_path : Path , session : aiohttp .ClientSession ) -> Update | NoUpdate | Obsolete :
93
140
stub_info = read_typeshed_stub_metadata (stub_path )
94
141
if stub_info .obsolete :
95
142
return NoUpdate (stub_info .distribution , "obsolete" )
@@ -101,6 +148,9 @@ async def determine_action(stub_path: Path, session: aiohttp.ClientSession) -> U
101
148
if pypi_info .version in spec :
102
149
return NoUpdate (stub_info .distribution , "up to date" )
103
150
151
+ if await package_contains_py_typed (pypi_info .release_to_download , session ):
152
+ return Obsolete (stub_info .distribution , stub_path , obsolete_since_version = str (pypi_info .version ))
153
+
104
154
return Update (
105
155
distribution = stub_info .distribution ,
106
156
stub_path = stub_path ,
@@ -109,23 +159,60 @@ async def determine_action(stub_path: Path, session: aiohttp.ClientSession) -> U
109
159
)
110
160
111
161
162
+ TYPESHED_OWNER = "python"
163
+ FORK_OWNER = "hauntsaninja"
164
+
165
+
166
+ async def create_or_update_pull_request (title : str , branch_name : str , session : aiohttp .ClientSession ):
167
+ secret = os .environ ["GITHUB_TOKEN" ]
168
+ if secret .startswith ("ghp" ):
169
+ auth = f"token { secret } "
170
+ else :
171
+ auth = f"Bearer { secret } "
172
+
173
+ async with session .post (
174
+ f"https://api.github.com/repos/{ TYPESHED_OWNER } /typeshed/pulls" ,
175
+ json = {"title" : title , "head" : f"{ FORK_OWNER } :{ branch_name } " , "base" : "master" },
176
+ headers = {"Accept" : "application/vnd.github.v3+json" , "Authorization" : auth },
177
+ ) as response :
178
+ body = await response .json ()
179
+ if response .status == 422 and any (
180
+ "A pull request already exists" in e .get ("message" , "" ) for e in body .get ("errors" , [])
181
+ ):
182
+ # Find the existing PR
183
+ async with session .get (
184
+ f"https://api.github.com/repos/{ TYPESHED_OWNER } /typeshed/pulls" ,
185
+ params = {"state" : "open" , "head" : f"{ FORK_OWNER } :{ branch_name } " , "base" : "master" },
186
+ headers = {"Accept" : "application/vnd.github.v3+json" , "Authorization" : auth },
187
+ ) as response :
188
+ response .raise_for_status ()
189
+ body = await response .json ()
190
+ assert len (body ) >= 1
191
+ pr_number = body [0 ]["number" ]
192
+ # Update the PR's title
193
+ async with session .patch (
194
+ f"https://api.github.com/repos/{ TYPESHED_OWNER } /typeshed/pulls/{ pr_number } " ,
195
+ json = {"title" : title },
196
+ headers = {"Accept" : "application/vnd.github.v3+json" , "Authorization" : auth },
197
+ ) as response :
198
+ response .raise_for_status ()
199
+ return
200
+ response .raise_for_status ()
201
+
202
+
112
203
def normalize (name : str ) -> str :
113
204
# PEP 503 normalization
114
205
return re .sub (r"[-_.]+" , "-" , name ).lower ()
115
206
116
207
208
+ # lock should be unnecessary, but can't hurt to enforce mutual exclusion
117
209
_repo_lock = asyncio .Lock ()
118
210
119
- TYPESHED_OWNER = "python"
120
- FORK_OWNER = "hauntsaninja"
121
-
122
211
123
- async def suggest_typeshed_update (
124
- update : Update , session : aiohttp . ClientSession , dry_run : bool
125
- ) -> None :
212
+ async def suggest_typeshed_update (update : Update , session : aiohttp . ClientSession , action_level : ActionLevel ) -> None :
213
+ if action_level <= ActionLevel . nothing :
214
+ return
126
215
title = f"[stubsabot] Bump { update .distribution } to { update .new_version_spec } "
127
-
128
- # lock should be unnecessary, but can't hurt to enforce mutual exclusion
129
216
async with _repo_lock :
130
217
branch_name = f"stubsabot/{ normalize (update .distribution )} "
131
218
subprocess .check_call (["git" , "checkout" , "-B" , branch_name , "origin/master" ])
@@ -135,56 +222,66 @@ async def suggest_typeshed_update(
135
222
with open (update .stub_path / "METADATA.toml" , "w" ) as f :
136
223
tomlkit .dump (meta , f )
137
224
subprocess .check_call (["git" , "commit" , "--all" , "-m" , title ])
138
- if dry_run :
225
+ if action_level <= ActionLevel . local :
139
226
return
140
227
subprocess .check_call (["git" , "push" , "origin" , branch_name , "--force-with-lease" ])
141
228
142
- secret = os .environ ["GITHUB_TOKEN" ]
143
- if secret .startswith ("ghp" ):
144
- auth = f"token { secret } "
145
- else :
146
- auth = f"Bearer { secret } "
229
+ await create_or_update_pull_request (title , branch_name , session )
147
230
148
- async with session .post (
149
- f"https://api.github.com/repos/{ TYPESHED_OWNER } /typeshed/pulls" ,
150
- json = {"title" : title , "head" : f"{ FORK_OWNER } :{ branch_name } " , "base" : "master" },
151
- headers = {"Accept" : "application/vnd.github.v3+json" , "Authorization" : auth },
152
- ) as response :
153
- body = await response .json ()
154
- if response .status == 422 and any (
155
- "A pull request already exists" in e .get ("message" , "" ) for e in body .get ("errors" , [])
156
- ):
157
- # TODO: diff and update existing pull request
231
+
232
+ async def suggest_typeshed_obsolete (obsolete : Obsolete , session : aiohttp .ClientSession , action_level : ActionLevel ) -> None :
233
+ if action_level <= ActionLevel .nothing :
234
+ return
235
+ title = f"[stubsabot] Mark { obsolete .distribution } as obsolete since { obsolete .obsolete_since_version } "
236
+ async with _repo_lock :
237
+ branch_name = f"stubsabot/{ normalize (obsolete .distribution )} "
238
+ subprocess .check_call (["git" , "checkout" , "-B" , branch_name , "origin/master" ])
239
+ with open (obsolete .stub_path / "METADATA.toml" , "rb" ) as f :
240
+ meta = tomlkit .load (f )
241
+ meta ["obsolete_since" ] = obsolete .obsolete_since_version
242
+ with open (obsolete .stub_path / "METADATA.toml" , "w" ) as f :
243
+ tomlkit .dump (meta , f )
244
+ subprocess .check_call (["git" , "commit" , "--all" , "-m" , title ])
245
+ if action_level <= ActionLevel .local :
158
246
return
159
- response .raise_for_status ()
247
+ subprocess .check_call (["git" , "push" , "origin" , branch_name , "--force-with-lease" ])
248
+
249
+ await create_or_update_pull_request (title , branch_name , session )
160
250
161
251
162
252
async def main () -> None :
163
253
assert sys .version_info >= (3 , 9 )
164
254
165
255
parser = argparse .ArgumentParser ()
166
- parser .add_argument ("--dry-run" , action = "store_true" )
256
+ parser .add_argument (
257
+ "--action-level" ,
258
+ type = lambda x : getattr (ActionLevel , x ), # type: ignore[no-any-return]
259
+ default = ActionLevel .everything ,
260
+ help = "Limit actions performed to achieve dry runs for different levels of dryness" ,
261
+ )
167
262
args = parser .parse_args ()
168
263
169
264
try :
170
265
conn = aiohttp .TCPConnector (limit_per_host = 10 )
171
266
async with aiohttp .ClientSession (connector = conn ) as session :
172
- tasks = [
173
- asyncio .create_task (determine_action (stubs_path , session ))
174
- for stubs_path in Path ("stubs" ).iterdir ()
175
- ]
267
+ tasks = [asyncio .create_task (determine_action (stubs_path , session )) for stubs_path in Path ("stubs" ).iterdir ()]
176
268
for task in asyncio .as_completed (tasks ):
177
269
update = await task
270
+ print (update )
178
271
if isinstance (update , NoUpdate ):
179
272
continue
180
273
if isinstance (update , Update ):
181
- await suggest_typeshed_update (update , session , dry_run = args .dry_run )
274
+ await suggest_typeshed_update (update , session , action_level = args .action_level )
275
+ continue
276
+ if isinstance (update , Obsolete ):
277
+ await suggest_typeshed_obsolete (update , session , action_level = args .action_level )
182
278
continue
183
279
raise AssertionError
184
280
finally :
185
281
# if you need to cleanup, try:
186
282
# git branch -D $(git branch --list 'stubsabot/*')
187
- subprocess .check_call (["git" , "checkout" , "master" ])
283
+ if args .action_level >= ActionLevel .local :
284
+ subprocess .check_call (["git" , "checkout" , "master" ])
188
285
189
286
190
287
if __name__ == "__main__" :
0 commit comments