Skip to content

Commit 2cc1c9b

Browse files
author
hauntsaninja
committed
stubsabot updates
1 parent 1c89081 commit 2cc1c9b

File tree

1 file changed

+135
-38
lines changed

1 file changed

+135
-38
lines changed

scripts/stubsabot.py

Lines changed: 135 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,18 @@
44
import argparse
55
import asyncio
66
import datetime
7+
import enum
8+
import io
79
import os
810
import re
911
import subprocess
1012
import sys
13+
import tarfile
1114
import urllib.parse
15+
import zipfile
1216
from dataclasses import dataclass
1317
from pathlib import Path
18+
from typing import Any
1419

1520
import aiohttp
1621
import packaging.specifiers
@@ -19,6 +24,12 @@
1924
import tomlkit
2025

2126

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+
2233
@dataclass
2334
class StubInfo:
2435
distribution: str
@@ -43,6 +54,7 @@ class PypiInfo:
4354
distribution: str
4455
version: packaging.version.Version
4556
upload_date: datetime.datetime
57+
release_to_download: dict[str, Any]
4658

4759

4860
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) ->
5163
response.raise_for_status()
5264
j = await response.json()
5365
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"])
5569
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,
5774
)
5875

5976

@@ -64,17 +81,47 @@ class Update:
6481
old_version_spec: str
6582
new_version_spec: str
6683

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

6898
@dataclass
6999
class NoUpdate:
70100
distribution: str
71101
reason: str
72102

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

74123
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}"
78125
return updated_spec
79126

80127

@@ -89,7 +136,7 @@ def get_updated_version_spec(spec: str, version: packaging.version.Version) -> s
89136
return _check_spec(".".join(rounded_version) + ".*", version)
90137

91138

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:
93140
stub_info = read_typeshed_stub_metadata(stub_path)
94141
if stub_info.obsolete:
95142
return NoUpdate(stub_info.distribution, "obsolete")
@@ -101,6 +148,9 @@ async def determine_action(stub_path: Path, session: aiohttp.ClientSession) -> U
101148
if pypi_info.version in spec:
102149
return NoUpdate(stub_info.distribution, "up to date")
103150

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+
104154
return Update(
105155
distribution=stub_info.distribution,
106156
stub_path=stub_path,
@@ -109,23 +159,60 @@ async def determine_action(stub_path: Path, session: aiohttp.ClientSession) -> U
109159
)
110160

111161

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+
112203
def normalize(name: str) -> str:
113204
# PEP 503 normalization
114205
return re.sub(r"[-_.]+", "-", name).lower()
115206

116207

208+
# lock should be unnecessary, but can't hurt to enforce mutual exclusion
117209
_repo_lock = asyncio.Lock()
118210

119-
TYPESHED_OWNER = "python"
120-
FORK_OWNER = "hauntsaninja"
121-
122211

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
126215
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
129216
async with _repo_lock:
130217
branch_name = f"stubsabot/{normalize(update.distribution)}"
131218
subprocess.check_call(["git", "checkout", "-B", branch_name, "origin/master"])
@@ -135,56 +222,66 @@ async def suggest_typeshed_update(
135222
with open(update.stub_path / "METADATA.toml", "w") as f:
136223
tomlkit.dump(meta, f)
137224
subprocess.check_call(["git", "commit", "--all", "-m", title])
138-
if dry_run:
225+
if action_level <= ActionLevel.local:
139226
return
140227
subprocess.check_call(["git", "push", "origin", branch_name, "--force-with-lease"])
141228

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)
147230

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:
158246
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)
160250

161251

162252
async def main() -> None:
163253
assert sys.version_info >= (3, 9)
164254

165255
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+
)
167262
args = parser.parse_args()
168263

169264
try:
170265
conn = aiohttp.TCPConnector(limit_per_host=10)
171266
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()]
176268
for task in asyncio.as_completed(tasks):
177269
update = await task
270+
print(update)
178271
if isinstance(update, NoUpdate):
179272
continue
180273
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)
182278
continue
183279
raise AssertionError
184280
finally:
185281
# if you need to cleanup, try:
186282
# 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"])
188285

189286

190287
if __name__ == "__main__":

0 commit comments

Comments
 (0)