Skip to content

Commit ebc2804

Browse files
authored
Merge branch 'python:main' into fix-enum-add-value-alias
2 parents 1e82dac + bb37548 commit ebc2804

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

66 files changed

+4467
-105
lines changed

lib/ts_utils/paths.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
PYPROJECT_PATH: Final = TS_BASE_PATH / "pyproject.toml"
1313
REQUIREMENTS_PATH: Final = TS_BASE_PATH / "requirements-tests.txt"
1414
GITIGNORE_PATH: Final = TS_BASE_PATH / ".gitignore"
15+
PYRIGHT_CONFIG: Final = TS_BASE_PATH / "pyrightconfig.stricter.json"
1516

1617
TESTS_DIR: Final = "@tests"
1718
TEST_CASES_DIR: Final = "test_cases"

pyrightconfig.stricter.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@
7070
"stubs/peewee",
7171
"stubs/pexpect",
7272
"stubs/pika",
73+
"stubs/pony",
7374
"stubs/protobuf",
7475
"stubs/psutil",
7576
"stubs/psycopg2",

requirements-tests.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ pytype==2024.10.11; platform_system != "Windows" and python_version >= "3.10" an
88
# Libraries used by our various scripts.
99
# TODO (2025-05-09): Installing this on Python 3.14 on Windows fails at
1010
# the moment.
11-
aiohttp==3.12.13; python_version < "3.14"
11+
aiohttp==3.12.14; python_version < "3.14"
1212
# TODO (2025-05-09): No wheels exist for Python 3.14 yet, slowing down CI
1313
# considerably and prone to fail.
1414
grpcio-tools>=1.66.2; python_version < "3.14" # For grpc_tools.protoc

scripts/create_baseline_stubs.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,7 @@
2323
import aiohttp
2424
import termcolor
2525

26-
from ts_utils.paths import STDLIB_PATH, STUBS_PATH
27-
28-
PYRIGHT_CONFIG = Path("pyrightconfig.stricter.json")
26+
from ts_utils.paths import PYRIGHT_CONFIG, STDLIB_PATH, STUBS_PATH
2927

3028

3129
def search_pip_freeze_output(project: str, output: str) -> tuple[str, str] | None:

scripts/stubsabot.py

Lines changed: 181 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -3,40 +3,46 @@
33

44
import argparse
55
import asyncio
6+
import calendar
67
import contextlib
78
import datetime
89
import enum
910
import functools
1011
import io
1112
import os
1213
import re
14+
import shutil
1315
import subprocess
1416
import sys
1517
import tarfile
1618
import textwrap
1719
import urllib.parse
1820
import zipfile
19-
from collections.abc import Iterator, Mapping, Sequence
21+
from collections.abc import Callable, Iterator, Mapping, Sequence
2022
from dataclasses import dataclass, field
2123
from http import HTTPStatus
2224
from pathlib import Path
23-
from typing import Annotated, Any, ClassVar, NamedTuple
25+
from typing import Annotated, Any, ClassVar, NamedTuple, TypeVar
2426
from typing_extensions import Self, TypeAlias
2527

2628
import aiohttp
2729
import packaging.version
30+
import tomli
2831
import tomlkit
2932
from packaging.specifiers import Specifier
3033
from termcolor import colored
34+
from tomlkit.items import String
3135

32-
from ts_utils.metadata import StubMetadata, read_metadata, update_metadata
33-
from ts_utils.paths import STUBS_PATH, distribution_path
36+
from ts_utils.metadata import NoSuchStubError, StubMetadata, metadata_path, read_metadata, update_metadata
37+
from ts_utils.paths import PYRIGHT_CONFIG, STUBS_PATH, distribution_path
3438

3539
TYPESHED_OWNER = "python"
3640
TYPESHED_API_URL = f"https://api.github.com/repos/{TYPESHED_OWNER}/typeshed"
3741

3842
STUBSABOT_LABEL = "bot: stubsabot"
3943

44+
POLICY_MONTHS_DELTA = 6
45+
4046

