Skip to content

Commit 186876c

Browse files
authored
Workflow to automatically sync typeshed (#13845)
Resolves #13812
1 parent efd713a commit 186876c

File tree

3 files changed

+134
-10
lines changed

3 files changed

+134
-10
lines changed

.github/workflows/sync_typeshed.yml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
name: Sync typeshed
2+
3+
on:
4+
workflow_dispatch:
5+
schedule:
6+
- cron: "0 0 1,15 * *"
7+
8+
permissions:
9+
contents: write
10+
pull-requests: write
11+
12+
jobs:
13+
sync_typeshed:
14+
name: Sync typeshed
15+
if: github.repository == 'python/mypy'
16+
runs-on: ubuntu-latest
17+
steps:
18+
- uses: actions/checkout@v3
19+
# TODO: use whatever solution ends up working for
20+
# https://github.com/python/typeshed/issues/8434
21+
- uses: actions/setup-python@v4
22+
with:
23+
python-version: "3.10"
24+
- name: git config
25+
run: |
26+
git config --global user.name mypybot
27+
git config --global user.email '<>'
28+
- name: Sync typeshed
29+
run: |
30+
python -m pip install requests==2.28.1
31+
GITHUB_TOKEN=${{ secrets.GITHUB_TOKEN }} python misc/sync-typeshed.py --make-pr

misc/sync-typeshed.py

Lines changed: 102 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,21 @@
1010
from __future__ import annotations
1111

1212
import argparse
13+
import functools
1314
import os
15+
import re
1416
import shutil
1517
import subprocess
1618
import sys
1719
import tempfile
1820
import textwrap
21+
from collections.abc import Mapping
22+
23+
import requests
1924

2025

2126
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"):
2328
sys.exit("error: The current working directory must be the mypy repository root")
2429
out = subprocess.check_output(["git", "status", "-s", os.path.join("mypy", "typeshed")])
2530
if out:
@@ -37,6 +42,7 @@ def update_typeshed(typeshed_dir: str, commit: str | None) -> str:
3742
if commit:
3843
subprocess.run(["git", "checkout", commit], check=True, cwd=typeshed_dir)
3944
commit = git_head_commit(typeshed_dir)
45+
4046
stdlib_dir = os.path.join("mypy", "typeshed", "stdlib")
4147
# Remove existing stubs.
4248
shutil.rmtree(stdlib_dir)
@@ -60,6 +66,69 @@ def git_head_commit(repo: str) -> str:
6066
return commit.strip()
6167

6268

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+
63132
def main() -> None:
64133
parser = argparse.ArgumentParser()
65134
parser.add_argument(
@@ -72,12 +141,21 @@ def main() -> None:
72141
default=None,
73142
help="Location of typeshed (default to a temporary repository clone)",
74143
)
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+
)
75149
args = parser.parse_args()
150+
76151
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)
81159

82160
if not args.typeshed_dir:
83161
# Clone typeshed repo if no directory given.
@@ -95,19 +173,34 @@ def main() -> None:
95173

96174
# Create a commit
97175
message = textwrap.dedent(
98-
"""\
176+
f"""\
99177
Sync typeshed
100178
101179
Source commit:
102180
https://github.com/python/typeshed/commit/{commit}
103-
""".format(
104-
commit=commit
105-
)
181+
"""
106182
)
107183
subprocess.run(["git", "add", "--all", os.path.join("mypy", "typeshed")], check=True)
108184
subprocess.run(["git", "commit", "-m", message], check=True)
109185
print("Created typeshed sync commit.")
110186

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+
111204

112205
if __name__ == "__main__":
113206
main()

tox.ini

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ commands =
2929
description = type check ourselves
3030
commands =
3131
python -m mypy --config-file mypy_self_check.ini -p mypy -p mypyc
32-
python -m mypy --config-file mypy_self_check.ini misc --exclude misc/fix_annotate.py --exclude misc/async_matrix.py
32+
python -m mypy --config-file mypy_self_check.ini misc --exclude misc/fix_annotate.py --exclude misc/async_matrix.py --exclude misc/sync-typeshed.py
3333

3434
[testenv:docs]
3535
description = invoke sphinx-build to build the HTML docs

0 commit comments

Comments
 (0)