10
10
import subprocess
11
11
import sys
12
12
import tempfile
13
+ from enum import IntEnum
13
14
from itertools import product
14
15
from pathlib import Path
15
16
from typing_extensions import TypeAlias
16
17
17
18
from utils import (
18
19
PackageInfo ,
20
+ VenvInfo ,
19
21
colored ,
20
22
get_all_testcase_directories ,
23
+ get_mypy_req ,
21
24
get_recursive_requirements ,
25
+ make_venv ,
22
26
print_error ,
23
27
print_success_msg ,
24
28
testcase_dir_from_package_name ,
25
29
)
26
30
27
31
ReturnCode : TypeAlias = int
28
32
33
+ TEST_CASES = "test_cases"
34
+ VENV_DIR = ".venv"
35
+ TYPESHED = "typeshed"
36
+
29
37
SUPPORTED_PLATFORMS = ["linux" , "darwin" , "win32" ]
30
38
SUPPORTED_VERSIONS = ["3.11" , "3.10" , "3.9" , "3.8" , "3.7" ]
31
39
@@ -34,7 +42,7 @@ def package_with_test_cases(package_name: str) -> PackageInfo:
34
42
"""Helper function for argument-parsing"""
35
43
36
44
if package_name == "stdlib" :
37
- return PackageInfo ("stdlib" , Path ("test_cases" ))
45
+ return PackageInfo ("stdlib" , Path (TEST_CASES ))
38
46
test_case_dir = testcase_dir_from_package_name (package_name )
39
47
if test_case_dir .is_dir ():
40
48
if not os .listdir (test_case_dir ):
@@ -43,6 +51,12 @@ def package_with_test_cases(package_name: str) -> PackageInfo:
43
51
raise argparse .ArgumentTypeError (f"No test cases found for { package_name !r} !" )
44
52
45
53
54
+ class Verbosity (IntEnum ):
55
+ QUIET = 0
56
+ NORMAL = 1
57
+ VERBOSE = 2
58
+
59
+
46
60
parser = argparse .ArgumentParser (description = "Script to run mypy against various test cases for typeshed's stubs" )
47
61
parser .add_argument (
48
62
"packages_to_test" ,
@@ -59,7 +73,12 @@ def package_with_test_cases(package_name: str) -> PackageInfo:
59
73
"Note that this cannot be specified if --platform and/or --python-version are specified."
60
74
),
61
75
)
62
- parser .add_argument ("--quiet" , action = "store_true" , help = "Print less output to the terminal" )
76
+ parser .add_argument (
77
+ "--verbosity" ,
78
+ choices = [member .name for member in Verbosity ],
79
+ default = Verbosity .NORMAL .name ,
80
+ help = "Control how much output to print to the terminal" ,
81
+ )
63
82
parser .add_argument (
64
83
"--platform" ,
65
84
dest = "platforms_to_test" ,
@@ -85,16 +104,64 @@ def package_with_test_cases(package_name: str) -> PackageInfo:
85
104
)
86
105
87
106
88
- def test_testcase_directory (package : PackageInfo , version : str , platform : str , quiet : bool ) -> ReturnCode :
89
- package_name , test_case_directory = package
90
- is_stdlib = package_name == "stdlib"
107
+ def verbose_log (msg : str ) -> None :
108
+ print (colored ("\n " + msg , "blue" ))
91
109
92
- msg = f"Running mypy --platform { platform } --python-version { version } on the "
93
- msg += "standard library test cases..." if is_stdlib else f"test cases for { package_name !r} ..."
94
- if not quiet :
95
- print (msg , end = " " )
96
110
97
- # "--enable-error-code ignore-without-code" is purposefully ommited. See https://github.com/python/typeshed/pull/8083
111
+ def setup_testcase_dir (package : PackageInfo , tempdir : Path , new_test_case_dir : Path , verbosity : Verbosity ) -> None :
112
+ if verbosity is verbosity .VERBOSE :
113
+ verbose_log (f"Setting up testcase dir in { tempdir } " )
114
+ # --warn-unused-ignores doesn't work for files inside typeshed.
115
+ # SO, to work around this, we copy the test_cases directory into a TemporaryDirectory,
116
+ # and run the test cases inside of that.
117
+ shutil .copytree (package .test_case_directory , new_test_case_dir )
118
+ if package .is_stdlib :
119
+ return
120
+
121
+ # HACK: we want to run these test cases in an isolated environment --
122
+ # we want mypy to see all stub packages listed in the "requires" field of METADATA.toml
123
+ # (and all stub packages required by those stub packages, etc. etc.),
124
+ # but none of the other stubs in typeshed.
125
+ #
126
+ # The best way of doing that without stopping --warn-unused-ignore from working
127
+ # seems to be to create a "new typeshed" directory in a tempdir
128
+ # that has only the required stubs copied over.
129
+ new_typeshed = tempdir / TYPESHED
130
+ new_typeshed .mkdir ()
131
+ shutil .copytree (Path ("stdlib" ), new_typeshed / "stdlib" )
132
+ requirements = get_recursive_requirements (package .name )
133
+ # mypy refuses to consider a directory a "valid typeshed directory"
134
+ # unless there's a stubs/mypy-extensions path inside it,
135
+ # so add that to the list of stubs to copy over to the new directory
136
+ for requirement in {package .name , * requirements .typeshed_pkgs , "mypy-extensions" }:
137
+ shutil .copytree (Path ("stubs" , requirement ), new_typeshed / "stubs" / requirement )
138
+
139
+ if requirements .external_pkgs :
140
+ if verbosity is Verbosity .VERBOSE :
141
+ verbose_log (f"Setting up venv in { tempdir / VENV_DIR } " )
142
+ pip_exe = make_venv (tempdir / VENV_DIR ).pip_exe
143
+ pip_command = [pip_exe , "install" , get_mypy_req (), * requirements .external_pkgs ]
144
+ if verbosity is Verbosity .VERBOSE :
145
+ verbose_log (f"{ pip_command = } " )
146
+ try :
147
+ subprocess .run (pip_command , check = True , capture_output = True , text = True )
148
+ except subprocess .CalledProcessError as e :
149
+ print (e .stderr )
150
+ raise
151
+
152
+
153
+ def run_testcases (
154
+ package : PackageInfo , version : str , platform : str , * , tempdir : Path , verbosity : Verbosity
155
+ ) -> subprocess .CompletedProcess [str ]:
156
+ env_vars = dict (os .environ )
157
+ new_test_case_dir = tempdir / TEST_CASES
158
+ testcasedir_already_setup = new_test_case_dir .exists () and new_test_case_dir .is_dir ()
159
+
160
+ if not testcasedir_already_setup :
161
+ setup_testcase_dir (package , tempdir = tempdir , new_test_case_dir = new_test_case_dir , verbosity = verbosity )
162
+
163
+ # "--enable-error-code ignore-without-code" is purposefully ommited.
164
+ # See https://github.com/python/typeshed/pull/8083
98
165
flags = [
99
166
"--python-version" ,
100
167
version ,
@@ -103,67 +170,70 @@ def test_testcase_directory(package: PackageInfo, version: str, platform: str, q
103
170
"--no-error-summary" ,
104
171
"--platform" ,
105
172
platform ,
106
- "--no-site-packages" ,
107
173
"--strict" ,
108
174
"--pretty" ,
109
175
]
110
176
111
- # --warn-unused-ignores doesn't work for files inside typeshed.
112
- # SO, to work around this, we copy the test_cases directory into a TemporaryDirectory.
113
- with tempfile .TemporaryDirectory () as td :
114
- td_path = Path (td )
115
- new_test_case_dir = td_path / "test_cases"
116
- shutil .copytree (test_case_directory , new_test_case_dir )
117
- env_vars = dict (os .environ )
118
- if is_stdlib :
119
- flags .extend (["--custom-typeshed-dir" , str (Path (__file__ ).parent .parent )])
177
+ if package .is_stdlib :
178
+ python_exe = sys .executable
179
+ custom_typeshed = Path (__file__ ).parent .parent
180
+ flags .append ("--no-site-packages" )
181
+ else :
182
+ custom_typeshed = tempdir / TYPESHED
183
+ env_vars ["MYPYPATH" ] = os .pathsep .join (map (str , custom_typeshed .glob ("stubs/*" )))
184
+ has_non_types_dependencies = (tempdir / VENV_DIR ).exists ()
185
+ if has_non_types_dependencies :
186
+ python_exe = VenvInfo .of_existing_venv (tempdir / VENV_DIR ).python_exe
120
187
else :
121
- # HACK: we want to run these test cases in an isolated environment --
122
- # we want mypy to see all stub packages listed in the "requires" field of METADATA.toml
123
- # (and all stub packages required by those stub packages, etc. etc.),
124
- # but none of the other stubs in typeshed.
125
- #
126
- # The best way of doing that without stopping --warn-unused-ignore from working
127
- # seems to be to create a "new typeshed" directory in a tempdir
128
- # that has only the required stubs copied over.
129
- new_typeshed = td_path / "typeshed"
130
- os .mkdir (new_typeshed )
131
- shutil .copytree (Path ("stdlib" ), new_typeshed / "stdlib" )
132
- requirements = get_recursive_requirements (package_name )
133
- # mypy refuses to consider a directory a "valid typeshed directory"
134
- # unless there's a stubs/mypy-extensions path inside it,
135
- # so add that to the list of stubs to copy over to the new directory
136
- for requirement in requirements + ["mypy-extensions" ]:
137
- shutil .copytree (Path ("stubs" , requirement ), new_typeshed / "stubs" / requirement )
138
- env_vars ["MYPYPATH" ] = os .pathsep .join (map (str , new_typeshed .glob ("stubs/*" )))
139
- flags .extend (["--custom-typeshed-dir" , str (td_path / "typeshed" )])
140
-
141
- # If the test-case filename ends with -py39,
142
- # only run the test if --python-version was set to 3.9 or higher (for example)
143
- for path in new_test_case_dir .rglob ("*.py" ):
144
- if match := re .fullmatch (r".*-py3(\d{1,2})" , path .stem ):
145
- minor_version_required = int (match [1 ])
146
- assert f"3.{ minor_version_required } " in SUPPORTED_VERSIONS
147
- if minor_version_required <= int (version .split ("." )[1 ]):
148
- flags .append (str (path ))
149
- else :
150
- flags .append (str (path ))
151
-
152
- result = subprocess .run ([sys .executable , "-m" , "mypy" , * flags ], capture_output = True , env = env_vars )
188
+ python_exe = sys .executable
189
+ flags .append ("--no-site-packages" )
190
+
191
+ flags .extend (["--custom-typeshed-dir" , str (custom_typeshed )])
192
+
193
+ # If the test-case filename ends with -py39,
194
+ # only run the test if --python-version was set to 3.9 or higher (for example)
195
+ for path in new_test_case_dir .rglob ("*.py" ):
196
+ if match := re .fullmatch (r".*-py3(\d{1,2})" , path .stem ):
197
+ minor_version_required = int (match [1 ])
198
+ assert f"3.{ minor_version_required } " in SUPPORTED_VERSIONS
199
+ python_minor_version = int (version .split ("." )[1 ])
200
+ if minor_version_required > python_minor_version :
201
+ continue
202
+ flags .append (str (path ))
203
+
204
+ mypy_command = [python_exe , "-m" , "mypy" ] + flags
205
+ if verbosity is Verbosity .VERBOSE :
206
+ verbose_log (f"{ mypy_command = } " )
207
+ if "MYPYPATH" in env_vars :
208
+ verbose_log (f"{ env_vars ['MYPYPATH' ]= } " )
209
+ else :
210
+ verbose_log ("MYPYPATH not set" )
211
+ return subprocess .run (mypy_command , capture_output = True , text = True , env = env_vars )
212
+
213
+
214
+ def test_testcase_directory (
215
+ package : PackageInfo , version : str , platform : str , * , verbosity : Verbosity , tempdir : Path
216
+ ) -> ReturnCode :
217
+ msg = f"Running mypy --platform { platform } --python-version { version } on the "
218
+ msg += "standard library test cases..." if package .is_stdlib else f"test cases for { package .name !r} ..."
219
+ if verbosity > Verbosity .QUIET :
220
+ print (msg , end = " " , flush = True )
221
+
222
+ result = run_testcases (package = package , version = version , platform = platform , tempdir = tempdir , verbosity = verbosity )
153
223
154
224
if result .returncode :
155
- if quiet :
225
+ if verbosity > Verbosity . QUIET :
156
226
# We'll already have printed this if --quiet wasn't passed.
157
- # If--quiet was passed, only print this if there were errors.
227
+ # If --quiet was passed, only print this if there were errors.
158
228
# If there are errors, the output is inscrutable if this isn't printed.
159
229
print (msg , end = " " )
160
230
print_error ("failure\n " )
161
- replacements = (str (new_test_case_dir ), str (test_case_directory ))
231
+ replacements = (str (tempdir / TEST_CASES ), str (package . test_case_directory ))
162
232
if result .stderr :
163
- print_error (result .stderr . decode () , fix_path = replacements )
233
+ print_error (result .stderr , fix_path = replacements )
164
234
if result .stdout :
165
- print_error (result .stdout . decode () , fix_path = replacements )
166
- elif not quiet :
235
+ print_error (result .stdout , fix_path = replacements )
236
+ elif verbosity > Verbosity . QUIET :
167
237
print_success_msg ()
168
238
return result .returncode
169
239
@@ -172,6 +242,7 @@ def main() -> ReturnCode:
172
242
args = parser .parse_args ()
173
243
174
244
testcase_directories = args .packages_to_test or get_all_testcase_directories ()
245
+ verbosity = Verbosity [args .verbosity ]
175
246
if args .all :
176
247
if args .platforms_to_test :
177
248
parser .error ("Cannot specify both --platform and --all" )
@@ -183,8 +254,12 @@ def main() -> ReturnCode:
183
254
versions_to_test = args .versions_to_test or [f"3.{ sys .version_info [1 ]} " ]
184
255
185
256
code = 0
186
- for platform , version , directory in product (platforms_to_test , versions_to_test , testcase_directories ):
187
- code = max (code , test_testcase_directory (directory , version , platform , args .quiet ))
257
+ for testcase_dir in testcase_directories :
258
+ with tempfile .TemporaryDirectory () as td :
259
+ tempdir = Path (td )
260
+ for platform , version in product (platforms_to_test , versions_to_test ):
261
+ this_code = test_testcase_directory (testcase_dir , version , platform , verbosity = verbosity , tempdir = tempdir )
262
+ code = max (code , this_code )
188
263
if code :
189
264
print_error ("\n Test completed with errors" )
190
265
else :
0 commit comments