1import contextlib 2import itertools 3import os 4import re 5import shutil 6import subprocess 7import sys 8import sysconfig 9import tempfile 10import unittest 11from pathlib import Path 12from test import support 13 14if sys.platform != "win32": 15 raise unittest.SkipTest("test only applies to Windows") 16 17# Get winreg after the platform check 18import winreg 19 20 21PY_EXE = "py.exe" 22DEBUG_BUILD = False 23if sys.executable.casefold().endswith("_d.exe".casefold()): 24 PY_EXE = "py_d.exe" 25 DEBUG_BUILD = True 26 27# Registry data to create. On removal, everything beneath top-level names will 28# be deleted. 29TEST_DATA = { 30 "PythonTestSuite": { 31 "DisplayName": "Python Test Suite", 32 "SupportUrl": "https://www.python.org/", 33 "3.100": { 34 "DisplayName": "X.Y version", 35 "InstallPath": { 36 None: sys.prefix, 37 "ExecutablePath": "X.Y.exe", 38 } 39 }, 40 "3.100-32": { 41 "DisplayName": "X.Y-32 version", 42 "InstallPath": { 43 None: sys.prefix, 44 "ExecutablePath": "X.Y-32.exe", 45 } 46 }, 47 "3.100-arm64": { 48 "DisplayName": "X.Y-arm64 version", 49 "InstallPath": { 50 None: sys.prefix, 51 "ExecutablePath": "X.Y-arm64.exe", 52 "ExecutableArguments": "-X fake_arg_for_test", 53 } 54 }, 55 "ignored": { 56 "DisplayName": "Ignored because no ExecutablePath", 57 "InstallPath": { 58 None: sys.prefix, 59 } 60 }, 61 }, 62 "PythonTestSuite1": { 63 "DisplayName": "Python Test Suite Single", 64 "3.100": { 65 "DisplayName": "Single Interpreter", 66 "InstallPath": { 67 None: sys.prefix, 68 "ExecutablePath": sys.executable, 69 } 70 } 71 }, 72} 73 74 75TEST_PY_ENV = dict( 76 PY_PYTHON="PythonTestSuite/3.100", 77 PY_PYTHON2="PythonTestSuite/3.100-32", 78 PY_PYTHON3="PythonTestSuite/3.100-arm64", 79) 80 81 82TEST_PY_DEFAULTS = "\n".join([ 83 "[defaults]", 84 *[f"{k[3:].lower()}={v}" for k, v in TEST_PY_ENV.items()], 85]) 86 87 88TEST_PY_COMMANDS = "\n".join([ 89 "[commands]", 90 "test-command=TEST_EXE.exe", 91]) 92 93 94def quote(s): 95 s = str(s) 96 return f'"{s}"' if " " in s else s 97 98 99def create_registry_data(root, data): 100 def _create_registry_data(root, key, value): 101 if isinstance(value, dict): 102 # For a dict, we recursively create keys 103 with winreg.CreateKeyEx(root, key) as hkey: 104 for k, v in value.items(): 105 _create_registry_data(hkey, k, v) 106 elif isinstance(value, str): 107 # For strings, we set values. 'key' may be None in this case 108 winreg.SetValueEx(root, key, None, winreg.REG_SZ, value) 109 else: 110 raise TypeError("don't know how to create data for '{}'".format(value)) 111 112 for k, v in data.items(): 113 _create_registry_data(root, k, v) 114 115 116def enum_keys(root): 117 for i in itertools.count(): 118 try: 119 yield winreg.EnumKey(root, i) 120 except OSError as ex: 121 if ex.winerror == 259: 122 break 123 raise 124 125 126def delete_registry_data(root, keys): 127 ACCESS = winreg.KEY_WRITE | winreg.KEY_ENUMERATE_SUB_KEYS 128 for key in list(keys): 129 with winreg.OpenKey(root, key, access=ACCESS) as hkey: 130 delete_registry_data(hkey, enum_keys(hkey)) 131 winreg.DeleteKey(root, key) 132 133 134def is_installed(tag): 135 key = rf"Software\Python\PythonCore\{tag}\InstallPath" 136 for root, flag in [ 137 (winreg.HKEY_CURRENT_USER, 0), 138 (winreg.HKEY_LOCAL_MACHINE, winreg.KEY_WOW64_64KEY), 139 (winreg.HKEY_LOCAL_MACHINE, winreg.KEY_WOW64_32KEY), 140 ]: 141 try: 142 winreg.CloseKey(winreg.OpenKey(root, key, access=winreg.KEY_READ | flag)) 143 return True 144 except OSError: 145 pass 146 return False 147 148 149class PreservePyIni: 150 def __init__(self, path, content): 151 self.path = Path(path) 152 self.content = content 153 self._preserved = None 154 155 def __enter__(self): 156 try: 157 self._preserved = self.path.read_bytes() 158 except FileNotFoundError: 159 self._preserved = None 160 self.path.write_text(self.content, encoding="utf-16") 161 162 def __exit__(self, *exc_info): 163 if self._preserved is None: 164 self.path.unlink() 165 else: 166 self.path.write_bytes(self._preserved) 167 168 169class RunPyMixin: 170 py_exe = None 171 172 @classmethod 173 def find_py(cls): 174 py_exe = None 175 if sysconfig.is_python_build(): 176 py_exe = Path(sys.executable).parent / PY_EXE 177 else: 178 for p in os.getenv("PATH").split(";"): 179 if p: 180 py_exe = Path(p) / PY_EXE 181 if py_exe.is_file(): 182 break 183 else: 184 py_exe = None 185 186 # Test launch and check version, to exclude installs of older 187 # releases when running outside of a source tree 188 if py_exe: 189 try: 190 with subprocess.Popen( 191 [py_exe, "-h"], 192 stdin=subprocess.PIPE, 193 stdout=subprocess.PIPE, 194 stderr=subprocess.PIPE, 195 encoding="ascii", 196 errors="ignore", 197 ) as p: 198 p.stdin.close() 199 version = next(p.stdout, "\n").splitlines()[0].rpartition(" ")[2] 200 p.stdout.read() 201 p.wait(10) 202 if not sys.version.startswith(version): 203 py_exe = None 204 except OSError: 205 py_exe = None 206 207 if not py_exe: 208 raise unittest.SkipTest( 209 "cannot locate '{}' for test".format(PY_EXE) 210 ) 211 return py_exe 212 213 def get_py_exe(self): 214 if not self.py_exe: 215 self.py_exe = self.find_py() 216 return self.py_exe 217 218 def run_py(self, args, env=None, allow_fail=False, expect_returncode=0, argv=None): 219 if not self.py_exe: 220 self.py_exe = self.find_py() 221 222 ignore = {"VIRTUAL_ENV", "PY_PYTHON", "PY_PYTHON2", "PY_PYTHON3"} 223 env = { 224 **{k.upper(): v for k, v in os.environ.items() if k.upper() not in ignore}, 225 "PYLAUNCHER_DEBUG": "1", 226 "PYLAUNCHER_DRYRUN": "1", 227 "PYLAUNCHER_LIMIT_TO_COMPANY": "", 228 **{k.upper(): v for k, v in (env or {}).items()}, 229 } 230 if not argv: 231 argv = [self.py_exe, *args] 232 with subprocess.Popen( 233 argv, 234 env=env, 235 executable=self.py_exe, 236 stdin=subprocess.PIPE, 237 stdout=subprocess.PIPE, 238 stderr=subprocess.PIPE, 239 ) as p: 240 p.stdin.close() 241 p.wait(10) 242 out = p.stdout.read().decode("utf-8", "replace") 243 err = p.stderr.read().decode("ascii", "replace").replace("\uFFFD", "?") 244 if p.returncode != expect_returncode and support.verbose and not allow_fail: 245 print("++ COMMAND ++") 246 print([self.py_exe, *args]) 247 print("++ STDOUT ++") 248 print(out) 249 print("++ STDERR ++") 250 print(err) 251 if allow_fail and p.returncode != expect_returncode: 252 raise subprocess.CalledProcessError(p.returncode, [self.py_exe, *args], out, err) 253 else: 254 self.assertEqual(expect_returncode, p.returncode) 255 data = { 256 s.partition(":")[0]: s.partition(":")[2].lstrip() 257 for s in err.splitlines() 258 if not s.startswith("#") and ":" in s 259 } 260 data["stdout"] = out 261 data["stderr"] = err 262 return data 263 264 def py_ini(self, content): 265 local_appdata = os.environ.get("LOCALAPPDATA") 266 if not local_appdata: 267 raise unittest.SkipTest("LOCALAPPDATA environment variable is " 268 "missing or empty") 269 return PreservePyIni(Path(local_appdata) / "py.ini", content) 270 271 @contextlib.contextmanager 272 def script(self, content, encoding="utf-8"): 273 file = Path(tempfile.mktemp(dir=os.getcwd()) + ".py") 274 file.write_text(content, encoding=encoding) 275 try: 276 yield file 277 finally: 278 file.unlink() 279 280 @contextlib.contextmanager 281 def fake_venv(self): 282 venv = Path.cwd() / "Scripts" 283 venv.mkdir(exist_ok=True, parents=True) 284 venv_exe = (venv / ("python_d.exe" if DEBUG_BUILD else "python.exe")) 285 venv_exe.touch() 286 try: 287 yield venv_exe, {"VIRTUAL_ENV": str(venv.parent)} 288 finally: 289 shutil.rmtree(venv) 290 291 292class TestLauncher(unittest.TestCase, RunPyMixin): 293 @classmethod 294 def setUpClass(cls): 295 with winreg.CreateKey(winreg.HKEY_CURRENT_USER, rf"Software\Python") as key: 296 create_registry_data(key, TEST_DATA) 297 298 if support.verbose: 299 p = subprocess.check_output("reg query HKCU\\Software\\Python /s") 300 #print(p.decode('mbcs')) 301 302 303 @classmethod 304 def tearDownClass(cls): 305 with winreg.OpenKey(winreg.HKEY_CURRENT_USER, rf"Software\Python", access=winreg.KEY_WRITE | winreg.KEY_ENUMERATE_SUB_KEYS) as key: 306 delete_registry_data(key, TEST_DATA) 307 308 309 def test_version(self): 310 data = self.run_py(["-0"]) 311 self.assertEqual(self.py_exe, Path(data["argv0"])) 312 self.assertEqual(sys.version.partition(" ")[0], data["version"]) 313 314 def test_help_option(self): 315 data = self.run_py(["-h"]) 316 self.assertEqual("True", data["SearchInfo.help"]) 317 318 def test_list_option(self): 319 for opt, v1, v2 in [ 320 ("-0", "True", "False"), 321 ("-0p", "False", "True"), 322 ("--list", "True", "False"), 323 ("--list-paths", "False", "True"), 324 ]: 325 with self.subTest(opt): 326 data = self.run_py([opt]) 327 self.assertEqual(v1, data["SearchInfo.list"]) 328 self.assertEqual(v2, data["SearchInfo.listPaths"]) 329 330 def test_list(self): 331 data = self.run_py(["--list"]) 332 found = {} 333 expect = {} 334 for line in data["stdout"].splitlines(): 335 m = re.match(r"\s*(.+?)\s+?(\*\s+)?(.+)$", line) 336 if m: 337 found[m.group(1)] = m.group(3) 338 for company in TEST_DATA: 339 company_data = TEST_DATA[company] 340 tags = [t for t in company_data if isinstance(company_data[t], dict)] 341 for tag in tags: 342 arg = f"-V:{company}/{tag}" 343 expect[arg] = company_data[tag]["DisplayName"] 344 expect.pop(f"-V:{company}/ignored", None) 345 346 actual = {k: v for k, v in found.items() if k in expect} 347 try: 348 self.assertDictEqual(expect, actual) 349 except: 350 if support.verbose: 351 print("*** STDOUT ***") 352 print(data["stdout"]) 353 raise 354 355 def test_list_paths(self): 356 data = self.run_py(["--list-paths"]) 357 found = {} 358 expect = {} 359 for line in data["stdout"].splitlines(): 360 m = re.match(r"\s*(.+?)\s+?(\*\s+)?(.+)$", line) 361 if m: 362 found[m.group(1)] = m.group(3) 363 for company in TEST_DATA: 364 company_data = TEST_DATA[company] 365 tags = [t for t in company_data if isinstance(company_data[t], dict)] 366 for tag in tags: 367 arg = f"-V:{company}/{tag}" 368 install = company_data[tag]["InstallPath"] 369 try: 370 expect[arg] = install["ExecutablePath"] 371 try: 372 expect[arg] += " " + install["ExecutableArguments"] 373 except KeyError: 374 pass 375 except KeyError: 376 expect[arg] = str(Path(install[None]) / Path(sys.executable).name) 377 378 expect.pop(f"-V:{company}/ignored", None) 379 380 actual = {k: v for k, v in found.items() if k in expect} 381 try: 382 self.assertDictEqual(expect, actual) 383 except: 384 if support.verbose: 385 print("*** STDOUT ***") 386 print(data["stdout"]) 387 raise 388 389 def test_filter_to_company(self): 390 company = "PythonTestSuite" 391 data = self.run_py([f"-V:{company}/"]) 392 self.assertEqual("X.Y.exe", data["LaunchCommand"]) 393 self.assertEqual(company, data["env.company"]) 394 self.assertEqual("3.100", data["env.tag"]) 395 396 def test_filter_to_company_with_default(self): 397 company = "PythonTestSuite" 398 data = self.run_py([f"-V:{company}/"], env=dict(PY_PYTHON="3.0")) 399 self.assertEqual("X.Y.exe", data["LaunchCommand"]) 400 self.assertEqual(company, data["env.company"]) 401 self.assertEqual("3.100", data["env.tag"]) 402 403 def test_filter_to_tag(self): 404 company = "PythonTestSuite" 405 data = self.run_py(["-V:3.100"]) 406 self.assertEqual("X.Y.exe", data["LaunchCommand"]) 407 self.assertEqual(company, data["env.company"]) 408 self.assertEqual("3.100", data["env.tag"]) 409 410 data = self.run_py(["-V:3.100-32"]) 411 self.assertEqual("X.Y-32.exe", data["LaunchCommand"]) 412 self.assertEqual(company, data["env.company"]) 413 self.assertEqual("3.100-32", data["env.tag"]) 414 415 data = self.run_py(["-V:3.100-arm64"]) 416 self.assertEqual("X.Y-arm64.exe -X fake_arg_for_test", data["LaunchCommand"]) 417 self.assertEqual(company, data["env.company"]) 418 self.assertEqual("3.100-arm64", data["env.tag"]) 419 420 def test_filter_to_company_and_tag(self): 421 company = "PythonTestSuite" 422 data = self.run_py([f"-V:{company}/3.1"], expect_returncode=103) 423 424 data = self.run_py([f"-V:{company}/3.100"]) 425 self.assertEqual("X.Y.exe", data["LaunchCommand"]) 426 self.assertEqual(company, data["env.company"]) 427 self.assertEqual("3.100", data["env.tag"]) 428 429 def test_filter_with_single_install(self): 430 company = "PythonTestSuite1" 431 data = self.run_py( 432 ["-V:Nonexistent"], 433 env={"PYLAUNCHER_LIMIT_TO_COMPANY": company}, 434 expect_returncode=103, 435 ) 436 437 def test_search_major_3(self): 438 try: 439 data = self.run_py(["-3"], allow_fail=True) 440 except subprocess.CalledProcessError: 441 raise unittest.SkipTest("requires at least one Python 3.x install") 442 self.assertEqual("PythonCore", data["env.company"]) 443 self.assertTrue(data["env.tag"].startswith("3."), data["env.tag"]) 444 445 def test_search_major_3_32(self): 446 try: 447 data = self.run_py(["-3-32"], allow_fail=True) 448 except subprocess.CalledProcessError: 449 if not any(is_installed(f"3.{i}-32") for i in range(5, 11)): 450 raise unittest.SkipTest("requires at least one 32-bit Python 3.x install") 451 raise 452 self.assertEqual("PythonCore", data["env.company"]) 453 self.assertTrue(data["env.tag"].startswith("3."), data["env.tag"]) 454 self.assertTrue(data["env.tag"].endswith("-32"), data["env.tag"]) 455 456 def test_search_major_2(self): 457 try: 458 data = self.run_py(["-2"], allow_fail=True) 459 except subprocess.CalledProcessError: 460 if not is_installed("2.7"): 461 raise unittest.SkipTest("requires at least one Python 2.x install") 462 self.assertEqual("PythonCore", data["env.company"]) 463 self.assertTrue(data["env.tag"].startswith("2."), data["env.tag"]) 464 465 def test_py_default(self): 466 with self.py_ini(TEST_PY_DEFAULTS): 467 data = self.run_py(["-arg"]) 468 self.assertEqual("PythonTestSuite", data["SearchInfo.company"]) 469 self.assertEqual("3.100", data["SearchInfo.tag"]) 470 self.assertEqual("X.Y.exe -arg", data["stdout"].strip()) 471 472 def test_py2_default(self): 473 with self.py_ini(TEST_PY_DEFAULTS): 474 data = self.run_py(["-2", "-arg"]) 475 self.assertEqual("PythonTestSuite", data["SearchInfo.company"]) 476 self.assertEqual("3.100-32", data["SearchInfo.tag"]) 477 self.assertEqual("X.Y-32.exe -arg", data["stdout"].strip()) 478 479 def test_py3_default(self): 480 with self.py_ini(TEST_PY_DEFAULTS): 481 data = self.run_py(["-3", "-arg"]) 482 self.assertEqual("PythonTestSuite", data["SearchInfo.company"]) 483 self.assertEqual("3.100-arm64", data["SearchInfo.tag"]) 484 self.assertEqual("X.Y-arm64.exe -X fake_arg_for_test -arg", data["stdout"].strip()) 485 486 def test_py_default_env(self): 487 data = self.run_py(["-arg"], env=TEST_PY_ENV) 488 self.assertEqual("PythonTestSuite", data["SearchInfo.company"]) 489 self.assertEqual("3.100", data["SearchInfo.tag"]) 490 self.assertEqual("X.Y.exe -arg", data["stdout"].strip()) 491 492 def test_py2_default_env(self): 493 data = self.run_py(["-2", "-arg"], env=TEST_PY_ENV) 494 self.assertEqual("PythonTestSuite", data["SearchInfo.company"]) 495 self.assertEqual("3.100-32", data["SearchInfo.tag"]) 496 self.assertEqual("X.Y-32.exe -arg", data["stdout"].strip()) 497 498 def test_py3_default_env(self): 499 data = self.run_py(["-3", "-arg"], env=TEST_PY_ENV) 500 self.assertEqual("PythonTestSuite", data["SearchInfo.company"]) 501 self.assertEqual("3.100-arm64", data["SearchInfo.tag"]) 502 self.assertEqual("X.Y-arm64.exe -X fake_arg_for_test -arg", data["stdout"].strip()) 503 504 def test_py_default_short_argv0(self): 505 with self.py_ini(TEST_PY_DEFAULTS): 506 for argv0 in ['"py.exe"', 'py.exe', '"py"', 'py']: 507 with self.subTest(argv0): 508 data = self.run_py(["--version"], argv=f'{argv0} --version') 509 self.assertEqual("PythonTestSuite", data["SearchInfo.company"]) 510 self.assertEqual("3.100", data["SearchInfo.tag"]) 511 self.assertEqual("X.Y.exe --version", data["stdout"].strip()) 512 513 def test_py_default_in_list(self): 514 data = self.run_py(["-0"], env=TEST_PY_ENV) 515 default = None 516 for line in data["stdout"].splitlines(): 517 m = re.match(r"\s*-V:(.+?)\s+?\*\s+(.+)$", line) 518 if m: 519 default = m.group(1) 520 break 521 self.assertEqual("PythonTestSuite/3.100", default) 522 523 def test_virtualenv_in_list(self): 524 with self.fake_venv() as (venv_exe, env): 525 data = self.run_py(["-0p"], env=env) 526 for line in data["stdout"].splitlines(): 527 m = re.match(r"\s*\*\s+(.+)$", line) 528 if m: 529 self.assertEqual(str(venv_exe), m.group(1)) 530 break 531 else: 532 if support.verbose: 533 print(data["stdout"]) 534 print(data["stderr"]) 535 self.fail("did not find active venv path") 536 537 data = self.run_py(["-0"], env=env) 538 for line in data["stdout"].splitlines(): 539 m = re.match(r"\s*\*\s+(.+)$", line) 540 if m: 541 self.assertEqual("Active venv", m.group(1)) 542 break 543 else: 544 self.fail("did not find active venv entry") 545 546 def test_virtualenv_with_env(self): 547 with self.fake_venv() as (venv_exe, env): 548 data1 = self.run_py([], env={**env, "PY_PYTHON": "PythonTestSuite/3"}) 549 data2 = self.run_py(["-V:PythonTestSuite/3"], env={**env, "PY_PYTHON": "PythonTestSuite/3"}) 550 # Compare stdout, because stderr goes via ascii 551 self.assertEqual(data1["stdout"].strip(), quote(venv_exe)) 552 self.assertEqual(data1["SearchInfo.lowPriorityTag"], "True") 553 # Ensure passing the argument doesn't trigger the same behaviour 554 self.assertNotEqual(data2["stdout"].strip(), quote(venv_exe)) 555 self.assertNotEqual(data2["SearchInfo.lowPriorityTag"], "True") 556 557 def test_py_shebang(self): 558 with self.py_ini(TEST_PY_DEFAULTS): 559 with self.script("#! /usr/bin/python -prearg") as script: 560 data = self.run_py([script, "-postarg"]) 561 self.assertEqual("PythonTestSuite", data["SearchInfo.company"]) 562 self.assertEqual("3.100", data["SearchInfo.tag"]) 563 self.assertEqual(f"X.Y.exe -prearg {quote(script)} -postarg", data["stdout"].strip()) 564 565 def test_python_shebang(self): 566 with self.py_ini(TEST_PY_DEFAULTS): 567 with self.script("#! python -prearg") as script: 568 data = self.run_py([script, "-postarg"]) 569 self.assertEqual("PythonTestSuite", data["SearchInfo.company"]) 570 self.assertEqual("3.100", data["SearchInfo.tag"]) 571 self.assertEqual(f"X.Y.exe -prearg {quote(script)} -postarg", data["stdout"].strip()) 572 573 def test_py2_shebang(self): 574 with self.py_ini(TEST_PY_DEFAULTS): 575 with self.script("#! /usr/bin/python2 -prearg") as script: 576 data = self.run_py([script, "-postarg"]) 577 self.assertEqual("PythonTestSuite", data["SearchInfo.company"]) 578 self.assertEqual("3.100-32", data["SearchInfo.tag"]) 579 self.assertEqual(f"X.Y-32.exe -prearg {quote(script)} -postarg", 580 data["stdout"].strip()) 581 582 def test_py3_shebang(self): 583 with self.py_ini(TEST_PY_DEFAULTS): 584 with self.script("#! /usr/bin/python3 -prearg") as script: 585 data = self.run_py([script, "-postarg"]) 586 self.assertEqual("PythonTestSuite", data["SearchInfo.company"]) 587 self.assertEqual("3.100-arm64", data["SearchInfo.tag"]) 588 self.assertEqual(f"X.Y-arm64.exe -X fake_arg_for_test -prearg {quote(script)} -postarg", 589 data["stdout"].strip()) 590 591 def test_py_shebang_nl(self): 592 with self.py_ini(TEST_PY_DEFAULTS): 593 with self.script("#! /usr/bin/python -prearg\n") as script: 594 data = self.run_py([script, "-postarg"]) 595 self.assertEqual("PythonTestSuite", data["SearchInfo.company"]) 596 self.assertEqual("3.100", data["SearchInfo.tag"]) 597 self.assertEqual(f"X.Y.exe -prearg {quote(script)} -postarg", 598 data["stdout"].strip()) 599 600 def test_py2_shebang_nl(self): 601 with self.py_ini(TEST_PY_DEFAULTS): 602 with self.script("#! /usr/bin/python2 -prearg\n") as script: 603 data = self.run_py([script, "-postarg"]) 604 self.assertEqual("PythonTestSuite", data["SearchInfo.company"]) 605 self.assertEqual("3.100-32", data["SearchInfo.tag"]) 606 self.assertEqual(f"X.Y-32.exe -prearg {quote(script)} -postarg", 607 data["stdout"].strip()) 608 609 def test_py3_shebang_nl(self): 610 with self.py_ini(TEST_PY_DEFAULTS): 611 with self.script("#! /usr/bin/python3 -prearg\n") as script: 612 data = self.run_py([script, "-postarg"]) 613 self.assertEqual("PythonTestSuite", data["SearchInfo.company"]) 614 self.assertEqual("3.100-arm64", data["SearchInfo.tag"]) 615 self.assertEqual(f"X.Y-arm64.exe -X fake_arg_for_test -prearg {quote(script)} -postarg", 616 data["stdout"].strip()) 617 618 def test_py_shebang_short_argv0(self): 619 with self.py_ini(TEST_PY_DEFAULTS): 620 with self.script("#! /usr/bin/python -prearg") as script: 621 # Override argv to only pass "py.exe" as the command 622 data = self.run_py([script, "-postarg"], argv=f'"py.exe" "{script}" -postarg') 623 self.assertEqual("PythonTestSuite", data["SearchInfo.company"]) 624 self.assertEqual("3.100", data["SearchInfo.tag"]) 625 self.assertEqual(f'X.Y.exe -prearg "{script}" -postarg', data["stdout"].strip()) 626 627 def test_py_handle_64_in_ini(self): 628 with self.py_ini("\n".join(["[defaults]", "python=3.999-64"])): 629 # Expect this to fail, but should get oldStyleTag flipped on 630 data = self.run_py([], allow_fail=True, expect_returncode=103) 631 self.assertEqual("3.999-64", data["SearchInfo.tag"]) 632 self.assertEqual("True", data["SearchInfo.oldStyleTag"]) 633 634 def test_search_path(self): 635 exe = Path("arbitrary-exe-name.exe").absolute() 636 exe.touch() 637 self.addCleanup(exe.unlink) 638 with self.py_ini(TEST_PY_DEFAULTS): 639 with self.script(f"#! /usr/bin/env {exe.stem} -prearg") as script: 640 data = self.run_py( 641 [script, "-postarg"], 642 env={"PATH": f"{exe.parent};{os.getenv('PATH')}"}, 643 ) 644 self.assertEqual(f"{quote(exe)} -prearg {quote(script)} -postarg", 645 data["stdout"].strip()) 646 647 def test_search_path_exe(self): 648 # Leave the .exe on the name to ensure we don't add it a second time 649 exe = Path("arbitrary-exe-name.exe").absolute() 650 exe.touch() 651 self.addCleanup(exe.unlink) 652 with self.py_ini(TEST_PY_DEFAULTS): 653 with self.script(f"#! /usr/bin/env {exe.name} -prearg") as script: 654 data = self.run_py( 655 [script, "-postarg"], 656 env={"PATH": f"{exe.parent};{os.getenv('PATH')}"}, 657 ) 658 self.assertEqual(f"{quote(exe)} -prearg {quote(script)} -postarg", 659 data["stdout"].strip()) 660 661 def test_recursive_search_path(self): 662 stem = self.get_py_exe().stem 663 with self.py_ini(TEST_PY_DEFAULTS): 664 with self.script(f"#! /usr/bin/env {stem}") as script: 665 data = self.run_py( 666 [script], 667 env={"PATH": f"{self.get_py_exe().parent};{os.getenv('PATH')}"}, 668 ) 669 # The recursive search is ignored and we get normal "py" behavior 670 self.assertEqual(f"X.Y.exe {quote(script)}", data["stdout"].strip()) 671 672 def test_install(self): 673 data = self.run_py(["-V:3.10"], env={"PYLAUNCHER_ALWAYS_INSTALL": "1"}, expect_returncode=111) 674 cmd = data["stdout"].strip() 675 # If winget is runnable, we should find it. Otherwise, we'll be trying 676 # to open the Store. 677 try: 678 subprocess.check_call(["winget.exe", "--version"], stdout=subprocess.PIPE, stderr=subprocess.STDOUT) 679 except FileNotFoundError: 680 self.assertIn("ms-windows-store://", cmd) 681 else: 682 self.assertIn("winget.exe", cmd) 683 # Both command lines include the store ID 684 self.assertIn("9PJPW5LDXLZ5", cmd) 685 686 def test_literal_shebang_absolute(self): 687 with self.script("#! C:/some_random_app -witharg") as script: 688 data = self.run_py([script]) 689 self.assertEqual( 690 f"C:\\some_random_app -witharg {quote(script)}", 691 data["stdout"].strip(), 692 ) 693 694 def test_literal_shebang_relative(self): 695 with self.script("#! ..\\some_random_app -witharg") as script: 696 data = self.run_py([script]) 697 self.assertEqual( 698 f"{quote(script.parent.parent / 'some_random_app')} -witharg {quote(script)}", 699 data["stdout"].strip(), 700 ) 701 702 def test_literal_shebang_quoted(self): 703 with self.script('#! "some random app" -witharg') as script: 704 data = self.run_py([script]) 705 self.assertEqual( 706 f"{quote(script.parent / 'some random app')} -witharg {quote(script)}", 707 data["stdout"].strip(), 708 ) 709 710 with self.script('#! some" random "app -witharg') as script: 711 data = self.run_py([script]) 712 self.assertEqual( 713 f"{quote(script.parent / 'some random app')} -witharg {quote(script)}", 714 data["stdout"].strip(), 715 ) 716 717 def test_literal_shebang_quoted_escape(self): 718 with self.script('#! some\\" random "app -witharg') as script: 719 data = self.run_py([script]) 720 self.assertEqual( 721 f"{quote(script.parent / 'some/ random app')} -witharg {quote(script)}", 722 data["stdout"].strip(), 723 ) 724 725 def test_literal_shebang_command(self): 726 with self.py_ini(TEST_PY_COMMANDS): 727 with self.script('#! test-command arg1') as script: 728 data = self.run_py([script]) 729 self.assertEqual( 730 f"TEST_EXE.exe arg1 {quote(script)}", 731 data["stdout"].strip(), 732 ) 733 734 def test_literal_shebang_invalid_template(self): 735 with self.script('#! /usr/bin/not-python arg1') as script: 736 data = self.run_py([script]) 737 expect = script.parent / "/usr/bin/not-python" 738 self.assertEqual( 739 f"{quote(expect)} arg1 {quote(script)}", 740 data["stdout"].strip(), 741 ) 742 743 def test_shebang_command_in_venv(self): 744 stem = "python-that-is-not-on-path" 745 746 # First ensure that our test name doesn't exist, and the launcher does 747 # not match any installed env 748 with self.script(f'#! /usr/bin/env {stem} arg1') as script: 749 data = self.run_py([script], expect_returncode=103) 750 751 with self.fake_venv() as (venv_exe, env): 752 # Put a "normal" Python on PATH as a distraction. 753 # The active VIRTUAL_ENV should be preferred when the name isn't an 754 # exact match. 755 exe = Path(Path(venv_exe).name).absolute() 756 exe.touch() 757 self.addCleanup(exe.unlink) 758 env["PATH"] = f"{exe.parent};{os.environ['PATH']}" 759 760 with self.script(f'#! /usr/bin/env {stem} arg1') as script: 761 data = self.run_py([script], env=env) 762 self.assertEqual(data["stdout"].strip(), f"{quote(venv_exe)} arg1 {quote(script)}") 763 764 with self.script(f'#! /usr/bin/env {exe.stem} arg1') as script: 765 data = self.run_py([script], env=env) 766 self.assertEqual(data["stdout"].strip(), f"{quote(exe)} arg1 {quote(script)}") 767 768 def test_shebang_executable_extension(self): 769 with self.script('#! /usr/bin/env python3.99') as script: 770 data = self.run_py([script], expect_returncode=103) 771 expect = "# Search PATH for python3.99.exe" 772 actual = [line.strip() for line in data["stderr"].splitlines() 773 if line.startswith("# Search PATH")] 774 self.assertEqual([expect], actual) 775