Skip to content

Commit 1c89081

Browse files
author
hauntsaninja
committed
[stubsabot] proof of concept
1 parent 17e7cc7 commit 1c89081

File tree

1 file changed

+191
-0
lines changed

1 file changed

+191
-0
lines changed

scripts/stubsabot.py

Lines changed: 191 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,191 @@
1+
#!/usr/bin/env python3
2+
from __future__ import annotations
3+
4+
import argparse
5+
import asyncio
6+
import datetime
7+
import os
8+
import re
9+
import subprocess
10+
import sys
11+
import urllib.parse
12+
from dataclasses import dataclass
13+
from pathlib import Path
14+
15+
import aiohttp
16+
import packaging.specifiers
17+
import packaging.version
18+
import tomli
19+
import tomlkit
20+
21+
22+
@dataclass
23+
class StubInfo:
24+
distribution: str
25+
version_spec: str
26+
obsolete: bool
27+
no_longer_updated: bool
28+
29+
30+
def read_typeshed_stub_metadata(stub_path: Path) -> StubInfo:
31+
with (stub_path / "METADATA.toml").open("rb") as f:
32+
meta = tomli.load(f)
33+
return StubInfo(
34+
distribution=stub_path.name,
35+
version_spec=meta["version"],
36+
obsolete="obsolete_since" in meta,
37+
no_longer_updated=meta.get("no_longer_updated", False),
38+
)
39+
40+
41+
@dataclass
42+
class PypiInfo:
43+
distribution: str
44+
version: packaging.version.Version
45+
upload_date: datetime.datetime
46+
47+
48+
async def fetch_pypi_info(distribution: str, session: aiohttp.ClientSession) -> PypiInfo:
49+
url = f"https://pypi.org/pypi/{urllib.parse.quote(distribution)}/json"
50+
async with session.get(url) as response:
51+
response.raise_for_status()
52+
j = await response.json()
53+
version = j["info"]["version"]
54+
date = datetime.datetime.fromisoformat(j["releases"][version][0]["upload_time"])
55+
return PypiInfo(
56+
distribution=distribution, version=packaging.version.Version(version), upload_date=date
57+
)
58+
59+
60+
@dataclass
61+
class Update:
62+
distribution: str
63+
stub_path: Path
64+
old_version_spec: str
65+
new_version_spec: str
66+
67+
68+
@dataclass
69+
class NoUpdate:
70+
distribution: str
71+
reason: str
72+
73+
74+
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}"
78+
return updated_spec
79+
80+
81+
def get_updated_version_spec(spec: str, version: packaging.version.Version) -> str:
82+
if not spec.endswith(".*"):
83+
return _check_spec(version.base_version, version)
84+
85+
specificity = spec.count(".") if spec.removesuffix(".*") else 0
86+
rounded_version = version.base_version.split(".")[:specificity]
87+
rounded_version.extend(["0"] * (specificity - len(rounded_version)))
88+
89+
return _check_spec(".".join(rounded_version) + ".*", version)
90+
91+
92+
async def determine_action(stub_path: Path, session: aiohttp.ClientSession) -> Update | NoUpdate:
93+
stub_info = read_typeshed_stub_metadata(stub_path)
94+
if stub_info.obsolete:
95+
return NoUpdate(stub_info.distribution, "obsolete")
96+
if stub_info.no_longer_updated:
97+
return NoUpdate(stub_info.distribution, "no longer updated")
98+
99+
pypi_info = await fetch_pypi_info(stub_info.distribution, session)
100+
spec = packaging.specifiers.SpecifierSet("==" + stub_info.version_spec)
101+
if pypi_info.version in spec:
102+
return NoUpdate(stub_info.distribution, "up to date")
103+
104+
return Update(
105+
distribution=stub_info.distribution,
106+
stub_path=stub_path,
107+
old_version_spec=stub_info.version_spec,
108+
new_version_spec=get_updated_version_spec(stub_info.version_spec, pypi_info.version),
109+
)
110+
111+
112+
def normalize(name: str) -> str:
113+
# PEP 503 normalization
114+
return re.sub(r"[-_.]+", "-", name).lower()
115+
116+
117+
_repo_lock = asyncio.Lock()
118+
119+
TYPESHED_OWNER = "python"
120+
FORK_OWNER = "hauntsaninja"
121+
122+
123+
async def suggest_typeshed_update(
124+
update: Update, session: aiohttp.ClientSession, dry_run: bool
125+
) -> None:
126+
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+
async with _repo_lock:
130+
branch_name = f"stubsabot/{normalize(update.distribution)}"
131+
subprocess.check_call(["git", "checkout", "-B", branch_name, "origin/master"])
132+
with open(update.stub_path / "METADATA.toml", "rb") as f:
133+
meta = tomlkit.load(f)
134+
meta["version"] = update.new_version_spec
135+
with open(update.stub_path / "METADATA.toml", "w") as f:
136+
tomlkit.dump(meta, f)
137+
subprocess.check_call(["git", "commit", "--all", "-m", title])
138+
if dry_run:
139+
return
140+
subprocess.check_call(["git", "push", "origin", branch_name, "--force-with-lease"])
141+
142+
secret = os.environ["GITHUB_TOKEN"]
143+
if secret.startswith("ghp"):
144+
auth = f"token {secret}"
145+
else:
146+
auth = f"Bearer {secret}"
147+
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
158+
return
159+
response.raise_for_status()
160+
161+
162+
async def main() -> None:
163+
assert sys.version_info >= (3, 9)
164+
165+
parser = argparse.ArgumentParser()
166+
parser.add_argument("--dry-run", action="store_true")
167+
args = parser.parse_args()
168+
169+
try:
170+
conn = aiohttp.TCPConnector(limit_per_host=10)
171+
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+
]
176+
for task in asyncio.as_completed(tasks):
177+
update = await task
178+
if isinstance(update, NoUpdate):
179+
continue
180+
if isinstance(update, Update):
181+
await suggest_typeshed_update(update, session, dry_run=args.dry_run)
182+
continue
183+
raise AssertionError
184+
finally:
185+
# if you need to cleanup, try:
186+
# git branch -D $(git branch --list 'stubsabot/*')
187+
subprocess.check_call(["git", "checkout", "master"])
188+
189+
190+
if __name__ == "__main__":
191+
asyncio.run(main())

0 commit comments

Comments
 (0)