diff --git a/templates/assembly-root/scripts/bootstrap.py b/templates/assembly-root/scripts/bootstrap.py index 7a03472f..38a09533 100755 --- a/templates/assembly-root/scripts/bootstrap.py +++ b/templates/assembly-root/scripts/bootstrap.py @@ -328,15 +328,18 @@ def _git_runs(unread: str) -> Iterator[None]: words are the validators'. Which `OSError` is git's is read as `validate-pins.py` reads it, by NAME: one whose `filename` is `git`, or a nameless `FileNotFoundError` or `PermissionError`, which is what - Windows raises for a program it cannot start. An error that names a - directory, which `subprocess` raises for a `cwd` it cannot enter, is - not git's, and goes on as it did. + Windows raises for a program it cannot start, or a nameless `OSError` + whose `winerror` is 193, which it raises for a `git.exe` it cannot + execute (#1001). An error that names a directory, which `subprocess` + raises for a `cwd` it cannot enter, is not git's, and goes on as it did. """ try: yield except OSError as exc: - if exc.filename != "git" and (exc.filename is not None or not ( - isinstance(exc, (FileNotFoundError, PermissionError)))): + nameless = (exc.filename is None + and (isinstance(exc, (FileNotFoundError, PermissionError)) + or getattr(exc, "winerror", None) == 193)) + if exc.filename != "git" and not nameless: raise raise Refusal("git-not-runnable", f"{unread}: git could not be run " diff --git a/templates/assembly-root/scripts/validate-pins.py b/templates/assembly-root/scripts/validate-pins.py index 62d53ecf..b485d870 100755 --- a/templates/assembly-root/scripts/validate-pins.py +++ b/templates/assembly-root/scripts/validate-pins.py @@ -799,11 +799,12 @@ def _git_runs(unread: str) -> Iterator[None]: `filename` where a program that will not start names `git`. So the rule is the NAME: any `OSError` whose `filename` is `git` counts, `ENOEXEC`'s included, and an error that names a directory or any other file passes - through unchanged, and so does any `OSError` that names none. The one - nameless error that counts is a `FileNotFoundError` or a - `PermissionError`: Windows' `CreateProcess` failures carry no filename, - so a missing git there is `[WinError 2]` with `filename` None, and - nothing says it was the directory. + through unchanged. Of the errors that name none, only a + `FileNotFoundError` or a `PermissionError` counts, and an `OSError` + whose `winerror` is 193: Windows' `CreateProcess` failures carry no + filename, so a missing git there is `[WinError 2]` with `filename` None, + and nothing says it was the directory, and a `git.exe` it cannot + execute is `[WinError 193]`, which is neither class (#1001). `recorded_gitlink`'s `except Refusal` is not in the way: an `OSError` is no `Refusal`, so a missing git reaches here instead of reading as "no @@ -813,7 +814,8 @@ def _git_runs(unread: str) -> Iterator[None]: yield except OSError as exc: nameless = (exc.filename is None - and isinstance(exc, (FileNotFoundError, PermissionError))) + and (isinstance(exc, (FileNotFoundError, PermissionError)) + or getattr(exc, "winerror", None) == 193)) if exc.filename != "git" and not nameless: raise raise Refusal( diff --git a/templates/family-root/scripts/bootstrap.py b/templates/family-root/scripts/bootstrap.py index bb70a87c..e60be90c 100755 --- a/templates/family-root/scripts/bootstrap.py +++ b/templates/family-root/scripts/bootstrap.py @@ -462,11 +462,14 @@ def git_not_started(exc: OSError) -> Refusal | None: `FileNotFoundError` traceback, exit 1, the code of `family bootstrap FAILED`, though nothing had failed. Git's is told by its NAME, as `validate-pins.py` tells it: a `filename` of `git`, or none at all on a - `FileNotFoundError` or a `PermissionError`, as Windows raises them; one - naming a directory is not git's. The code, and the remedy's first - sentence, are `validate-family.py`'s.""" - nameless = exc.filename is None and isinstance( - exc, (FileNotFoundError, PermissionError)) + `FileNotFoundError` or a `PermissionError`, as Windows raises them, or + on an `OSError` whose `winerror` is 193, Windows' `[WinError 193]` for a + `git.exe` it cannot execute (#1001); one naming a directory is not + git's. The code, and the remedy's first sentence, are + `validate-family.py`'s.""" + nameless = (exc.filename is None + and (isinstance(exc, (FileNotFoundError, PermissionError)) + or getattr(exc, "winerror", None) == 193)) if exc.filename != "git" and not nameless: return None return Refusal( diff --git a/templates/family-root/scripts/validate-family.py b/templates/family-root/scripts/validate-family.py index 20d6a6d3..d7131e27 100755 --- a/templates/family-root/scripts/validate-family.py +++ b/templates/family-root/scripts/validate-family.py @@ -334,23 +334,24 @@ def _git_runs(unread: str) -> Iterator[None]: `filename` is `git` counts, `ENOEXEC`'s included (a binary for another architecture, a script with no shebang), and so does a `FileNotFoundError` or a `PermissionError` that names no file, as - Windows' `CreateProcess` raises one for a missing git. Every other - `OSError` passes through as it came, and so does Windows' `[WinError - 193]`, a `git.exe` it cannot execute, which names no file and is - neither class: `validate-pins.py` passes it through as well. + Windows' `CreateProcess` raises one for a missing git, and so does an + `OSError` that names none whose `winerror` is 193, Windows' `[WinError + 193]` for a `git.exe` it cannot execute, which is neither class (#1001). + Every other `OSError` passes through as it came. `validate-pins.py`'s RULE, COPIED, NOT SHARED. This file is a pinned copy, and a holder re-copies its pinned files one row at a time, so it may run beside a `repo_shape.py` older than itself: a rule moved there would end this run in an `ImportError` (#610's skew rule). - `tests/test_validate_family_git_not_runnable.py` holds the two copies to - one answer. + `tests/test_validate_family_git_not_runnable.py` holds this copy, that + one and both `bootstrap.py`s' to one answer and one text. """ try: yield except OSError as exc: nameless = (exc.filename is None - and isinstance(exc, (FileNotFoundError, PermissionError))) + and (isinstance(exc, (FileNotFoundError, PermissionError)) + or getattr(exc, "winerror", None) == 193)) if exc.filename != "git" and not nameless: raise raise Refusal( diff --git a/tests/test_bootstrap_without_git.py b/tests/test_bootstrap_without_git.py index 4f2985dd..c7804391 100644 --- a/tests/test_bootstrap_without_git.py +++ b/tests/test_bootstrap_without_git.py @@ -19,7 +19,9 @@ going on would only say it again. Which `OSError` is git's is read by NAME, as `validate-pins.py` reads it: `filename` `git`, or none on a `FileNotFoundError` or a `PermissionError`, which is how Windows raises -them; an error naming a directory goes on as it did. +them, or on an `OSError` whose `winerror` is 193, Windows' `[WinError 193]` +for a `git.exe` it cannot execute (#1001); an error naming a directory goes +on as it did. The end-to-end runs are a person's: a scaffold's recursive clone, a plain clone, and a family holder, each run with no git on `PATH`. The rule's other @@ -37,6 +39,7 @@ from conftest import (PROJECT, REPO, clone_just_pushed, load_without_bytecode, run_script) from test_family import family, holder # noqa: F401 +from test_validate_pins_without_git import WIN_ERROR_193 #: What `subprocess` says when there is no git to start, here. NO_GIT = ("The system cannot find the file specified" if os.name == "nt" @@ -53,6 +56,33 @@ "through git too, so without it nothing here can be fetched or " "bootstrapped.") +#: What stands where a `git.exe` Windows cannot execute would. Python imports +#: `sitecustomize` at start-up from `PYTHONPATH`, so the script, and anything +#: it starts, load this first, as `tests/test_update_shape_landing.py`'s +#: `GH_ON_PATH` stands in for `gh`. Starting a program named `git` raises +#: what CPython raises on Windows there, `WIN_ERROR_193`; nothing else +#: changes. +GIT_NOT_WIN32 = """\ +import errno +import os +import subprocess + + +class _Popen(subprocess.Popen): + def __init__(self, args, *rest, **kwargs): + if (isinstance(args, (list, tuple)) and args + and os.path.basename(str(args[0])).lower() + in ("git", "git.exe")): + error = OSError(errno.ENOEXEC, + "%1 is not a valid Win32 application") + error.winerror = 193 + raise error + super().__init__(args, *rest, **kwargs) + + +subprocess.Popen = _Popen +""" + def without_git(root: Path, tmp_path: Path, *extra: str, env: dict | None = None): @@ -89,25 +119,37 @@ def test_an_assembly_root_with_no_git_refuses_in_step_a(project, tmp_path, "\n(a) legs on tracking branches, pins untouched\n"), result.stdout +@pytest.mark.parametrize("shim, said", [ + (None, NO_GIT), (GIT_NOT_WIN32, WIN_ERROR_193.strerror)], + ids=["no-git", "winerror-193"]) def test_the_offline_upstream_notice_with_no_git_refuses_too(scaffolded, - tmp_path): + tmp_path, shim, + said): """A PLAIN clone: both legs NOT INITIALIZED, so step (a) runs no git, and `SHAPE_UPSTREAM_PATH` names a directory, so the notice after the validators runs one. `validate-pins.py` refuses `git-not-runnable` itself; the notice then ended the run in a traceback. It refuses, and - the review-authority step is not reached.""" + the review-authority step is not reached. So it does where every start + of git raises Windows' `[WinError 193]` (`GIT_NOT_WIN32`; #1001, + RV1028's NIT 1), which the notice's `except OSError` used to swallow + and say nothing of.""" clone = tmp_path / PROJECT made = clone_just_pushed(scaffolded["remotes"] / f"{PROJECT}.git", clone, recurse=False) assert made.returncode == 0, made.stderr upstream = tmp_path / "upstream" upstream.mkdir() - - result = without_git(clone, tmp_path, - env={"SHAPE_UPSTREAM_PATH": str(upstream)}) + env = {"SHAPE_UPSTREAM_PATH": str(upstream)} + if shim is not None: + sitecustomize = tmp_path / "git-not-win32" + sitecustomize.mkdir() + (sitecustomize / "sitecustomize.py").write_text(shim, encoding="utf-8") + env["PYTHONPATH"] = str(sitecustomize) + + result = without_git(clone, tmp_path, env=env) refused(result, "REFUSED git-not-runnable: the clone SHAPE_UPSTREAM_PATH names, " - f"{upstream}, could not be read: git could not be run ({NO_GIT})", + f"{upstream}, could not be read: git could not be run ({said})", ASSEMBLY_REMEDY) assert " [spec] spec: NOT INITIALIZED." in result.stdout, result.stdout assert "(c) review authority" not in result.stdout @@ -157,12 +199,16 @@ def made_of(module, error: OSError): PermissionError(errno.EACCES, "Permission denied", "git"), OSError(errno.ENOEXEC, "Exec format error", "git"), FileNotFoundError(2, "The system cannot find the file specified"), -], ids=["no git", "git not executable", "git no program", "windows"]) + WIN_ERROR_193, +], ids=["no git", "git not executable", "git no program", "windows", + "windows winerror 193"]) def test_each_way_git_cannot_start_is_git_not_runnable(root_module, error): made = made_of(root_module, error) assert made.code == "git-not-runnable", made assert made.detail.endswith( f": git could not be run ({error.strerror})"), made.detail + assert made.remediation.startswith( + "Remediation: put git on PATH and run this again. "), made @pytest.mark.parametrize("error", [ diff --git a/tests/test_validate_family_git_not_runnable.py b/tests/test_validate_family_git_not_runnable.py index 1cc69006..fb185824 100644 --- a/tests/test_validate_family_git_not_runnable.py +++ b/tests/test_validate_family_git_not_runnable.py @@ -19,11 +19,21 @@ `repo_shape.py`, but both validators are pinned copies, and a holder or a root re-copies its pinned files one row at a time, so either may run beside a `repo_shape.py` older than itself (#610's skew rule, -`tests/test_pinned_copy_text.py`). Here the two templates' `_git_runs` are -loaded in this process and handed the same errors, and must give one -answer to each: the errors `validate-pins.py`'s own tests hand its wrapper -(`tests/test_validate_pins_without_git.py`), the member checkout git could -not enter, and Windows' `[WinError 193]`. +`tests/test_pinned_copy_text.py`). Here the rule's four copies are loaded +in this process and handed the same errors, and must give one answer to +each: both validators' `_git_runs`, and both roots' `bootstrap.py`, the +assembly root's `_git_runs` and the holder's `git_not_started` (#694; RV983's +NIT 1 on #983). The errors are the ones `validate-pins.py`'s own tests hand +its wrapper (`tests/test_validate_pins_without_git.py`), the member checkout +git could not enter, and Windows' `[WinError 193]`, which every copy now +counts (#1001). And the four are held to one text. + +WINDOWS' `[WinError 193]` (#1001), a `git.exe` Windows cannot execute, names +no file and is neither `FileNotFoundError` nor `PermissionError`, so no copy +counted it, and each run ended in a traceback, exit 1. Each copy now counts a +nameless `OSError` whose `winerror` is 193, with the words, the remedy and +the exit of a missing git; here each copy's own script is run with git +raising it, as Windows would. THE REST RUNS THE HOLDER'S OWN VALIDATOR IN THIS PROCESS, with git failing at one of the three calls that are still wrapped (the gitlink, the pinned @@ -38,62 +48,83 @@ import ast import errno import sys +from pathlib import Path from typing import NamedTuple import pytest -from conftest import REPO, git, load_without_bytecode +from conftest import REPO, git, load_without_bytecode, run_script +from test_bootstrap_without_git import (ASSEMBLY_REMEDY, FAMILY_REMEDY, + GIT_NOT_WIN32) from test_family import MEMBERS, family, holder # noqa: F401 from test_validate_family_without_git import (REMEDIATION, holder_validator, run, without_git) from test_validate_pins_without_git import (CANNOT_EXECUTE, NO_FILE_NAMED, NO_GIT, NOT_EXECUTABLE, NOT_EXECUTABLE_NO_FILE_NAMED, - NOT_GIT) + NOT_GIT, WIN_ERROR_193) +from test_validate_pins_without_git import REMEDIATION as PINS_REMEDIATION FAMILY_TEMPLATE = (REPO / "templates" / "family-root" / "scripts" / "validate-family.py") PINS_TEMPLATE = (REPO / "templates" / "assembly-root" / "scripts" / "validate-pins.py") +#: The rule's four copies, each the template and the function that holds it +#: (#1001; RV983's NIT 1 on #983): both validators' `_git_runs`, the +#: assembly root's `bootstrap.py`'s `_git_runs`, and the holder's +#: `bootstrap.py`'s `git_not_started` (#694). +COPIES = { + "validate-family.py": (FAMILY_TEMPLATE, "_git_runs"), + "validate-pins.py": (PINS_TEMPLATE, "_git_runs"), + "family-root bootstrap.py": ( + REPO / "templates" / "family-root" / "scripts" / "bootstrap.py", + "git_not_started"), + "assembly-root bootstrap.py": ( + REPO / "templates" / "assembly-root" / "scripts" / "bootstrap.py", + "_git_runs"), +} + MEMBER = MEMBERS[0] ROW = f"members/{MEMBER}" COUNTED = "git-not-runnable" PASSED = "passed through unchanged" +#: What every copy's `git-not-runnable` remedy starts with. +PUT_GIT_ON_PATH = "Remediation: put git on PATH and run " -def win_error_193(_root=None) -> OSError: - """What CPython raises on Windows for a `git.exe` that Windows cannot - execute (`[WinError 193] %1 is not a valid Win32 application`): a plain - `OSError`, since `winerror` 193 maps to `errno` `ENOEXEC`, which has no - subclass, and no filename, since `CreateProcess` failures carry none. - Read from CPython (`PC/errmap.h`, `_winapi.CreateProcess`), not run on - Windows, and built by hand on every OS: neither rule reads `winerror`.""" - error = OSError(errno.ENOEXEC, "%1 is not a valid Win32 application") +def win_error_193_naming(path: Path) -> OSError: + """`WIN_ERROR_193` naming `path`: no error Windows raises at a git call, + since `CreateProcess` failures carry no filename, but the half of the + rule that counts 193 only where nothing is named (#1001).""" + error = OSError(errno.ENOEXEC, "%1 is not a valid Win32 application", + str(path)) error.winerror = 193 return error #: The errors git raises when it will not start, each named `git` or naming -#: nothing (`tests/test_validate_pins_without_git.py`'s): both rules count -#: them. +#: nothing (`tests/test_validate_pins_without_git.py`'s): every copy counts +#: them, Windows' `[WinError 193]` too (#1001). WILL_NOT_START = { "no-git": NO_GIT, "git-not-executable": NOT_EXECUTABLE, "git-cannot-execute": CANNOT_EXECUTE, "no-git-naming-no-file": NO_FILE_NAMED, "git-not-executable-naming-no-file": NOT_EXECUTABLE_NO_FILE_NAMED, + "winerror-193": WIN_ERROR_193, } #: The errors that are not a git that will not start, each built from a #: root: `validate-pins.py`'s `NOT_GIT`, the member's checkout git could #: not enter, which `subprocess` names in `filename`, as #928 names it, and -#: Windows' `[WinError 193]`, which both rules pass through. +#: a `[WinError 193]` that names it. NOT_GITS = { **NOT_GIT, "member-checkout-not-entered": lambda root: PermissionError( errno.EACCES, "Permission denied", str(root / ROW)), - "winerror-193": win_error_193, + "winerror-193-naming-the-member": lambda root: win_error_193_naming( + root / ROW), } PARITY = [ @@ -104,48 +135,116 @@ def win_error_193(_root=None) -> OSError: @pytest.fixture(scope="module") -def validators() -> dict: - """Both templates' validators, loaded with no bytecode, each over the +def copies() -> dict: + """The four copies' templates, loaded with no bytecode, each over the standard's `repo_shape`; `sys.path`, which each extends, is put back.""" saved = list(sys.path) try: - return { - "validate-family.py": load_without_bytecode( - FAMILY_TEMPLATE, "validate_family_git_not_runnable"), - "validate-pins.py": load_without_bytecode( - PINS_TEMPLATE, "validate_pins_git_not_runnable"), - } + return {name: load_without_bytecode(path, f"git_not_runnable_{index}") + for index, (name, (path, _)) in enumerate(COPIES.items())} finally: sys.path[:] = saved def answer(module, error: OSError) -> str: - """What `module._git_runs` does with `error` raised inside it: the - `git-not-runnable` refusal raised from it, or `error` itself raised on, - else what it raised instead, or that it raised nothing.""" + """What `module`'s copy of the rule does with `error` raised where git + starts: the `git-not-runnable` refusal raised from it, with a missing + git's words and remedy, or `error` itself raised on, else what it raised + instead, or that it raised nothing. A `_git_runs` is handed it inside + its block; the holder's `git_not_started` is asked as its call site + asks it (`fetch_members`), and the refusal it gives is raised from it.""" try: - with module._git_runs("the check named"): - raise error + if hasattr(module, "_git_runs"): + with module._git_runs("the check named"): + raise error + else: + refusal = module.git_not_started(error) + if refusal is None: + raise error + raise refusal from error except module.Refusal as refusal: - return (COUNTED if (refusal.code, refusal.__cause__) - == (COUNTED, error) else repr(refusal)) + missing_git = ( + (refusal.code, refusal.__cause__) == (COUNTED, error) + and refusal.detail.endswith( + f": git could not be run ({error.strerror})") + and refusal.remediation.startswith(PUT_GIT_ON_PATH)) + return COUNTED if missing_git else repr(refusal) except OSError as raised: return PASSED if raised is error else repr(raised) return "nothing raised" @pytest.mark.parametrize("make, expected", PARITY) -def test_both_validators_give_one_answer_to_each_oserror(validators, - tmp_path, make, - expected): - """THE PARITY (#928): one rule in two pinned copies. `validate-family.py` - counted all of these, and `validate-pins.py` the five that say git will - not start. A change to either copy's rule fails here until the other - copy says the same.""" +def test_the_four_copies_give_one_answer_to_each_oserror(copies, tmp_path, + make, expected): + """THE PARITY (#928, #1001): one rule in four pinned copies. + `validate-family.py` counted all of these, and `validate-pins.py` and + both bootstraps the five that say git will not start, until #1001 made + `[WinError 193]` the sixth in all four. A change to any copy's rule + fails here until the other three say the same.""" error = make(tmp_path) - answers = {name: answer(module, error) - for name, module in validators.items()} - assert answers == dict.fromkeys(validators, expected) + answers = {name: answer(module, error) for name, module in copies.items()} + assert answers == dict.fromkeys(copies, expected) + + +def the_rule(path: Path, function: str) -> list[str]: + """The rule as `function` in `path` holds it, its layout aside + (`ast.dump`): what `nameless` is, and the test of the `if` that reads + it.""" + tree = ast.parse(path.read_text(encoding="utf-8")) + held = next(node for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) and node.name == function) + return [ast.dump(node.value if isinstance(node, ast.Assign) else node.test) + for node in ast.walk(held) + if (isinstance(node, ast.Assign) + and [getattr(target, "id", None) for target in node.targets] + == ["nameless"]) + or (isinstance(node, ast.If) + and any(isinstance(name, ast.Name) and name.id == "nameless" + for name in ast.walk(node.test)))] + + +def test_the_four_copies_are_one_text(): + """ONE TEXT (#1001). The rule is copied, not shared (#610's skew rule), + and the parity above holds the copies to one answer for the errors it + knows. Here the copies are held to one rule for every error: the same + `nameless`, and the same test of it, in each. The assembly root's + `bootstrap.py` said the same rule in other words until #1001.""" + rules = {name: the_rule(path, function) + for name, (path, function) in COPIES.items()} + assert len(rules["validate-pins.py"]) == 2, rules + assert rules == dict.fromkeys(COPIES, rules["validate-pins.py"]) + + +@pytest.mark.parametrize("root, script, remedy", [ + pytest.param("holder", "validate-family.py", REMEDIATION, + id="validate-family.py"), + pytest.param("project", "validate-pins.py", PINS_REMEDIATION, + id="validate-pins.py"), + pytest.param("holder", "bootstrap.py", FAMILY_REMEDY, + id="family-root bootstrap.py"), + pytest.param("project", "bootstrap.py", ASSEMBLY_REMEDY, + id="assembly-root bootstrap.py"), +]) +def test_a_git_windows_cannot_execute_is_refused_by_each_copy( + request, tmp_path, root, script, remedy): + """#1001's ROW IN EACH COPY, RUN: the root's own script, in a child, where + every start of git raises Windows' `[WinError 193]`. Each ended in a + traceback, exit 1; each now refuses `git-not-runnable`, exit 2, at the + first check that runs git, with its remedy to put git on PATH.""" + where = request.getfixturevalue(root) + shim = tmp_path / "git-not-win32" + shim.mkdir() + (shim / "sitecustomize.py").write_text(GIT_NOT_WIN32, encoding="utf-8") + result = run_script(where / "scripts" / script, cwd=where, + env={"PYTHONPATH": str(shim)}) + assert result.returncode == 2, result.stderr + result.stdout + assert "Traceback" not in result.stderr, result.stderr + refused, said = result.stderr.splitlines()[-2:] + assert refused.startswith(f"REFUSED {COUNTED}: "), result.stderr + assert refused.endswith(": git could not be run (%1 is not a valid " + "Win32 application)"), result.stderr + assert said == remedy, result.stderr class Call(NamedTuple): diff --git a/tests/test_validate_pins_without_git.py b/tests/test_validate_pins_without_git.py index fa020756..f7410bc5 100644 --- a/tests/test_validate_pins_without_git.py +++ b/tests/test_validate_pins_without_git.py @@ -17,11 +17,13 @@ told apart from every other `OSError`: the wrapper is around the git calls alone, and inside one it counts an `OSError` whose `filename` is `git` (a missing git, one that will not run, and one that cannot be executed, `ENOEXEC`: -a binary for another architecture or a script with no shebang), and a +a binary for another architecture or a script with no shebang), a `FileNotFoundError` or a `PermissionError` that names no file (Windows' -`CreateProcess` failures carry none). `subprocess` raises the same two classes -when the directory it was to run git in cannot be entered, and names that -directory in `filename`; a program that will not start names `git`. +`CreateProcess` failures carry none), and an `OSError` that names none whose +`winerror` is 193, a `git.exe` Windows cannot execute (#1001). `subprocess` +raises the same two classes when the directory it was to run git in cannot be +entered, and names that directory in `filename`; a program that will not start +names `git`. THE ROOT'S OWN VALIDATOR, RUN IN THIS PROCESS, with git failing as a missing program does, and no PATH changed for anything else, so each test means the @@ -66,9 +68,19 @@ CANNOT_EXECUTE = OSError(errno.ENOEXEC, "Exec format error", "git") #: An error that names no file: Windows' `CreateProcess` failures carry no #: filename, so a missing git there looks like this, and nothing says it was a -#: directory. Only these two classes count, named or not (see `NOT_GIT`). +#: directory. Only these two classes count, named or not, and the one +#: `WIN_ERROR_193` is (see `NOT_GIT`). NO_FILE_NAMED = FileNotFoundError(2, "No such file or directory") NOT_EXECUTABLE_NO_FILE_NAMED = PermissionError(13, "Permission denied") +#: What CPython raises on Windows for a `git.exe` that Windows cannot execute +#: (`[WinError 193] %1 is not a valid Win32 application`): a plain `OSError`, +#: since `winerror` 193 maps to `errno` `ENOEXEC`, which has no subclass, and +#: no filename, since `CreateProcess` failures carry none. It counts by its +#: `winerror` (#1001). Read from CPython (`PC/errmap.h`, +#: `_winapi.CreateProcess`), not run on Windows, and built by hand on every +#: OS, since only Windows sets `winerror`. +WIN_ERROR_193 = OSError(errno.ENOEXEC, "%1 is not a valid Win32 application") +WIN_ERROR_193.winerror = 193 #: The run options. The validator has one mode of work and these three #: spellings of asking it: plain, without the `ok` notes, and with a source @@ -201,9 +213,9 @@ def test_each_call_is_picked_by_its_subcommand_wherever_it_sits(before): @RUNS @pytest.mark.parametrize( "error", [NO_GIT, NOT_EXECUTABLE, CANNOT_EXECUTE, NO_FILE_NAMED, - NOT_EXECUTABLE_NO_FILE_NAMED], + NOT_EXECUTABLE_NO_FILE_NAMED, WIN_ERROR_193], ids=["no-git", "git-not-executable", "git-cannot-execute", - "no-file-named", "not-executable-no-file-named"]) + "no-file-named", "not-executable-no-file-named", "winerror-193"]) def test_no_git_at_all_is_one_refusal_in_every_run( validator, project, monkeypatch, capsys, extra, error): """A SCAFFOLDED ROOT AND NO GIT: the first check to run git is the first @@ -321,11 +333,11 @@ def test_with_git_running_the_same_root_says_pins_ok( #: will not start: the directory git was to run in, which `subprocess` names #: in `filename` where it names `git` for a program (here the leg's submodule, #: the directory two of the three calls run in, as a submodule nobody may -#: enter), and errors that name no file and are neither of the two classes -#: that Windows' `CreateProcess` raises for a git it cannot start (an `ENOEXEC` +#: enter), and errors that name no file and are none of the three that +#: Windows' `CreateProcess` raises for a git it cannot start (an `ENOEXEC` #: with no name is the one a reader might expect to count, and does not: the -#: nameless exceptions are those two classes only). Built in the test, which -#: knows the paths. +#: nameless errors that count are those two classes and `WIN_ERROR_193`, which +#: carries its `winerror`). Built in the test, which knows the paths. NOT_GIT = { "unreadable-directory": lambda root: PermissionError( errno.EACCES, "Permission denied", str(root / "spec")),