• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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