10
10
from __future__ import annotations
11
11
12
12
import argparse
13
+ import functools
13
14
import os
15
+ import re
14
16
import shutil
15
17
import subprocess
16
18
import sys
17
19
import tempfile
18
20
import textwrap
21
+ from collections .abc import Mapping
22
+
23
+ import requests
19
24
20
25
21
26
def check_state () -> None :
22
- if not os .path .isfile ("README.md" ):
27
+ if not os .path .isfile ("README.md" ) and not os . path . isdir ( "mypy" ) :
23
28
sys .exit ("error: The current working directory must be the mypy repository root" )
24
29
out = subprocess .check_output (["git" , "status" , "-s" , os .path .join ("mypy" , "typeshed" )])
25
30
if out :
@@ -37,6 +42,7 @@ def update_typeshed(typeshed_dir: str, commit: str | None) -> str:
37
42
if commit :
38
43
subprocess .run (["git" , "checkout" , commit ], check = True , cwd = typeshed_dir )
39
44
commit = git_head_commit (typeshed_dir )
45
+
40
46
stdlib_dir = os .path .join ("mypy" , "typeshed" , "stdlib" )
41
47
# Remove existing stubs.
42
48
shutil .rmtree (stdlib_dir )
@@ -60,6 +66,69 @@ def git_head_commit(repo: str) -> str:
60
66
return commit .strip ()
61
67
62
68
69
+ @functools .cache
70
+ def get_github_api_headers () -> Mapping [str , str ]:
71
+ headers = {"Accept" : "application/vnd.github.v3+json" }
72
+ secret = os .environ .get ("GITHUB_TOKEN" )
73
+ if secret is not None :
74
+ headers ["Authorization" ] = (
75
+ f"token { secret } " if secret .startswith ("ghp" ) else f"Bearer { secret } "
76
+ )
77
+ return headers
78
+
79
+
80
+ @functools .cache
81
+ def get_origin_owner () -> str :
82
+ output = subprocess .check_output (["git" , "remote" , "get-url" , "origin" ], text = True ).strip ()
83
+ match = re .match (
84
+ r"(git@github.com:|https://github.com/)(?P<owner>[^/]+)/(?P<repo>[^/\s]+)" , output
85
+ )
86
+ assert match is not None , f"Couldn't identify origin's owner: { output !r} "
87
+ assert (
88
+ match .group ("repo" ).removesuffix (".git" ) == "mypy"
89
+ ), f'Unexpected repo: { match .group ("repo" )!r} '
90
+ return match .group ("owner" )
91
+
92
+
93
+ def create_or_update_pull_request (* , title : str , body : str , branch_name : str ) -> None :
94
+ fork_owner = get_origin_owner ()
95
+
96
+ with requests .post (
97
+ "https://api.github.com/repos/python/mypy/pulls" ,
98
+ json = {
99
+ "title" : title ,
100
+ "body" : body ,
101
+ "head" : f"{ fork_owner } :{ branch_name } " ,
102
+ "base" : "master" ,
103
+ },
104
+ headers = get_github_api_headers (),
105
+ ) as response :
106
+ resp_json = response .json ()
107
+ if response .status_code == 422 and any (
108
+ "A pull request already exists" in e .get ("message" , "" )
109
+ for e in resp_json .get ("errors" , [])
110
+ ):
111
+ # Find the existing PR
112
+ with requests .get (
113
+ "https://api.github.com/repos/python/mypy/pulls" ,
114
+ params = {"state" : "open" , "head" : f"{ fork_owner } :{ branch_name } " , "base" : "master" },
115
+ headers = get_github_api_headers (),
116
+ ) as response :
117
+ response .raise_for_status ()
118
+ resp_json = response .json ()
119
+ assert len (resp_json ) >= 1
120
+ pr_number = resp_json [0 ]["number" ]
121
+ # Update the PR's title and body
122
+ with requests .patch (
123
+ f"https://api.github.com/repos/python/mypy/pulls/{ pr_number } " ,
124
+ json = {"title" : title , "body" : body },
125
+ headers = get_github_api_headers (),
126
+ ) as response :
127
+ response .raise_for_status ()
128
+ return
129
+ response .raise_for_status ()
130
+
131
+
63
132
def main () -> None :
64
133
parser = argparse .ArgumentParser ()
65
134
parser .add_argument (
@@ -72,12 +141,21 @@ def main() -> None:
72
141
default = None ,
73
142
help = "Location of typeshed (default to a temporary repository clone)" ,
74
143
)
144
+ parser .add_argument (
145
+ "--make-pr" ,
146
+ action = "store_true" ,
147
+ help = "Whether to make a PR with the changes (default to no)" ,
148
+ )
75
149
args = parser .parse_args ()
150
+
76
151
check_state ()
77
- print ("Update contents of mypy/typeshed from typeshed? [yN] " , end = "" )
78
- answer = input ()
79
- if answer .lower () != "y" :
80
- sys .exit ("Aborting" )
152
+
153
+ if args .make_pr :
154
+ if os .environ .get ("GITHUB_TOKEN" ) is None :
155
+ raise ValueError ("GITHUB_TOKEN environment variable must be set" )
156
+
157
+ branch_name = "mypybot/sync-typeshed"
158
+ subprocess .run (["git" , "checkout" , "-B" , branch_name , "origin/master" ], check = True )
81
159
82
160
if not args .typeshed_dir :
83
161
# Clone typeshed repo if no directory given.
@@ -95,19 +173,34 @@ def main() -> None:
95
173
96
174
# Create a commit
97
175
message = textwrap .dedent (
98
- """\
176
+ f """\
99
177
Sync typeshed
100
178
101
179
Source commit:
102
180
https://github.com/python/typeshed/commit/{ commit }
103
- """ .format (
104
- commit = commit
105
- )
181
+ """
106
182
)
107
183
subprocess .run (["git" , "add" , "--all" , os .path .join ("mypy" , "typeshed" )], check = True )
108
184
subprocess .run (["git" , "commit" , "-m" , message ], check = True )
109
185
print ("Created typeshed sync commit." )
110
186
187
+ # Currently just LiteralString reverts
188
+ commits_to_cherry_pick = ["780534b13722b7b0422178c049a1cbbf4ea4255b" ]
189
+ for commit in commits_to_cherry_pick :
190
+ subprocess .run (["git" , "cherry-pick" , commit ], check = True )
191
+ print (f"Cherry-picked { commit } ." )
192
+
193
+ if args .make_pr :
194
+ subprocess .run (["git" , "push" , "--force" , "origin" , branch_name ], check = True )
195
+ print ("Pushed commit." )
196
+
197
+ warning = "Note that you will need to close and re-open the PR in order to trigger CI."
198
+
199
+ create_or_update_pull_request (
200
+ title = "Sync typeshed" , body = message + "\n " + warning , branch_name = branch_name
201
+ )
202
+ print ("Created PR." )
203
+
111
204
112
205
if __name__ == "__main__" :
113
206
main ()
0 commit comments