# -*- coding: utf-8 -*- """`liftexport.find_python_report`: an interpreter is chosen because it RUNS and imports what the caller needs, not because its folder sorts first. Gitea #16 (2026-09-05): `_python_dirs()` lists the newest install folder first, `find_python()` took the first python.exe that existed, and a bare Python 3.13 beat the Python 3.11 that had PyYAML. Panel Analysis' server died at `import yaml` before printing its URL and every mark went NOT RUN reading "did not report a URL in time". These pin the shape of the fix: probe, first PASS wins, every rejection is named, and a hand-configured interpreter that fails is reported rather than quietly replaced. """ import os import re import sys import pytest from vibe import liftexport as L ROOT = os.path.dirname(os.path.dirname(os.path.dirname( os.path.abspath(__file__)))) TOOLS = os.path.join(ROOT, 'tools') if TOOLS not in sys.path: sys.path.insert(0, TOOLS) EXE = L._PYTHON_NAMES[0] @pytest.fixture(autouse=True) def _clean(monkeypatch): L._PROBE_OK.clear() monkeypatch.delenv('WILLTOOLS_PYTHON', raising=False) monkeypatch.setenv('PATH', '') yield L._PROBE_OK.clear() def _install(folder, name=EXE): folder.mkdir(parents=True, exist_ok=True) exe = folder / name exe.write_bytes(b'not really an interpreter\n') return str(exe) def _probe_table(verdicts): """A probe that answers from a table {exe: (ok, verdict)} and records the order it was asked in.""" asked = [] def probe(exe, needs): asked.append(exe) return verdicts[exe] probe.asked = asked return probe # ------------------------------------------------------------ parse / quote def test_probe_line_with_nothing_missing_parses(): assert L._parse_probe('PY 3.11.4 MISSING ') == ((3, 11, 4), []) def test_probe_line_with_two_missing_modules_parses(): assert L._parse_probe('PY 3.13.0 MISSING yaml,fitz\n') == ( (3, 13, 0), ['yaml', 'fitz']) def test_garbage_output_is_not_a_version(): assert L._parse_probe('Python 3.11.4') == (None, []) assert L._parse_probe('') == (None, []) assert L._parse_probe(None) == (None, []) def test_win_quote_follows_the_commandlinetoargvw_rule(): assert L._win_quote('yaml') == '"yaml"' assert L._win_quote('a "b" c') == '"a \\"b\\" c"' assert L._win_quote('C:\\dir\\') == '"C:\\dir\\\\"' assert L._win_quote('x\\"y') == '"x\\\\\\"y"' assert L._win_quote('line one\nline two') == '"line one\nline two"' # --------------------------------------------------------------- real probe def test_the_host_interpreter_passes_a_real_probe(): ok, verdict = L.probe_python(sys.executable, ()) assert ok, verdict assert verdict.startswith('Python {0}.{1}.'.format(*sys.version_info[:2])) def test_a_real_probe_names_the_module_it_cannot_import(): ok, verdict = L.probe_python( sys.executable, ('no_such_module_willtools_gitea16',)) assert not ok assert 'no_such_module_willtools_gitea16' in verdict assert 'cannot import' in verdict def test_a_missing_module_prints_its_pip_name_not_its_import_name(): ok, verdict = L.probe_python( sys.executable, ('yaml_surely_absent_gitea16',)) assert not ok # the pip name table: yaml -> pyyaml, fitz -> pymupdf; unknown as is assert L._pip_name('yaml') == 'pyyaml' assert L._pip_name('fitz') == 'pymupdf' assert L._pip_name('yaml_surely_absent_gitea16') == \ 'yaml_surely_absent_gitea16' def test_a_file_that_is_not_an_interpreter_is_a_named_rejection(tmp_path): fake = _install(tmp_path / 'Python313') ok, verdict = L.probe_python(fake, ()) assert not ok assert verdict # never an empty reason def test_an_interpreter_below_the_floor_is_rejected(monkeypatch): monkeypatch.setattr(L, '_run_probe', lambda exe, needs: (0, 'PY 2.7.18 MISSING ')) ok, verdict = L.probe_python('C:/old/python.exe', ()) assert not ok assert '2.7.18' in verdict and '3.9+' in verdict # ---------------------------------------------------------------- caching def test_a_pass_is_cached_so_a_second_call_does_not_spawn(monkeypatch): calls = [] def run(exe, needs): calls.append(exe) return 0, 'PY 3.11.9 MISSING ' monkeypatch.setattr(L, '_run_probe', run) assert L.probe_python('C:/py/python.exe', ('yaml',))[0] assert L.probe_python('C:/py/python.exe', ('yaml',))[0] assert calls == ['C:/py/python.exe'] def test_a_failure_is_never_cached_so_pip_install_fixes_the_next_press( monkeypatch): answers = [(1, 'PY 3.13.0 MISSING yaml'), (0, 'PY 3.13.0 MISSING ')] monkeypatch.setattr(L, '_run_probe', lambda exe, needs: answers.pop(0)) ok1, verdict1 = L.probe_python('C:/py/python.exe', ('yaml',)) ok2, verdict2 = L.probe_python('C:/py/python.exe', ('yaml',)) assert (ok1, ok2) == (False, True) assert 'yaml' in verdict1 and 'pip install pyyaml' in verdict1 def test_the_cache_is_keyed_by_what_was_asked_for(monkeypatch): monkeypatch.setattr(L, '_run_probe', lambda exe, needs: (0, 'PY 3.11.9 MISSING ')) L.probe_python('C:/py/python.exe', ()) assert ('C:/py/python.exe', ('yaml',)) not in L._PROBE_OK # --------------------------------------------------------------- selection def test_gitea_16_the_older_install_with_the_package_wins(tmp_path, monkeypatch): """The exact machine: Python313 sorts first and has no PyYAML, Python311 has it. The 313 used to win on existence alone.""" newest = _install(tmp_path / 'Python313') older = _install(tmp_path / 'Python311') monkeypatch.setattr(L, '_python_dirs', lambda: [ os.path.dirname(newest), os.path.dirname(older)]) probe = _probe_table({ newest: (False, 'Python 3.13.0 cannot import yaml (pip install pyyaml)'), older: (True, 'Python 3.11.9, imports yaml'), }) exe, tried = L.find_python_report(None, ('yaml',), probe=probe) assert exe == older assert probe.asked == [newest, older] assert tried == [ (newest, 'Python 3.13.0 cannot import yaml (pip install pyyaml)'), (older, 'Python 3.11.9, imports yaml'), ] def test_find_python_is_the_report_with_the_reasons_dropped(tmp_path, monkeypatch): exe_path = _install(tmp_path / 'Python311') monkeypatch.setattr(L, '_python_dirs', lambda: [os.path.dirname(exe_path)]) monkeypatch.setattr(L, 'probe_python', lambda exe, needs: (True, 'ok')) assert L.find_python(None, ('yaml',)) == exe_path def test_no_candidate_passing_returns_none_and_every_rejection(tmp_path, monkeypatch): a = _install(tmp_path / 'Python313') b = _install(tmp_path / 'Python311') monkeypatch.setattr(L, '_python_dirs', lambda: [ os.path.dirname(a), os.path.dirname(b)]) probe = _probe_table({a: (False, 'cannot import yaml'), b: (False, 'cannot import yaml')}) exe, tried = L.find_python_report(None, ('yaml',), probe=probe) assert exe is None assert [t[0] for t in tried] == [a, b] text = L.describe_python_search(tried, ('yaml',)) assert a in text and b in text assert 'cannot import yaml' in text assert 'handling.python_exe' in text def test_an_empty_search_says_nothing_was_found_and_how_to_fix_it(): text = L.describe_python_search([], ('yaml',)) assert 'no CPython 3.9+ was found' in text assert 'handling.python_exe' in text def test_the_store_alias_folder_is_never_probed(tmp_path, monkeypatch): store = _install(tmp_path / 'WindowsApps') real = _install(tmp_path / 'Python311') monkeypatch.setattr(L, '_python_dirs', lambda: [ os.path.dirname(store), os.path.dirname(real)]) probe = _probe_table({real: (True, 'ok')}) exe, tried = L.find_python_report(None, (), probe=probe) assert exe == real assert store not in probe.asked def test_the_same_interpreter_reached_twice_is_probed_once(tmp_path, monkeypatch): exe_path = _install(tmp_path / 'Python311') folder = os.path.dirname(exe_path) monkeypatch.setattr(L, '_python_dirs', lambda: [folder, folder]) probe = _probe_table({exe_path: (False, 'no')}) L.find_python_report(None, (), probe=probe) assert probe.asked == [exe_path] # ------------------------------------------------ a hand-configured path def test_a_configured_path_that_fails_is_reported_not_replaced(tmp_path, monkeypatch): """WILLTOOLS_PYTHON names an interpreter without the package; a working one sits in the install folder. The configured one is the ONLY candidate: the run must not proceed on an interpreter nobody chose.""" stated = _install(tmp_path / 'chosen') other = _install(tmp_path / 'Python311') monkeypatch.setenv('WILLTOOLS_PYTHON', stated) monkeypatch.setattr(L, '_python_dirs', lambda: [os.path.dirname(other)]) probe = _probe_table({stated: (False, 'cannot import yaml'), other: (True, 'ok')}) exe, tried = L.find_python_report(None, ('yaml',), probe=probe) assert exe is None assert probe.asked == [stated] assert tried == [(stated, 'WILLTOOLS_PYTHON: cannot import yaml')] def test_a_configured_path_that_passes_is_used(tmp_path, monkeypatch): stated = _install(tmp_path / 'chosen') monkeypatch.setenv('WILLTOOLS_PYTHON', stated) probe = _probe_table({stated: (True, 'Python 3.11.9')}) exe, tried = L.find_python_report(None, (), probe=probe) assert exe == stated assert tried == [(stated, 'WILLTOOLS_PYTHON: Python 3.11.9')] def test_a_configured_path_that_does_not_exist_stops_the_search(tmp_path, monkeypatch): other = _install(tmp_path / 'Python311') monkeypatch.setenv('WILLTOOLS_PYTHON', str(tmp_path / 'gone' / EXE)) monkeypatch.setattr(L, '_python_dirs', lambda: [os.path.dirname(other)]) probe = _probe_table({other: (True, 'ok')}) exe, tried = L.find_python_report(None, (), probe=probe) assert exe is None assert probe.asked == [] assert len(tried) == 1 and 'does not exist' in tried[0][1] assert 'WILLTOOLS_PYTHON' in tried[0][1] def test_the_knowledge_layer_path_outranks_the_environment(tmp_path, monkeypatch): from_know = _install(tmp_path / 'know') from_env = _install(tmp_path / 'env') monkeypatch.setenv('WILLTOOLS_PYTHON', from_env) know = {'handling': {'python_exe': from_know}} probe = _probe_table({from_know: (True, 'ok'), from_env: (True, 'ok')}) exe, tried = L.find_python_report(know, (), probe=probe) assert exe == from_know assert tried[0][1].startswith('handling.python_exe:') # ---------------------------------------------- setup_machine.py --check def _step(): S = pytest.importorskip('setup_machine') out = [] step = S.Step(out.append) step.lines = out return S, step class _FakeLiftexport(object): def __init__(self, exe, tried): self._answer = (exe, tried) self.needs_seen = None def find_python_report(self, know, needs): self.needs_seen = needs return self._answer describe_python_search = staticmethod(L.describe_python_search) def test_setup_check_passes_when_the_chooser_finds_a_working_interpreter(): S, step = _step() fake = _FakeLiftexport('C:/py311/python.exe', [('C:/py311/python.exe', 'Python 3.11.9, imports yaml')]) assert S.verify_button_python(step, liftexport=fake) assert fake.needs_seen == S.BUTTON_PYTHON_NEEDS assert step.passed and not step.failures assert 'C:/py311/python.exe' in step.lines[0] def test_setup_check_fails_and_lists_every_rejected_interpreter(): """The machine in Gitea #16: `sys.executable` imports yaml, so the old dependency check passed, while the chooser picked a 3.13 that could not. This step asks the chooser, so it fails here.""" S, step = _step() fake = _FakeLiftexport(None, [ ('C:/py313/python.exe', 'Python 3.13.0 cannot import yaml (pip install pyyaml)'), ]) assert not S.verify_button_python(step, liftexport=fake) assert step.failures text = '\n'.join(step.lines) assert 'C:/py313/python.exe' in text assert 'cannot import yaml' in text assert 'handling.python_exe' in text def test_setup_check_fails_loudly_when_liftexport_cannot_load(monkeypatch): S, step = _step() monkeypatch.setattr(S, '_load_liftexport', lambda: None) assert not S.verify_button_python(step) assert step.failures def test_setup_check_runs_the_real_chooser_on_this_host(monkeypatch): """No fake: the real `find_python_report` under the real environment of this host. Only asserts the shape -- a checkout with no CPython on PATH is a legitimate FAIL, and this test may not fake one in.""" S, step = _step() monkeypatch.setenv('PATH', os.path.dirname(sys.executable)) S.verify_button_python(step) assert step.passed or step.failures assert step.lines def test_setup_check_asks_for_what_the_button_asks_for(): """BUTTON_PYTHON_NEEDS and the button's PA_NEEDS are kept in step by hand; this is the test that notices when one moves.""" S, _ = _step() script = os.path.join( ROOT, 'WillTools.extension', 'WillTools.tab', 'Design.panel', 'DesignHub.pulldown', 'PanelAnalysis.pushbutton', 'script.py') with open(script, encoding='utf-8') as fh: text = fh.read() m = re.search(r"^PA_NEEDS = \((.*)\)$", text, re.M) assert m, 'PanelAnalysis script.py no longer declares PA_NEEDS' button_needs = tuple(x.strip().strip("'\"") for x in m.group(1).split(',') if x.strip()) assert button_needs == S.BUTTON_PYTHON_NEEDS def test_setup_check_is_part_of_run_verification(): S, _ = _step() import inspect assert 'verify_button_python' in inspect.getsource(S.run_verification)