3
3
4
4
import argparse
5
5
import asyncio
6
+ import calendar
6
7
import contextlib
7
8
import datetime
8
9
import enum
9
10
import functools
10
11
import io
11
12
import os
12
13
import re
14
+ import shutil
13
15
import subprocess
14
16
import sys
15
17
import tarfile
16
18
import textwrap
17
19
import urllib .parse
18
20
import zipfile
19
- from collections .abc import Iterator , Mapping , Sequence
21
+ from collections .abc import Callable , Iterator , Mapping , Sequence
20
22
from dataclasses import dataclass , field
21
23
from http import HTTPStatus
22
24
from pathlib import Path
23
- from typing import Annotated , Any , ClassVar , NamedTuple
25
+ from typing import Annotated , Any , ClassVar , NamedTuple , TypeVar
24
26
from typing_extensions import Self , TypeAlias
25
27
26
28
import aiohttp
27
29
import packaging .version
30
+ import tomli
28
31
import tomlkit
29
32
from packaging .specifiers import Specifier
30
33
from termcolor import colored
34
+ from tomlkit .items import String
31
35
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
34
38
35
39
TYPESHED_OWNER = "python"
36
40
TYPESHED_API_URL = f"https://api.github.com/repos/{ TYPESHED_OWNER } /typeshed"
37
41
38
42
STUBSABOT_LABEL = "bot: stubsabot"
39
43
44
+ POLICY_MONTHS_DELTA = 6
45
+
40
46
41
47
class ActionLevel (enum .IntEnum ):
42
48
def __new__ (cls , value : int , doc : str ) -> Self :
@@ -149,6 +155,16 @@ def __str__(self) -> str:
149
155
return f"Marking { self .distribution } as obsolete since { self .obsolete_since_version !r} "
150
156
151
157
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
+
152
168
@dataclass
153
169
class NoUpdate :
154
170
distribution : str
@@ -158,6 +174,38 @@ def __str__(self) -> str:
158
174
return f"Skipping { self .distribution } : { self .reason } "
159
175
160
176
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
+
161
209
def all_py_files_in_source_are_in_py_typed_dirs (source : zipfile .ZipFile | tarfile .TarFile ) -> bool :
162
210
py_typed_dirs : list [Path ] = []
163
211
all_python_files : list [Path ] = []
@@ -207,27 +255,7 @@ def all_py_files_in_source_are_in_py_typed_dirs(source: zipfile.ZipFile | tarfil
207
255
208
256
209
257
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 )
231
259
232
260
233
261
async def find_first_release_with_py_typed (pypi_info : PypiInfo , * , session : aiohttp .ClientSession ) -> PypiReleaseDownload | None :
@@ -470,12 +498,94 @@ async def analyze_diff(
470
498
return DiffAnalysis (py_files = py_files , py_files_stubbed_in_typeshed = py_files_stubbed_in_typeshed )
471
499
472
500
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 :
474
565
stub_info = read_metadata (distribution )
475
566
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" )
477
577
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" )
479
589
480
590
pypi_info = await fetch_pypi_info (stub_info .distribution , session )
481
591
latest_release = pypi_info .get_latest_release ()
@@ -683,6 +793,22 @@ def get_update_pr_body(update: Update, metadata: Mapping[str, Any]) -> str:
683
793
return body
684
794
685
795
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
+
686
812
async def suggest_typeshed_update (update : Update , session : aiohttp .ClientSession , action_level : ActionLevel ) -> None :
687
813
if action_level <= ActionLevel .nothing :
688
814
return
@@ -729,6 +855,28 @@ async def suggest_typeshed_obsolete(obsolete: Obsolete, session: aiohttp.ClientS
729
855
await create_or_update_pull_request (title = title , body = body , branch_name = branch_name , session = session )
730
856
731
857
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
+
732
880
async def main () -> None :
733
881
parser = argparse .ArgumentParser ()
734
882
parser .add_argument (
@@ -803,10 +951,13 @@ async def main() -> None:
803
951
if isinstance (update , Update ):
804
952
await suggest_typeshed_update (update , session , action_level = args .action_level )
805
953
continue
806
- # Redundant, but keeping for extra runtime validation
807
- if isinstance (update , Obsolete ): # pyright: ignore[reportUnnecessaryIsInstance]
954
+ if isinstance (update , Obsolete ):
808
955
await suggest_typeshed_obsolete (update , session , action_level = args .action_level )
809
956
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
810
961
except RemoteConflictError as e :
811
962
print (colored (f"... but ran into { type (e ).__qualname__ } : { e } " , "red" ))
812
963
continue
0 commit comments