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