4147
class ActionLevel(enum.IntEnum):
4248
def __new__(cls, value: int, doc: str) -> Self:
@@ -149,6 +155,16 @@ def __str__(self) -> str:
149155
return f"Marking {self.distribution} as obsolete since {self.obsolete_since_version!r}"
150156

151157

158+
@dataclass
159+
class Remove:
160+
distribution: str
161+
reason: str
162+
links: dict[str, str]
163+
164+
def __str__(self) -> str:
165+
return f"Removing {self.distribution} as {self.reason}"
166+
167+
152168
@dataclass
153169
class NoUpdate:
154170
distribution: str
@@ -158,6 +174,38 @@ def __str__(self) -> str:
158174
return f"Skipping {self.distribution}: {self.reason}"
159175

160176

177+
_T = TypeVar("_T")
178+
179+
180+
async def with_extracted_archive(
181+
release_to_download: PypiReleaseDownload,
182+
*,
183+
session: aiohttp.ClientSession,
184+
handler: Callable[[zipfile.ZipFile | tarfile.TarFile], _T],
185+
) -> _T:
186+
async with session.get(release_to_download.url) as response:
187+
body = io.BytesIO(await response.read())
188+
189+
packagetype = release_to_download.packagetype
190+
if packagetype == "bdist_wheel":
191+
assert release_to_download.filename.endswith(".whl")
192+
with zipfile.ZipFile(body) as zf:
193+
return handler(zf)
194+
elif packagetype == "sdist":
195+
# sdist defaults to `.tar.gz` on Lunix and to `.zip` on Windows:
196+
# https://docs.python.org/3.11/distutils/sourcedist.html
197+
if release_to_download.filename.endswith(".tar.gz"):
198+
with tarfile.open(fileobj=body, mode="r:gz") as zf:
199+
return handler(zf)
200+
elif release_to_download.filename.endswith(".zip"):
201+
with zipfile.ZipFile(body) as zf:
202+
return handler(zf)
203+
else:
204+
raise AssertionError(f"Package file {release_to_download.filename!r} does not end with '.tar.gz' or '.zip'")
205+
else:
206+
raise AssertionError(f"Unknown package type for {release_to_download.distribution}: {packagetype!r}")
207+
208+
161209
def all_py_files_in_source_are_in_py_typed_dirs(source: zipfile.ZipFile | tarfile.TarFile) -> bool:
162210
py_typed_dirs: list[Path] = []
163211
all_python_files: list[Path] = []
@@ -207,27 +255,7 @@ def all_py_files_in_source_are_in_py_typed_dirs(source: zipfile.ZipFile | tarfil
207255

208256

209257
async def release_contains_py_typed(release_to_download: PypiReleaseDownload, *, session: aiohttp.ClientSession) -> bool:
210-
async with session.get(release_to_download.url) as response:
211-
body = io.BytesIO(await response.read())
212-
213-
packagetype = release_to_download.packagetype
214-
if packagetype == "bdist_wheel":
215-
assert release_to_download.filename.endswith(".whl")
216-
with zipfile.ZipFile(body) as zf:
217-
return all_py_files_in_source_are_in_py_typed_dirs(zf)
218-
elif packagetype == "sdist":
219-
# sdist defaults to `.tar.gz` on Lunix and to `.zip` on Windows:
220-
# https://docs.python.org/3.11/distutils/sourcedist.html
221-
if release_to_download.filename.endswith(".tar.gz"):
222-
with tarfile.open(fileobj=body, mode="r:gz") as zf:
223-
return all_py_files_in_source_are_in_py_typed_dirs(zf)
224-
elif release_to_download.filename.endswith(".zip"):
225-
with zipfile.ZipFile(body) as zf:
226-
return all_py_files_in_source_are_in_py_typed_dirs(zf)
227-
else:
228-
raise AssertionError(f"Package file {release_to_download.filename!r} does not end with '.tar.gz' or '.zip'")
229-
else:
230-
raise AssertionError(f"Unknown package type for {release_to_download.distribution}: {packagetype!r}")
258+
return await with_extracted_archive(release_to_download, session=session, handler=all_py_files_in_source_are_in_py_typed_dirs)
231259

232260

233261
async def find_first_release_with_py_typed(pypi_info: PypiInfo, *, session: aiohttp.ClientSession) -> PypiReleaseDownload | None:
@@ -470,12 +498,94 @@ async def analyze_diff(
470498
return DiffAnalysis(py_files=py_files, py_files_stubbed_in_typeshed=py_files_stubbed_in_typeshed)
471499

472500

473-
async def determine_action(distribution: str, session: aiohttp.ClientSession) -> Update | NoUpdate | Obsolete:
501+
def _add_months(date: datetime.date, months: int) -> datetime.date:
502+
month = date.month - 1 + months
503+
year = date.year + month // 12
504+
month = month % 12 + 1
505+
day = min(date.day, calendar.monthrange(year, month)[1])
506+
return datetime.date(year, month, day)
507+
508+
509+
def obsolete_more_than_6_months(distribution: str) -> bool:
510+
try:
511+
with metadata_path(distribution).open("rb") as file:
512+
data = tomlkit.load(file)
513+
except FileNotFoundError:
514+
raise NoSuchStubError(f"Typeshed has no stubs for {distribution!r}!") from None
515+
516+
obsolete_since = data["obsolete_since"]
517+
if not obsolete_since:
518+
return False
519+
520+
assert type(obsolete_since) is String
521+
comment: str | None = obsolete_since.trivia.comment
522+
if not comment:
523+
return False
524+
525+
release_date_string = comment.removeprefix("# Released on ")
526+
release_date = datetime.date.fromisoformat(release_date_string)
527+
remove_date = _add_months(release_date, POLICY_MONTHS_DELTA)
528+
today = datetime.datetime.now(tz=datetime.timezone.utc).date()
529+
530+
return remove_date <= today
531+
532+
533+
def parse_no_longer_updated_from_archive(source: zipfile.ZipFile | tarfile.TarFile) -> bool:
534+
if isinstance(source, zipfile.ZipFile):
535+
try:
536+
file = source.open("METADATA.toml", "r")
537+
except KeyError:
538+
return False
539+
else:
540+
try:
541+
tarinfo = source.getmember("METADATA.toml")
542+
file = source.extractfile(tarinfo) # type: ignore[assignment]
543+
if file is None:
544+
return False
545+
except KeyError:
546+
return False
547+
548+
with file as f:
549+
toml_data: dict[str, object] = tomli.load(f)
550+
551+
no_longer_updated = toml_data.get("no_longer_updated", False)
552+
assert type(no_longer_updated) is bool
553+
return bool(no_longer_updated)
554+
555+
556+
async def has_no_longer_updated_release(release_to_download: PypiReleaseDownload, *, session: aiohttp.ClientSession) -> bool:
557+
"""
558+
Return `True` if the `no_longer_updated` field exists and the value is
559+
`True` in the `METADATA.toml` file of latest `types-{distribution}` pypi release.
560+
"""
561+
return await with_extracted_archive(release_to_download, session=session, handler=parse_no_longer_updated_from_archive)
562+
563+
564+
async def determine_action(distribution: str, session: aiohttp.ClientSession) -> Update | NoUpdate | Obsolete | Remove:
474565
stub_info = read_metadata(distribution)
475566
if stub_info.is_obsolete:
476-
return NoUpdate(stub_info.distribution, "obsolete")
567+
if obsolete_more_than_6_months(stub_info.distribution):
568+
pypi_info = await fetch_pypi_info(f"types-{stub_info.distribution}", session)
569+
latest_release = pypi_info.get_latest_release()
570+
links = {
571+
"Typeshed release": f"{pypi_info.pypi_root}",
572+
"Typeshed stubs": f"https://github.com/{TYPESHED_OWNER}/typeshed/tree/main/stubs/{stub_info.distribution}",
573+
}
574+
return Remove(stub_info.distribution, reason="older than 6 months", links=links)
575+
else:
576+
return NoUpdate(stub_info.distribution, "obsolete")
477577
if stub_info.no_longer_updated:
478-
return NoUpdate(stub_info.distribution, "no longer updated")
578+
pypi_info = await fetch_pypi_info(f"types-{stub_info.distribution}", session)
579+
latest_release = pypi_info.get_latest_release()
580+
581+
if await has_no_longer_updated_release(latest_release, session=session):
582+
links = {
583+
"Typeshed release": f"{pypi_info.pypi_root}",
584+
"Typeshed stubs": f"https://github.com/{TYPESHED_OWNER}/typeshed/tree/main/stubs/{stub_info.distribution}",
585+
}
586+
return Remove(stub_info.distribution, reason="no longer updated", links=links)
587+
else:
588+
return NoUpdate(stub_info.distribution, "no longer updated")
479589

480590
pypi_info = await fetch_pypi_info(stub_info.distribution, session)
481591
latest_release = pypi_info.get_latest_release()
@@ -683,6 +793,22 @@ def get_update_pr_body(update: Update, metadata: Mapping[str, Any]) -> str:
683793
return body
684794

685795

796+
def remove_stubs(distribution: str) -> None:
797+
stub_path = distribution_path(distribution)
798+
target_path_prefix = f'"stubs/{distribution}'
799+
800+
if stub_path.exists() and stub_path.is_dir():
801+
shutil.rmtree(stub_path)
802+
803+
with PYRIGHT_CONFIG.open("r", encoding="UTF-8") as f:
804+
lines = f.readlines()
805+
806+
lines = [line for line in lines if not line.lstrip().startswith(target_path_prefix)]
807+
808+
with PYRIGHT_CONFIG.open("w", encoding="UTF-8") as f:
809+
f.writelines(lines)
810+
811+
686812
async def suggest_typeshed_update(update: Update, session: aiohttp.ClientSession, action_level: ActionLevel) -> None:
687813
if action_level <= ActionLevel.nothing:
688814
return
@@ -729,6 +855,28 @@ async def suggest_typeshed_obsolete(obsolete: Obsolete, session: aiohttp.ClientS
729855
await create_or_update_pull_request(title=title, body=body, branch_name=branch_name, session=session)
730856

731857

858+
async def suggest_typeshed_remove(remove: Remove, session: aiohttp.ClientSession, action_level: ActionLevel) -> None:
859+
if action_level <= ActionLevel.nothing:
860+
return
861+
title = f"[stubsabot] Remove {remove.distribution} as {remove.reason}"
862+
async with _repo_lock:
863+
branch_name = f"{BRANCH_PREFIX}/{normalize(remove.distribution)}"
864+
subprocess.check_call(["git", "checkout", "-B", branch_name, "origin/main"])
865+
remove_stubs(remove.distribution)
866+
body = "\n".join(f"{k}: {v}" for k, v in remove.links.items())
867+
subprocess.check_call(["git", "commit", "--all", "-m", f"{title}\n\n{body}"])
868+
if action_level <= ActionLevel.local:
869+
return
870+
if not latest_commit_is_different_to_last_commit_on_origin(branch_name):
871+
print(f"No pushing to origin required: origin/{branch_name} exists and requires no changes!")
872+
return
873+
somewhat_safe_force_push(branch_name)
874+
if action_level <= ActionLevel.fork:
875+
return
876+
877+
await create_or_update_pull_request(title=title, body=body, branch_name=branch_name, session=session)
878+
879+
732880
async def main() -> None:
733881
parser = argparse.ArgumentParser()
734882
parser.add_argument(
@@ -803,10 +951,13 @@ async def main() -> None:
803951
if isinstance(update, Update):
804952
await suggest_typeshed_update(update, session, action_level=args.action_level)
805953
continue
806-
# Redundant, but keeping for extra runtime validation
807-
if isinstance(update, Obsolete): # pyright: ignore[reportUnnecessaryIsInstance]
954+
if isinstance(update, Obsolete):
808955
await suggest_typeshed_obsolete(update, session, action_level=args.action_level)
809956
continue
957+
# Redundant, but keeping for extra runtime validation
958+
if isinstance(update, Remove): # pyright: ignore[reportUnnecessaryIsInstance]
959+
await suggest_typeshed_remove(update, session, action_level=args.action_level)
960+
continue
810961
except RemoteConflictError as e:
811962
print(colored(f"... but ran into {type(e).__qualname__}: {e}", "red"))
812963
continue

0 commit comments

Comments
 (0)