diff --git a/templates/family-root/scripts/bootstrap.py b/templates/family-root/scripts/bootstrap.py index 72bfe1d4..bb70a87c 100755 --- a/templates/family-root/scripts/bootstrap.py +++ b/templates/family-root/scripts/bootstrap.py @@ -124,6 +124,16 @@ Where (a) cannot start git, the run STOPS there, exit 2, refused `git-not-runnable` as `validate-family.py` refuses it (#694): each member's own bootstrap runs git too, so (b) would only say it again. + And where (a) or (b) cannot ask whether a row's member is checked + out, or whether it has a `Makefile`, because the operating system + refused the `stat` of its `.git` or its `Makefile` (a `members/` this + user cannot search), the run STOPS there, exit 2, refused + `member-unreadable`, naming the path and the cure (#430). On Python + 3.14 that member read as NOT CHECKED OUT, or as having no Makefile, + and was skipped, and the run said `family bootstrap ok`; before it, + the run ended in a `PermissionError` traceback. A `family.yaml` whose + `stat` is refused is `family-manifest-unreadable`, never + `family-manifest-missing`. THE FAMILY CONFERS NOTHING and neither does this command. It fetches, it delegates, and it says what it could not reach. @@ -132,13 +142,16 @@ from __future__ import annotations import argparse +import errno import os import re import shlex import shutil +import stat import subprocess import sys from collections import Counter +from collections.abc import Callable from pathlib import Path from typing import NamedTuple @@ -216,6 +229,102 @@ #: HTTPS one for the duration of a single command. REWRITE_TARGETS = ("https://github.com/", "git@github.com:") +#: The `OSError`s that `Path.is_file()` and `Path.exists()` answered False to +#: on Python 3.9 to 3.13 (pathlib's `_ignore_error`), because each means there +#: is nothing at the name to open: no such file, a part of the path that is +#: not a directory, a bad descriptor, a symlink loop, and Windows' "not +#: ready", "invalid name" and "cannot resolve". Every other one was raised. +_ABSENT_ERRNOS = (errno.ENOENT, errno.ENOTDIR, errno.EBADF, errno.ELOOP) +_ABSENT_WINERRORS = (21, 123, 1921) + +#: What a path the operating system refused for ANOTHER reason than +#: permission is told: an I/O error, a name longer than the filesystem +#: takes, a file replaced while this ran. No `chmod` puts one right. +#: `validate-pins.py`'s words for the same refusal (#712, #430). +OTHER_REASON = ( + "Where the operating system gave another reason, put a regular file this " + "user can open back in its place, or take away one nobody wants, and make " + "sure nothing is changing the directory while this runs. ") + + +def _is_a(path: Path, kind: Callable[[int], bool]) -> bool: + """Whether `path` is there and of `kind` (`stat.S_ISREG`, `_anything`), + raising the `OSError` of a `stat` the operating system REFUSED (#430). + + NOT `Path.is_file()` OR `Path.exists()`, whose answer depends on the + Python. Up to 3.13 they raised a refused `stat` (`PermissionError`), and + `make bootstrap` ended in that traceback; from 3.14 they answer False to + ANY `OSError`. On 3.14 a member mounted under a `members/` this user + cannot search was NOT CHECKED OUT, skipped, beside the advice to set a + credential, and one whose `Makefile` could not be reached had none, and + the run said `family bootstrap ok` over members nothing ran in. Asked + here, every Python answers as 3.9 to 3.13 did, and a refused `stat` is a + refusal that names the path (`unreadable`). A name the system cannot + take at all (a NUL in a row's `path:`) is no file, as `exists()` + answered it. This file's own copy, as each pinned copy keeps one (#370, + #610): `bump-leg.py` has the same. + """ + try: + mode = path.stat().st_mode + except OSError as exc: + if (exc.errno in _ABSENT_ERRNOS + or getattr(exc, "winerror", None) in _ABSENT_WINERRORS): + return False + raise + except ValueError: + return False + return kind(mode) + + +def _anything(mode: int) -> bool: + """Any kind of file at all: what `Path.exists()` asks of a `.git`, + which is a file in a mounted member and a folder in a clone (#430).""" + return stat.S_IFMT(mode) != 0 + + +def unreadable(code: str, named: str, exc: OSError, what: str) -> Refusal: + """`code`'s refusal of a path this command must ask about and the + operating system will not let it: its `stat` refused (`_is_a`), as in a + folder this user cannot search (#430). The line is `validate-pins.py`'s + `_cannot_read`, `: cannot be read ()`, with + `named` relative to the holder and spelled; `what` says what was not + done, and why the question matters. THE ADVICE FOLLOWS THE CAUSE, in + `validate-pins.py`'s `_unreadable_file`'s words, word for word, since + every path this command asks about is inside the holder: a permission + (`PermissionError`, which is `EACCES` and `EPERM`) is put right by the + mode or the owner, and any other cause is `OTHER_REASON`, which is not + told to `chmod`.""" + advice = ( + "Make what this user may not open readable by this user: `chmod u+r` " + "on a file, `chmod u+rx` on a directory (and on every directory above " + "it inside the root), or fix who owns it. " + if isinstance(exc, PermissionError) else OTHER_REASON) + return Refusal( + code, + f"{named}: cannot be read ({exc.strerror or type(exc).__name__})", + f"Remediation: {what} A path this command cannot read is a question " + f"it cannot answer, never a pass. {advice}Then run the same command " + "again.") + + +def checked_out(root: Path, row: dict) -> bool: + """Whether the row's member is checked out: its mount holds a `.git`, + asked as `_is_a` asks it, or the `member-unreadable` refusal naming that + `.git` where its `stat` was refused (#430). (a) asks it of every row + (`unfetchable`) and (b) before it runs anything in the member + (`run_member_target`); on Python 3.14 the answer was "not checked out", + and the member was skipped.""" + git_dir = member_path(root, row) / ".git" + try: + return _is_a(git_dir, _anything) + except OSError as exc: + raise unreadable( + "member-unreadable", spelled(f"{row_path(row)}/.git"), exc, + "Each row's mount is asked whether git checked its member out " + "before anything is run in it, and this one could not be asked, " + "so the run stopped there: no member's command was run after " + "it.") from exc + def credential() -> tuple[str, str]: """`(source, token)`; `("none", "")` when the environment carries none.""" @@ -244,7 +353,9 @@ def fetch_members(root: Path, rows: list[dict]) -> list[str]: fetched only where there is none and git exited 0. The advice to set a credential is printed only where git asked for one (`credential_advice`, #839). Raises the `git-not-runnable` refusal - where git cannot be started (#694).""" + where git cannot be started (#694), and `member-unreadable` where a + row's mount cannot be asked about, after git's own words (`unfetched`, + #430).""" source, token = credential() args = ["git"] if token: @@ -263,7 +374,7 @@ def fetch_members(root: Path, rows: list[dict]) -> list[str]: if refusal is None: raise raise refusal from exc - left, stopper, modules = unfetchable(root, rows, proc.returncode == 0) + left, stopper, modules = unfetched(root, rows, proc, source, token) if proc.returncode != 0: fetch_incomplete(proc, source, token, credential_advice(bool(left), stopper, modules)) @@ -287,6 +398,24 @@ def fetch_members(root: Path, rows: list[dict]) -> list[str]: return [] +def unfetched(root: Path, rows: list[dict], + proc: subprocess.CompletedProcess, source: str, token: str + ) -> tuple[list[tuple[dict, str, str]], str | None, + Gitmodules]: + """`unfetchable`, asked after git's update `proc` (#430). Where it + refuses a mount it cannot ask about (`member-unreadable`) and git did + not exit 0, git's own words for the update are printed first + (`fetch_incomplete`), with no advice: what git said of that mount is + the same fact, the refusal after it names the cure, and no credential + is it. It refused before they were printed, and they were lost.""" + try: + return unfetchable(root, rows, proc.returncode == 0) + except Refusal: + if proc.returncode != 0: + fetch_incomplete(proc, source, token, "") + raise + + def fetch_incomplete(proc: subprocess.CompletedProcess, source: str, token: str, advice: str) -> None: """(a)'s report of a `git submodule update` that exited non-zero: git's @@ -919,10 +1048,11 @@ def unfetchable(root: Path, rows: list[dict], exited_0: bool same, and git's init asked where it stopped, for `fetch_members` to ask why git updated none (`updated_none`): this returned before `init_stop` there, so a run whose update git's init stopped said - `family bootstrap ok` (#879).""" + `family bootstrap ok` (#879). A row whose mount cannot be asked is + refused, `member-unreadable` (`checked_out`, #430).""" left: dict[str, dict] = {} for row in rows: - if not (member_path(root, row) / ".git").exists(): + if not checked_out(root, row): left.setdefault(row_path(row), row) if not left and exited_0: return [], None, Gitmodules(Counter(), {}, {}) @@ -982,8 +1112,21 @@ def updated_none(stopper: str | None, modules: Gitmodules) -> str | None: def members_of(root: Path) -> tuple[str, list[dict]]: + """`(name:, the member rows)` out of `family.yaml`, or a refusal. + + A `family.yaml` whose `stat` the operating system refused is + `family-manifest-unreadable`, naming it and the cure (#430), never + `family-manifest-missing`, which Python 3.14 said of it, whose remedy + sends a person to the wrong kind of root.""" manifest_path = root / MANIFEST - if not manifest_path.is_file(): + try: + present = _is_a(manifest_path, stat.S_ISREG) + except OSError as exc: + raise unreadable( + "family-manifest-unreadable", MANIFEST, exc, + f"{MANIFEST} declares this holder's members, and which they are " + "cannot be told without it.") from exc + if not present: raise Refusal( "family-manifest-missing", f"{manifest_path} does not exist, so there is no declaration of " @@ -1035,11 +1178,20 @@ def run_member_target(root: Path, row: dict, target: str) -> str | None: project = spelled(row.get("project") or "?") path = member_path(root, row) named = spelled(target) - if not (path / ".git").exists(): + if not checked_out(root, row): print(f" [{project}] {spelled(path.name)}: NOT CHECKED OUT; skipped. " "See the fetch above.") return None - if not (path / "Makefile").is_file(): + try: + has_makefile = _is_a(path / "Makefile", stat.S_ISREG) + except OSError as exc: + # One it cannot reach is no "no Makefile" (#430), which Python 3.14 + # said of it, and `family bootstrap ok` after. + raise unreadable( + "member-unreadable", spelled(f"{row_path(row)}/Makefile"), exc, + f"`make {named}` was not run in {project}, nor in any member " + "after it.") from exc + if not has_makefile: print(f" [{project}] no Makefile; skipped. A member without one is " "not misconfigured — it simply has nothing for this command to " "call.") @@ -1148,6 +1300,29 @@ def said(self, line: bytes) -> None: self.code = int(code) +def run_members(root: Path, rows: list[dict], target: str, + skip: bool) -> list[str]: + """(b) Each member's own `make `, in row order: the failure lines + (`run_member_target`), or none where `skip` (`--skip-members`) or no row + is declared. Raises `member-unreadable` at the first member whose mount + or `Makefile` cannot be asked about (#430), and no member after it is + run: `main` refuses the run there, exit 2.""" + print(f"\n(b) each member's own `make {spelled(target)}`") + if skip: + print(" skipped (--skip-members)") + return [] + if not rows: + print(" no members declared. A family with none is empty, not wrong: " + "`family.py add` is what puts one here.") + return [] + failed = [] + for row in rows: + failure = run_member_target(root, row, target) + if failure: + failed.append(failure) + return failed + + def run_make(make: str, target: str, cwd: Path) -> tuple[int, int | None]: """(b) `make ` in a member, as `(make's exit code, the exit code of the member's command it names)`, the second None where make names @@ -1316,26 +1491,20 @@ def main(argv: list[str] | None = None) -> int: print("\n(a) members and their legs") failed: list[str] = [] - if args.no_fetch: - print(" skipped (--no-fetch)") - else: - try: + try: + if args.no_fetch: + print(" skipped (--no-fetch)") + else: failed += fetch_members(root, rows) - except Refusal as exc: - print(str(exc), file=sys.stderr) - return 2 - - print(f"\n(b) each member's own `make {spelled(args.members_make)}`") - if args.skip_members: - print(" skipped (--skip-members)") - elif not rows: - print(" no members declared. A family with none is empty, not wrong: " - "`family.py add` is what puts one here.") - else: - for row in rows: - failure = run_member_target(root, row, args.members_make) - if failure: - failed.append(failure) + failed += run_members(root, rows, args.members_make, + args.skip_members) + except Refusal as exc: + # `git-not-runnable` from (a) (#694), or `member-unreadable` from + # (a) or (b) (#430). Stdout first, so a log of both streams reads + # in the order the run went. + sys.stdout.flush() + print(str(exc), file=sys.stderr) + return 2 print() if failed: diff --git a/templates/family-root/scripts/siblings.py b/templates/family-root/scripts/siblings.py index 3e61f983..d3171f80 100755 --- a/templates/family-root/scripts/siblings.py +++ b/templates/family-root/scripts/siblings.py @@ -156,19 +156,27 @@ a `make resume` that skipped every member because nobody had run `make siblings` must never read as success 2 a REFUSAL: the question could not be asked — no `family.yaml`, no - `git`, no `make` for a `--make` run. An unanswerable question is never - an implicit pass. + `git`, no `make` for a `--make` run, or a path of a member's that this + user cannot read (`member-unreadable`, #430): the pinned copy's + `project.yaml` or `.git`, or the working clone's `project.yaml`, + `scripts/bootstrap.py` or `Makefile`, whose `stat` the operating + system refused, as it does in a folder this user cannot search. That + member is refused by name, with the cure, and the others are still + tried. An unanswerable question is never an implicit pass. """ from __future__ import annotations import argparse +import errno import os import re import shlex import shutil +import stat import subprocess import sys +from collections.abc import Callable from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) @@ -239,6 +247,89 @@ GIT_INT = re.compile(r"([+-]?)(0[xX][0-9a-fA-F]+|0[0-7]*|[1-9][0-9]*)" r"([kKmMgG]?)") +#: The `OSError`s that `Path.is_file()` and `Path.exists()` answered False to +#: on Python 3.9 to 3.13 (pathlib's `_ignore_error`), because each means there +#: is nothing at the name to open: no such file, a part of the path that is +#: not a directory, a bad descriptor, a symlink loop, and Windows' "not +#: ready", "invalid name" and "cannot resolve". Every other one was raised. +_ABSENT_ERRNOS = (errno.ENOENT, errno.ENOTDIR, errno.EBADF, errno.ELOOP) +_ABSENT_WINERRORS = (21, 123, 1921) + +#: What a path the operating system refused for ANOTHER reason than +#: permission is told: an I/O error, a name longer than the filesystem +#: takes, a file replaced while this ran. No `chmod` puts one right. +#: `validate-pins.py`'s words for the same refusal (#712, #430). +OTHER_REASON = ( + "Where the operating system gave another reason, put a regular file this " + "user can open back in its place, or take away one nobody wants, and make " + "sure nothing is changing the directory while this runs. ") + + +def _is_a(path: Path, kind: Callable[[int], bool]) -> bool: + """Whether `path` is there and of `kind` (`stat.S_ISREG`, `_anything`), + raising the `OSError` of a `stat` the operating system REFUSED (#430). + + NOT `Path.is_file()` OR `Path.exists()`, whose answer depends on the + Python. Up to 3.13 they raised a refused `stat` (`PermissionError`), and + `make siblings` ended in that traceback; from 3.14 they answer False to + ANY `OSError`. On 3.14 a pinned copy under a `members/` this + user cannot search was "not checked out", and the member's working + clone was made on its remote's default branch, not its tracking branch; + a clone's `project.yaml` that could not be reached was no id, so the + clone was fetched into, or a verb run in it, unidentified; and a + `scripts/bootstrap.py` or `Makefile` that could not be reached was none, + so nothing ran there. Asked here, every Python answers as 3.9 to 3.13 + did, and a refused `stat` is `member_unreadable`'s refusal, naming the + path. A name the system cannot take at all (a NUL in a row's `path:`) + is no file, as `exists()` answered it. This file's own copy, as each + pinned copy keeps one (#370, #610): `bump-leg.py` has the same. + """ + try: + mode = path.stat().st_mode + except OSError as exc: + if (exc.errno in _ABSENT_ERRNOS + or getattr(exc, "winerror", None) in _ABSENT_WINERRORS): + return False + raise + except ValueError: + return False + return kind(mode) + + +def _anything(mode: int) -> bool: + """Any kind of file at all: what `Path.exists()` asks of a `.git`, + which is a folder in a clone and a file in a mounted member (#430).""" + return stat.S_IFMT(mode) != 0 + + +def member_unreadable(named: str, exc: OSError, what: str, + then: str = "Then run the same command again." + ) -> Refusal: + """`member-unreadable`: `: cannot be read ()`, `validate-pins.py`'s `_cannot_read` line (#430), for a path + of a member's that this command must ask about and the operating system + will not let it: its `stat` refused (`_is_a`), as in a folder this user + cannot search. + + `named` is the path as this run names it, spelled: relative to the + holder where it is in it (`members//project.yaml`), and whole + where it is the working clone beside it. `what` says what was not done + for the member, and why the question matters; `then` is the step after + the cure. THE ADVICE FOLLOWS THE CAUSE, in `validate-pins.py`'s words: a + permission (`PermissionError`, which is `EACCES` and `EPERM`) is put + right by the mode or the owner, and any other cause is `OTHER_REASON`, + which is not told to `chmod`.""" + advice = ( + "Make what this user may not open readable by this user: `chmod u+r` " + "on a file, `chmod u+rx` on a directory (and on every directory above " + "it), or fix who owns it. " + if isinstance(exc, PermissionError) else OTHER_REASON) + return Refusal( + "member-unreadable", + f"{named}: cannot be read ({exc.strerror or type(exc).__name__})", + f"Remediation: {what} A path this command cannot read is a question " + f"it cannot answer, never a pass. {advice}{then}") + # --------------------------------------------------------------------------- # where a member comes from @@ -443,11 +534,27 @@ def tracking_branch(root: Path, row: dict) -> tuple[str | None, str]: print. An ESC or a newline would reach this command's output raw, where the member's own bootstrap escapes the same field (#391); `repr` escapes exactly what `isprintable` refuses, so the two agree with no import. + + A COPY THAT CANNOT BE ASKED ABOUT IS REFUSED, NOT "NOT CHECKED OUT" + (#430): where the `stat` of its `project.yaml` is refused, as under a + `members/` this user cannot search, this raises + `member-unreadable`, and the member is not cloned. On Python 3.14 the + copy read as not checked out and the clone was made on the remote's + default branch, which is the wrong one wherever the two differ. """ label = (str(row.get("path") or f"members/{row.get('project')}") + "/project.yaml") manifest = member_path(root, row) / "project.yaml" - if not manifest.is_file(): + try: + checked_out = _is_a(manifest, stat.S_ISREG) + except OSError as exc: + raise member_unreadable( + spelled(label), exc, + "`make siblings` reads the member's pinned copy for the " + "tracking branch its working clone is made on, so nothing was " + "cloned for it: a clone on the remote's default branch is on " + "the wrong one wherever the two differ.") from exc + if not checked_out: return None, (f"{label} is not checked out, so its tracking branch " "is not known here (`make bootstrap` checks it out)") try: @@ -556,6 +663,9 @@ def __init__(self, project: str, path: Path): self.state = "?" self.branch = "-" self.finding: str | None = None + #: A path of this member's the system would not let this command + #: read (`member-unreadable`, #430): the run then exits 2. + self.refusal: Refusal | None = None def row(self, kind: str = "sibling") -> str: # `project` is a manifest's text and `branch` a clone's (#454), so a @@ -565,9 +675,47 @@ def row(self, kind: str = "sibling") -> str: f"{spelled(self.branch)}") +def refused_member(root: Path, row: dict, refusal: Refusal) -> Sibling: + """The row of a member that `place` or `dispatch` refused, for a path of + its own this command cannot read (`member-unreadable`, #430). The + members after it are still placed, or their verb run: a refusal never + stops the ones after it, and `make park` exists to save work.""" + project = str(row.get("project") or "?") + sibling = Sibling(project, root.parent / project) + sibling.state = "CANNOT BE READ" + sibling.refusal = refusal + return sibling + + +def said_findings_and_refusals(findings: list[str], + refusals: list[Refusal]) -> None: + """Each FINDING, then each refusal whole, its remedy included (#430), + on stderr: the lines before a run's summary.""" + for finding in findings: + print(f"FINDING {finding}", file=sys.stderr) + for refusal in refusals: + print(str(refusal), file=sys.stderr) + + def project_id(path: Path) -> str | None: + """The `id:` of the clone at `path`, or None where it has no + `project.yaml`, or one that does not load. + + ONE IT CANNOT REACH IS REFUSED (#430): where the `stat` of `project.yaml` + is refused, this raises `member-unreadable`, since the id is how + `verify_sibling` tells this member's clone from another's. On Python + 3.14 it was None, so the clone was fetched into, or a verb run in it, + with its id never read.""" manifest = path / "project.yaml" - if not manifest.is_file(): + try: + present = _is_a(manifest, stat.S_ISREG) + except OSError as exc: + raise member_unreadable( + spelled(manifest), exc, + "This command never fetches into, or runs a verb in, a clone it " + "cannot identify, and the clone's own `project.yaml` is how it " + "is identified: nothing was fetched or run there.") from exc + if not present: return None try: data = load_yaml(manifest) @@ -679,10 +827,27 @@ def run_member_bootstrap(path: Path, sibling: Sibling) -> None: Not reimplemented here for the same reason the family's `bootstrap.py` does not reimplement it: each member is a whole project and knows how. + + A SCRIPT THAT CANNOT BE REACHED IS REFUSED, NOT "NONE" (#430): where its + `stat` is refused this raises `member-unreadable`, whose remedy says the + clone is there and how to run its bootstrap, since a run of `make + siblings` after it only fetches. On Python 3.14 it was "no + scripts/bootstrap.py", and the run said `siblings ok`. """ script = path / "scripts" / "bootstrap.py" project = spelled(sibling.project) - if not script.is_file(): + try: + present = _is_a(script, stat.S_ISREG) + except OSError as exc: + raise member_unreadable( + spelled(script), exc, + f"The clone of {project} at {spelled(path)} was made, and its " + "own bootstrap, which puts its legs on their branches, was not " + "run.", + f"Then run `{PYTHON} scripts/bootstrap.py` in that clone: a " + "second `make siblings` only fetches a clone that is " + "there.") from exc + if not present: print(f" [{project}] no scripts/bootstrap.py; cloned and " "left as it is. A member without one is not misconfigured — it " "simply has nothing for this command to call.") @@ -993,7 +1158,16 @@ def dispatch(root: Path, row: dict, urls: dict[str, str], target: str, if not verify_sibling(row, path, url, repository, sibling): return sibling - if not (path / "Makefile").is_file(): + makefile = path / "Makefile" + try: + present = _is_a(makefile, stat.S_ISREG) + except OSError as exc: + # One it cannot reach is no "no Makefile" (#430), which Python 3.14 + # said of it: the member is refused by name, and the others run. + raise member_unreadable( + spelled(makefile), exc, + f"`{spelling}` was not run in {at}.") from exc + if not present: sibling.state = "SKIPPED no Makefile" sibling.finding = ( f"{who}: {at} has no Makefile, so there is no `{spelling}` " @@ -1073,8 +1247,11 @@ def dispatch_all(root: Path, name: str, rows: list[dict], print(" no members declared. A family with none is empty, not wrong: " "`family.py add` is what puts one here.") for row in rows: - dispatched.append(dispatch(root, row, urls, target, make_args, make, - dry_run)) + try: + dispatched.append(dispatch(root, row, urls, target, make_args, + make, dry_run)) + except Refusal as exc: + dispatched.append(refused_member(root, row, exc)) print("\n(b) what ran") print(f" {'what':<9} {'name':<18} {'state':<22} branch") @@ -1082,16 +1259,16 @@ def dispatch_all(root: Path, name: str, rows: list[dict], print(sibling.row()) findings = [s.finding for s in dispatched if s.finding] + refusals = [s.refusal for s in dispatched if s.refusal] print() sys.stdout.flush() - if findings: - for finding in findings: - print(f"FINDING {finding}", file=sys.stderr) + if findings or refusals: + said_findings_and_refusals(findings, refusals) print(f"siblings: `make {target}` did not run everywhere — " - f"{len(dispatched) - len(findings)} of {len(dispatched)} " - "member(s) ran and exited 0. Nothing was cloned or fetched.", - file=sys.stderr) - return 1 + f"{len(dispatched) - len(findings) - len(refusals)} of " + f"{len(dispatched)} member(s) ran and exited 0. Nothing was " + "cloned or fetched.", file=sys.stderr) + return 2 if refusals else 1 if dry_run: print(f"--dry-run: nothing was run. `make {target}` would run in " f"{len(dispatched)} member(s).") @@ -1363,18 +1540,33 @@ def _place_all_members(root: Path, rows: list[dict], urls: dict[str, str], """Every member, placed, in the order `family.yaml` lists them. Split from `main` for #137 — "(a) the members, beside the holder". + A member `place` refuses, for a path of its own this command cannot + read, is that member's row, and the next is placed (#430). """ if not rows: print(" no members declared. A family with none is empty, not wrong: " "`family.py add` is what puts one here.") - return [place(root, row, urls, prefix, token, dry_run) for row in rows] + placed: list[Sibling] = [] + for row in rows: + try: + placed.append(place(root, row, urls, prefix, token, dry_run)) + except Refusal as exc: + placed.append(refused_member(root, row, exc)) + return placed -def _print_pinned_row(root: Path, row: dict) -> None: +def _print_pinned_row(root: Path, row: dict) -> Sibling: """One `members/` row of the layout table: the PINNED copy, - detached, that `make bootstrap` and `make validate` read. + detached, that `make bootstrap` and `make validate` read. Returns it, + so a refusal on it reaches the report. Split from `main` for #137 — the body of its second layout-table loop. + + A MOUNT THAT CANNOT BE ASKED ABOUT IS REFUSED, NOT "NOT CHECKED OUT" + (#430): where the `stat` of its `.git` is refused, as in a `members/` + this user cannot search, the row says `CANNOT BE READ` and the run is + refused `member-unreadable`. On Python 3.14 it said `not checked out`, + `make bootstrap`, of a checked-out copy, and the run said `siblings ok`. """ mounted = member_path(root, row) # The row's OWN `path:` as the label, never a path computed back out @@ -1382,18 +1574,30 @@ def _print_pinned_row(root: Path, row: dict) -> None: # under this root, and a table is not the place to discover it. pinned = Sibling(str(row.get("path") or f"members/{row.get('project')}"), mounted) - if (mounted / ".git").exists(): - pinned.state = "pinned in the holder" - pinned.branch = branch_of(mounted) + try: + checked_out = _is_a(mounted / ".git", _anything) + except OSError as exc: + pinned.state = "CANNOT BE READ" + pinned.refusal = member_unreadable( + spelled(f"{pinned.project}/.git"), exc, + "The pinned copy under members/ is what `make bootstrap` and " + "`make validate` read, and whether it is checked out could not " + "be asked.") else: - pinned.state = "not checked out" - pinned.branch = "make bootstrap" + if checked_out: + pinned.state = "pinned in the holder" + pinned.branch = branch_of(mounted) + else: + pinned.state = "not checked out" + pinned.branch = "make bootstrap" print(pinned.row("pinned")) + return pinned def _print_layout_table(root: Path, siblings: list[Sibling], - rows: list[dict]) -> None: + rows: list[dict]) -> list[Sibling]: """"(b) the layout": the holder, every sibling, then every PINNED mount. + Returns the pinned rows, for the report (#430). Split from `main` for #137. """ @@ -1408,24 +1612,30 @@ def _print_layout_table(root: Path, siblings: list[Sibling], # THE SECOND COPY, said out loud in the same table: the pinned mount is # what `make bootstrap` and `make validate` read, and it is detached # because a pin is a commit and not a branch. - for row in rows: - _print_pinned_row(root, row) + return [_print_pinned_row(root, row) for row in rows] -def _report_placement(root: Path, siblings: list[Sibling], - dry_run: bool) -> int: +def _report_placement(root: Path, siblings: list[Sibling], dry_run: bool, + pinned: list[Sibling] | None = None) -> int: """The findings (or the ok / dry-run line), and the exit code that matches. Split from `main` for #137. + + A path of a member's this command cannot read, a sibling's or a pinned + copy's (`pinned`), is a refusal and the run exits 2 (#430). """ findings = [s.finding for s in siblings if s.finding] + refusals = [s.refusal for s in [*siblings, *(pinned or [])] + if s.refusal] print() sys.stdout.flush() - if findings: - for finding in findings: - print(f"FINDING {finding}", file=sys.stderr) - print(f"siblings: {len(findings)} finding(s); nothing was moved and " + if findings or refusals: + said_findings_and_refusals(findings, refusals) + counted = [f"{len(findings)} finding(s)"] if findings else [] + if refusals: + counted.append(f"{len(refusals)} path(s) this user cannot read") + print(f"siblings: {' and '.join(counted)}; nothing was moved and " "no existing clone was touched", file=sys.stderr) - return 1 + return 2 if refusals else 1 if dry_run: print("--dry-run: nothing was cloned, fetched or moved.") return 0 @@ -1491,9 +1701,9 @@ def _run_placement(root: Path, name: str, rows: list[dict], manifest: dict, f"{source})") siblings = _place_all_members(root, rows, urls, prefix, token, dry_run) - _print_layout_table(root, siblings, rows) + pinned = _print_layout_table(root, siblings, rows) - return _report_placement(root, siblings, dry_run) + return _report_placement(root, siblings, dry_run, pinned) def main(argv: list[str] | None = None) -> int: diff --git a/tests/test_family_siblings_bootstrap_unreadable.py b/tests/test_family_siblings_bootstrap_unreadable.py new file mode 100644 index 00000000..94ab18cd --- /dev/null +++ b/tests/test_family_siblings_bootstrap_unreadable.py @@ -0,0 +1,663 @@ +# SPDX-License-Identifier: Apache-2.0 +"""The family root's `scripts/siblings.py` and `scripts/bootstrap.py`: a +member's path this user cannot read is refused by name, never read as +absent (#430, slice F of lane 1's plan). + +THE DEFECT. Each tool asked `Path.is_file()` or `Path.exists()` of a +member's path. Up to Python 3.13 those raise a refused `stat` +(`PermissionError`), so a `members/` this user cannot search ended +`make siblings` and `make bootstrap` in that traceback; from 3.14 they +answer False to ANY `OSError`, and the path read as not there: + +- `siblings.py` `tracking_branch`: the pinned copy's `project.yaml` was "not + checked out", and the working clone was made on the remote's DEFAULT + branch, not the member's tracking branch, `siblings ok`; +- `project_id`: a clone's `project.yaml` was no id, so the clone was + fetched into, or `make park` run in it, with its id never read; +- `run_member_bootstrap`: the member's `scripts/bootstrap.py` was "none", so + its legs were never placed, `siblings ok`; +- `dispatch`: the clone's `Makefile` was "no Makefile"; +- `_print_pinned_row`: a checked-out pinned copy was `not checked out`, + `make bootstrap`; +- `bootstrap.py` `unfetchable` and `run_member_target`: the member was NOT + CHECKED OUT, beside the advice to set a credential, or had no Makefile, + skipped, and the run said `family bootstrap ok`; +- `members_of`: `family.yaml` "does not exist" (`family-manifest-missing`). + +Each is now asked by `stat`ing the path (each file's own `_is_a`, as +`bump-leg.py`'s): nothing at the name (`ENOENT`, `ENOTDIR`, ...) is the +absent case it always was, and any other `OSError` is refused, +`member-unreadable` (`family-manifest-unreadable` for `family.yaml`), +`: cannot be read ()`, exit 2, with `chmod` for a permission +and `validate-pins.py`'s `OTHER_REASON` for any other cause. + +EACH SITE HAS THREE ROWS: nothing there (the control, unchanged); a refused +`stat` as Python 3.14 answers it, in process, on every platform and as root +(`refused_stat`); and a real mode, where one can be made (`needs_a_mode`: +not on Windows, not as root). A mode on `members/` reaches the +pinned copy, its `.git` and its `project.yaml` through the command itself; +a working clone's `Makefile`, a member's `Makefile` and `family.yaml` are +reached by a link into a folder at mode 000, the only way to refuse their +`stat` while the folder they are in, and its `.git`, can still be read. +""" + +from __future__ import annotations + +import errno +import os +import shutil +import stat +import subprocess +from pathlib import Path + +import pytest + +from test_bump_leg_unopenable import ( + made_unopenable, needs_a_mode, refusal_of, refused_stat, +) +from test_doctor_unreadable_pin import IO_ERROR, refusing +from test_family import ( # noqa: F401 + NAME, allow_file_protocol, estate, family, holder, siblings, +) +from test_family_bootstrap_gate_order import bootstrap +from test_family_gitmodules_values import by_path +from test_family_siblings_placement import placement # noqa: F401 + +DENIED = "Permission denied" +#: The two codes this slice refuses with. +MEMBER_UNREADABLE = "member-unreadable" +MANIFEST_UNREADABLE = "family-manifest-unreadable" +#: A row whose member is nowhere: the control's absent case. +NOBODY = {"project": "Nobody", "id": "nobody", "path": "members/Nobody"} +#: A row whose path runs through a file (`ENOTDIR`): absent too. +THROUGH_A_FILE = {"project": "Nobody", "id": "nobody", + "path": "family.yaml/Nobody"} +ABSENT_ROWS = pytest.mark.parametrize( + "row", [NOBODY, THROUGH_A_FILE], + ids=["no-such-path", "a-file-on-the-path"]) +#: The words a permission's remedy and any other cause's begin with: +#: siblings.py's, whose clones sit outside the holder, and bootstrap.py's, +#: `validate-pins.py`'s `_unreadable_file` word for word. +CHMOD = "`chmod u+rx` on a directory (and on every directory above it)" +CHMOD_IN_ROOT = ("`chmod u+rx` on a directory (and on every directory above " + "it inside the root)") +OTHER_CAUSE = "Where the operating system gave another reason" +#: The summary `make siblings` ends on where it refused a path (#430). +SUMMARY_TAIL = "; nothing was moved and no existing clone was touched" +#: The `stat` errnos that mean nothing is there, Windows' errors that mean +#: the same ("not ready", "invalid name", "cannot resolve"), and every +#: other errno this platform names. +ABSENT_ERRNOS = [errno.ENOENT, errno.ENOTDIR, errno.EBADF, errno.ELOOP] +ABSENT_WINERRORS = [21, 123, 1921] +OTHER_ERRNOS = sorted(set(errno.errorcode) - set(ABSENT_ERRNOS)) +BOTH_FILES = pytest.mark.parametrize("which", ["siblings", "bootstrap"]) + + +@pytest.fixture(scope="module") +def boot(): + """The template's `bootstrap.py`, loaded by path, no bytecode.""" + return by_path("bootstrap.py", "unreadable_family_bootstrap") + + +def row_of(boot, root: Path, project: str) -> dict: + """`project`'s row of the holder's `family.yaml`, as bootstrap reads it.""" + _, rows = boot.members_of(root) + return next(row for row in rows if row.get("project") == project) + + +def refusal_line(code: str, named: str, reason: str = DENIED) -> str: + return f"REFUSED {code}: {named}: cannot be read ({reason})" + + +def no_traceback(result) -> None: + assert "Traceback" not in result.stderr, result.stderr + + +def flat_rows(stdout: str) -> list[str]: + """Each line of a table, its runs of blanks made one.""" + return [" ".join(line.split()) for line in stdout.splitlines()] + + +def linked_into_locked(directory: Path, name: str) -> str: + """`directory/name` made a link to `directory/locked/name`, its bytes + moved there: once `locked` is at mode 000 the `stat` of `name` is + refused while `directory` and its `.git` can still be read. Returns + `locked`, relative to `directory`, for `made_unopenable`.""" + locked = directory / "locked" + locked.mkdir() + (directory / name).rename(locked / name) + (directory / name).symlink_to(Path("locked") / name) + return "locked" + + +@pytest.fixture(scope="module") +def placed_folder(family, tmp_path_factory) -> Path: + """`/InkRouter/` with the holder and both members placed beside it + by the holder's own `make siblings`, once for the module.""" + folder = tmp_path_factory.mktemp("placed") / NAME + folder.mkdir() + shutil.copytree(family["root"], folder / NAME, symlinks=True) + result = siblings(folder / NAME) + assert result.returncode == 0, result.stdout + result.stderr + return folder + + +@pytest.fixture +def placed(placed_folder, tmp_path) -> Path: + """A private copy of `placed_folder`; returns its holder.""" + folder = tmp_path / NAME + shutil.copytree(placed_folder, folder, symlinks=True) + return folder / NAME + + +# --- siblings.py: `tracking_branch`, the pinned copy's project.yaml ---------- + +@ABSENT_ROWS +def test_a_pinned_copy_not_there_keeps_the_default_branch(placement, family, + row): + """The control: no pinned copy is the default branch it always was.""" + assert placement.tracking_branch(family["root"], row) == ( + None, f"{row['path']}/project.yaml is not checked out, so its " + "tracking branch is not known here (`make bootstrap` checks " + "it out)") + + +def test_a_pinned_copy_whose_stat_is_refused_is_refused(placement, family, + monkeypatch): + """On every Python: not the default branch, but `member-unreadable`, + naming the copy's `project.yaml` relative to the holder.""" + row = row_of(placement, family["root"], "IRSS") + refused_stat(monkeypatch, "project.yaml") + refusal = refusal_of(placement, placement.tracking_branch, + family["root"], row) + assert refusal.code == MEMBER_UNREADABLE + assert refusal.detail == (f"members/IRSS/project.yaml: cannot be read " + f"({DENIED})") + assert "nothing was cloned for it" in refusal.remediation + assert CHMOD in refusal.remediation + + +@needs_a_mode +@pytest.mark.parametrize("mode", [0o000, 0o444], ids=["000", "444"]) +def test_make_siblings_clones_no_member_whose_pinned_copy_it_cannot_search( + estate, mode): + """`members/IRSS` at 000 or 444, nothing beside the holder yet: 3.12 + ended in a traceback after IRRS was cloned, and 3.14 cloned IRSS on + its remote's default branch with `siblings ok`. Now IRSS is refused by + name, nothing is cloned for it, IRRS is still placed, exit 2.""" + holder = estate["holder"] + with made_unopenable(holder, "members/IRSS", mode=mode): + result = siblings(holder) + no_traceback(result) + assert result.returncode == 2, result.stdout + result.stderr + assert refusal_line(MEMBER_UNREADABLE, "members/IRSS/project.yaml") \ + in result.stderr + assert "sibling IRSS CANNOT BE READ -" in flat_rows(result.stdout) + assert not (estate["folder"] / "IRSS").exists() + assert (estate["folder"] / "IRRS" / ".git").is_dir() + assert "default branch" not in result.stdout + + +# --- siblings.py: `project_id`, a clone's own project.yaml ------------------- + +def test_a_clone_with_no_project_yaml_has_no_id(placement, tmp_path): + """The control: no `project.yaml` is no id, as it always was.""" + assert placement.project_id(tmp_path) is None + + +def test_a_clone_whose_project_yaml_stat_is_refused_is_refused( + placement, family, monkeypatch): + """On every Python: no "no id", which let the clone be fetched into, or + a verb run in it, with its id never read.""" + clone = family["root"] / "members" / "IRSS" + refused_stat(monkeypatch, "project.yaml") + refusal = refusal_of(placement, placement.project_id, clone) + assert refusal.code == MEMBER_UNREADABLE + assert refusal.detail == (f"{clone / 'project.yaml'}: cannot be read " + f"({DENIED})") + assert "nothing was fetched or run there" in refusal.remediation + + +@needs_a_mode +def test_a_clone_it_cannot_search_is_refused_not_unidentified(placement, + holder): + """A real mode, in process: the clone's folder at 000.""" + clone = holder / "members" / "IRSS" + with made_unopenable(holder, "members/IRSS"): + refusal = refusal_of(placement, placement.project_id, clone) + assert refusal.detail == (f"{clone / 'project.yaml'}: cannot be read " + f"({DENIED})") + + +@pytest.mark.skipif(shutil.which("make") is None, + reason="needs `make` on PATH") +def test_make_park_refuses_every_clone_it_cannot_identify_and_goes_on( + placement, placed_folder, monkeypatch, capsys): + """Through `dispatch_all`, on every Python: each member is refused on + its own row, the one after it is still tried, and the run exits 2.""" + holder = placed_folder / NAME + _, rows = placement.members_of(holder) + refused_stat(monkeypatch, "project.yaml") + code = placement.dispatch_all(holder, NAME, rows, "park", [], True) + said = capsys.readouterr() + assert code == 2, said.out + said.err + table = flat_rows(said.out) + for project in ("IRRS", "IRSS"): + assert f"sibling {project} CANNOT BE READ -" in table, said.out + assert said.err.count(f"REFUSED {MEMBER_UNREADABLE}: ") == 2, said.err + assert "0 of 2 member(s) ran" in said.err + + +# --- siblings.py: `run_member_bootstrap`, the clone's scripts/bootstrap.py --- + +def test_a_clone_with_no_bootstrap_is_left_as_it_is(placement, tmp_path, + capsys): + """The control: a member with no `scripts/bootstrap.py` is cloned and + left as it is, no finding.""" + sibling = placement.Sibling("Nobody", tmp_path) + placement.run_member_bootstrap(tmp_path, sibling) + assert "[Nobody] no scripts/bootstrap.py; cloned and left as it is." \ + in capsys.readouterr().out + assert sibling.finding is None + + +def test_a_bootstrap_whose_stat_is_refused_is_refused_not_skipped( + placement, family, monkeypatch): + """On every Python: no "no scripts/bootstrap.py" and `siblings ok`. The + remedy says the clone is there and how to run its bootstrap, since a + second `make siblings` only fetches it.""" + clone = family["root"] / "members" / "IRSS" + refused_stat(monkeypatch, "bootstrap.py") + refusal = refusal_of(placement, placement.run_member_bootstrap, clone, + placement.Sibling("IRSS", clone)) + assert refusal.code == MEMBER_UNREADABLE + assert refusal.detail == (f"{clone / 'scripts' / 'bootstrap.py'}: cannot " + f"be read ({DENIED})") + assert (f"Then run `{placement.PYTHON} scripts/bootstrap.py` in that " + "clone") in refusal.remediation + assert "Then run the same command again" not in refusal.remediation + + +@needs_a_mode +def test_a_bootstrap_it_cannot_search_for_is_refused(placement, holder): + """A real mode, in process: the clone's `scripts/` at 000.""" + clone = holder / "members" / "IRSS" + with made_unopenable(clone, "scripts"): + refusal = refusal_of(placement, placement.run_member_bootstrap, + clone, placement.Sibling("IRSS", clone)) + assert refusal.detail == (f"{clone / 'scripts' / 'bootstrap.py'}: cannot " + f"be read ({DENIED})") + + +# --- siblings.py: `dispatch`, the clone's Makefile --------------------------- + +def dispatched(placement, holder: Path, project: str): + """`dispatch` of `make park --dry-run` for `project`, in process.""" + row = row_of(placement, holder, project) + return placement.dispatch(holder, row, placement.submodule_urls(holder), + "park", [], "make", True) + + +def test_a_clone_with_no_makefile_is_still_skipped_by_name(placement, placed): + """The control: a clone with no `Makefile` is `SKIPPED no Makefile`.""" + sibling_dir = placed.parent / "IRSS" + (sibling_dir / "Makefile").rename(sibling_dir / "Makefile.aside") + sibling = dispatched(placement, placed, "IRSS") + assert sibling.state == "SKIPPED no Makefile" + assert "has no Makefile" in sibling.finding + + +def test_a_makefile_whose_stat_is_refused_is_refused_not_absent( + placement, placed_folder, monkeypatch): + """On every Python: no "has no Makefile".""" + holder = placed_folder / NAME + refused_stat(monkeypatch, "Makefile") + refusal = refusal_of(placement, dispatched, placement, holder, "IRSS") + assert refusal.code == MEMBER_UNREADABLE + assert refusal.detail == (f"{placed_folder / 'IRSS' / 'Makefile'}: cannot " + f"be read ({DENIED})") + assert "`make park` was not run in" in refusal.remediation + + +@needs_a_mode +def test_make_park_refuses_a_makefile_it_cannot_reach_and_runs_the_rest( + placed): + """A real mode: IRSS's `Makefile` a link into a folder at 000. 3.12 + ended `make park` in a traceback after IRRS, and 3.14 said IRSS `has no + Makefile`. Now IRSS is refused by name, IRRS still runs, exit 2.""" + clone = placed.parent / "IRSS" + with made_unopenable(clone, linked_into_locked(clone, "Makefile")): + result = siblings(placed, "--make", "park", "--dry-run") + no_traceback(result) + assert result.returncode == 2, result.stdout + result.stderr + named = placed.resolve().parent / "IRSS" / "Makefile" + assert refusal_line(MEMBER_UNREADABLE, str(named)) in result.stderr + assert "sibling IRRS would make park main" in flat_rows(result.stdout) + assert "has no Makefile" not in result.stderr + + +# --- siblings.py: `_print_pinned_row`, the pinned copy's .git -------------- + +def test_a_pinned_copy_not_there_is_still_not_checked_out(placement, + family, capsys): + """The control: no mount is `not checked out`, `make bootstrap`.""" + placement._print_pinned_row(family["root"], NOBODY) + assert "pinned members/Nobody not checked out make bootstrap" in \ + flat_rows(capsys.readouterr().out) + + +def test_a_pinned_mount_whose_stat_is_refused_is_refused(placement, family, + monkeypatch, capsys): + """On every Python: no `not checked out` of a checked-out copy.""" + row = row_of(placement, family["root"], "IRSS") + refused_stat(monkeypatch, ".git") + pinned = placement._print_pinned_row(family["root"], row) + assert pinned.state == "CANNOT BE READ" + assert pinned.refusal.detail == (f"members/IRSS/.git: cannot be read " + f"({DENIED})") + assert "pinned members/IRSS CANNOT BE READ -" in flat_rows( + capsys.readouterr().out) + + +@needs_a_mode +def test_make_siblings_refuses_a_pinned_copy_beside_a_placed_clone(placed): + """A real mode, the members placed already: `members/IRSS` at 000. 3.12 + ended in a traceback at the layout table, and 3.14 said `not checked + out`, `make bootstrap` and `siblings ok`. Now the pinned row is + refused, the clones are still fetched, exit 2.""" + with made_unopenable(placed, "members/IRSS"): + result = siblings(placed) + no_traceback(result) + assert result.returncode == 2, result.stdout + result.stderr + assert refusal_line(MEMBER_UNREADABLE, "members/IRSS/.git") \ + in result.stderr + assert "pinned members/IRSS CANNOT BE READ -" in flat_rows(result.stdout) + assert "sibling IRSS present, fetched main" in flat_rows(result.stdout) + assert "siblings ok" not in result.stdout + assert result.stderr.splitlines()[-1] == ( + f"siblings: 1 path(s) this user cannot read{SUMMARY_TAIL}") + + +@pytest.mark.parametrize("findings, refusals, code, counted", [ + (1, 0, 1, "1 finding(s)"), + (0, 2, 2, "2 path(s) this user cannot read"), + (1, 2, 2, "1 finding(s) and 2 path(s) this user cannot read"), +], ids=["a-finding", "two-refusals", "both"]) +def test_the_placement_summary_counts_what_it_could_not_read( + placement, tmp_path, capsys, findings, refusals, code, counted): + """`_report_placement`'s last line counts the findings, as it did, and + the paths it could not read, a sibling's or a pinned copy's, and the + run exits 2 where there is any.""" + rows = [] + for number in range(findings): + row = placement.Sibling(f"F{number}", tmp_path) + row.finding = f"F{number}: a finding" + rows.append(row) + pinned = [] + for number in range(refusals): + row = placement.Sibling(f"members/R{number}", tmp_path) + row.refusal = placement.member_unreadable( + f"members/R{number}/.git", + PermissionError(errno.EACCES, DENIED), "What was not done.") + pinned.append(row) + assert placement._report_placement(tmp_path, rows, False, pinned) == code + assert capsys.readouterr().err.splitlines()[-1] == ( + f"siblings: {counted}{SUMMARY_TAIL}") + + +# --- bootstrap.py: `checked_out`, in `unfetchable` and `run_member_target` - + +@ABSENT_ROWS +def test_a_member_not_there_is_still_not_checked_out(boot, family, row, + capsys): + """The control: no mount, or a path through a file, is the member NOT + CHECKED OUT it always was, in (a) and in (b).""" + left, _, _ = boot.unfetchable(family["root"], [row], True) + assert left == [(row, row["path"], boot.NO_GITLINK)] + assert boot.run_member_target(family["root"], row, "help") is None + assert "[Nobody] Nobody: NOT CHECKED OUT; skipped." in \ + capsys.readouterr().out + + +@pytest.mark.parametrize("ask", ["unfetchable", "run_member_target"]) +def test_a_mount_whose_stat_is_refused_is_refused(boot, family, monkeypatch, + ask): + """On every Python, in (a) and in (b): no NOT CHECKED OUT, skipped.""" + row = row_of(boot, family["root"], "IRRS") + refused_stat(monkeypatch, ".git") + args = ([row], True) if ask == "unfetchable" else (row, "help") + refusal = refusal_of(boot, getattr(boot, ask), family["root"], *args) + assert refusal.code == MEMBER_UNREADABLE + assert refusal.detail == f"members/IRRS/.git: cannot be read ({DENIED})" + assert "the run stopped there" in refusal.remediation + assert CHMOD_IN_ROOT in refusal.remediation + + +@pytest.mark.parametrize("exit_code", [1, 0], ids=["git-failed", "git-ok"]) +def test_gits_own_words_come_before_a_mount_it_refuses( + boot, family, monkeypatch, capsys, exit_code): + """Where (a) refuses a mount and git's update did not exit 0, git's own + words are printed first, with no credential advice, then the refusal; + they were lost. Where git exited 0 there is nothing of git's to say. + In process, with git's update stood in for, on every platform.""" + _, rows = boot.members_of(family["root"]) + real = subprocess.run + + def git_update(args, *more, **kwargs): + if "submodule" in args: + return subprocess.CompletedProcess( + args, exit_code, "", "fatal: the update stopped here\n") + return real(args, *more, **kwargs) + + for name in boot.CREDENTIAL_ENV: + monkeypatch.delenv(name, raising=False) + monkeypatch.setattr(boot.subprocess, "run", git_update) + refused_stat(monkeypatch, ".git") + refusal = refusal_of(boot, boot.fetch_members, family["root"], rows) + said = capsys.readouterr().err + assert refusal.detail == f"members/IRRS/.git: cannot be read ({DENIED})" + assert said == (" FETCH INCOMPLETE (git exit 1) using credential " + "source 'none':\nfatal: the update stopped here\n" + if exit_code else "") + + +@needs_a_mode +@pytest.mark.parametrize("relative, mode, named", [ + ("members/IRSS", 0o000, "members/IRSS/.git"), + ("members", 0o444, "members/IRRS/.git"), +], ids=["a-mount-at-000", "members-at-444"]) +def test_make_bootstrap_refuses_a_mount_it_cannot_search(holder, relative, + mode, named): + """A real mode: 3.12 ended (a) in a traceback, and 3.14 said NOT + CHECKED OUT, set SHAPE_LEGS_TOKEN, and `family bootstrap ok`. Now (a) + refuses the mount by name, no member's `make` is run, exit 2.""" + with made_unopenable(holder, relative, mode=mode): + result = bootstrap(holder, allow_file_protocol()) + no_traceback(result) + assert result.returncode == 2, result.stdout + result.stderr + refused = refusal_line(MEMBER_UNREADABLE, named) + assert refused in result.stderr + assert "family bootstrap ok" not in result.stdout + assert ": make bootstrap ---" not in result.stdout + assert CHMOD_IN_ROOT in result.stderr + assert "SHAPE_LEGS_TOKEN" not in result.stderr + fetch = result.stderr.find(" FETCH INCOMPLETE (git exit ") + assert -1 < fetch < result.stderr.index(refused), result.stderr + + +@needs_a_mode +def test_make_validates_member_half_refuses_a_mount_it_cannot_search(holder): + """`make validate`'s second step, `--no-fetch`: (b) runs IRRS's target, + then refuses IRSS's mount by name, exit 2, where 3.14 skipped it and + said `family bootstrap ok`.""" + with made_unopenable(holder, "members/IRSS"): + result = bootstrap(holder, allow_file_protocol(), "--no-fetch", + "--members-make", "help") + no_traceback(result) + assert result.returncode == 2, result.stdout + result.stderr + assert "--- IRRS: make help ---" in result.stdout + assert refusal_line(MEMBER_UNREADABLE, "members/IRSS/.git") \ + in result.stderr + + +# --- bootstrap.py: `run_member_target`, the member's Makefile ---------------- + +def test_a_member_with_no_makefile_is_still_skipped(boot, tmp_path, capsys): + """The control: a mount with a `.git` and no `Makefile` is skipped.""" + (tmp_path / "members" / "Ghost").mkdir(parents=True) + (tmp_path / "members" / "Ghost" / ".git").write_bytes(b"gitdir: x\n") + row = {"project": "Ghost", "path": "members/Ghost"} + assert boot.run_member_target(tmp_path, row, "help") is None + assert "[Ghost] no Makefile; skipped." in capsys.readouterr().out + + +def test_a_member_makefile_whose_stat_is_refused_is_refused(boot, family, + monkeypatch): + """On every Python: no "no Makefile; skipped".""" + row = row_of(boot, family["root"], "IRSS") + refused_stat(monkeypatch, "Makefile") + refusal = refusal_of(boot, boot.run_member_target, family["root"], row, + "help") + assert refusal.code == MEMBER_UNREADABLE + assert refusal.detail == (f"members/IRSS/Makefile: cannot be read " + f"({DENIED})") + assert "`make help` was not run in IRSS" in refusal.remediation + + +@needs_a_mode +def test_make_bootstrap_refuses_a_member_makefile_it_cannot_reach(holder): + """A real mode: IRRS's `Makefile` a link into a folder at 000. 3.12 + ended in a traceback, and 3.14 said `no Makefile; skipped` and + `family bootstrap ok`.""" + member = holder / "members" / "IRRS" + with made_unopenable(member, linked_into_locked(member, "Makefile")): + result = bootstrap(holder, allow_file_protocol(), "--no-fetch", + "--members-make", "help") + no_traceback(result) + assert result.returncode == 2, result.stdout + result.stderr + assert refusal_line(MEMBER_UNREADABLE, "members/IRRS/Makefile") \ + in result.stderr + assert "--- IRRS: make help ---" not in result.stdout + + +# --- bootstrap.py: `members_of`, family.yaml -------------------------------- + +def test_a_holder_with_no_family_yaml_is_still_missing(boot, tmp_path): + """The control: no `family.yaml` is `family-manifest-missing`.""" + refusal = refusal_of(boot, boot.members_of, tmp_path) + assert refusal.code == "family-manifest-missing" + + +def test_a_family_yaml_whose_stat_is_refused_is_not_called_missing( + boot, family, monkeypatch): + """On every Python: `family-manifest-unreadable`, never `does not + exist` and its remedy for another kind of root.""" + refused_stat(monkeypatch, "family.yaml") + refusal = refusal_of(boot, boot.members_of, family["root"]) + assert refusal.code == MANIFEST_UNREADABLE + assert refusal.detail == f"family.yaml: cannot be read ({DENIED})" + assert "`chmod u+r` on a file" in refusal.remediation + + +@needs_a_mode +@pytest.mark.parametrize("script", ["bootstrap.py", "siblings.py"]) +def test_both_scripts_refuse_a_family_yaml_they_cannot_reach(holder, script): + """A real mode: `family.yaml` a link into a folder at 000. Both scripts + read it through `members_of`, and both refuse it by name, exit 2.""" + with made_unopenable(holder, linked_into_locked(holder, "family.yaml")): + result = bootstrap(holder, allow_file_protocol()) \ + if script == "bootstrap.py" else siblings(holder) + no_traceback(result) + assert result.returncode == 2, result.stdout + result.stderr + assert refusal_line(MANIFEST_UNREADABLE, "family.yaml") in result.stderr + assert "family-manifest-missing" not in result.stderr + + +# --- the cause: a permission is told `chmod`, any other cause is not --------- + +@pytest.mark.parametrize("which", ["siblings", "bootstrap"]) +def test_a_stat_that_fails_for_another_reason_is_refused_sans_chmod( + placement, boot, family, monkeypatch, which): + """An I/O error is no permission: the same refusal, the system's own + reason on the line, and `validate-pins.py`'s `OTHER_REASON`, with no + `chmod`, in each file's own copy of the words.""" + module = placement if which == "siblings" else boot + pinned = row_of(boot, family["root"], "IRSS") + refusing(monkeypatch, "stat", ".git", OSError(errno.EIO, IO_ERROR)) + if which == "siblings": + refusal = placement._print_pinned_row(family["root"], pinned).refusal + else: + refusal = refusal_of(boot, boot.checked_out, family["root"], pinned) + assert refusal.detail == (f"members/IRSS/.git: cannot be read " + f"({IO_ERROR})") + assert OTHER_CAUSE in refusal.remediation + assert "chmod" not in refusal.remediation + assert module.OTHER_REASON in refusal.remediation + + +@BOTH_FILES +@pytest.mark.parametrize("code", ABSENT_ERRNOS, + ids=[errno.errorcode[code] for code in ABSENT_ERRNOS]) +def test_each_errno_of_nothing_there_is_absent(placement, boot, monkeypatch, + which, code): + """Each file's own `_is_a` answers as `bump-leg.py`'s does: a `stat` + that says nothing is at the name is False, as `is_file()` said it.""" + module = placement if which == "siblings" else boot + assert tuple(ABSENT_ERRNOS) == module._ABSENT_ERRNOS + refusing(monkeypatch, "stat", "probe", OSError(code, os.strerror(code))) + assert module._is_a(Path("probe"), stat.S_ISREG) is False + + +@BOTH_FILES +@pytest.mark.parametrize("winerror", ABSENT_WINERRORS) +def test_each_windows_error_of_nothing_there_is_absent( + placement, boot, monkeypatch, which, winerror): + """So is a `stat` whose Windows error says nothing is there, whatever + its errno: here `EINVAL`, which is raised without one. In process, on + every platform.""" + module = placement if which == "siblings" else boot + assert tuple(ABSENT_WINERRORS) == module._ABSENT_WINERRORS + real = Path.stat + + def refused(self, *args, **kwargs): + if self.name != "probe": + return real(self, *args, **kwargs) + error = OSError(errno.EINVAL, os.strerror(errno.EINVAL), str(self)) + error.winerror = winerror + raise error + + monkeypatch.setattr(Path, "stat", refused) + assert module._is_a(Path("probe"), stat.S_ISREG) is False + + +@BOTH_FILES +def test_every_other_errno_is_raised(placement, boot, monkeypatch, which): + """And a `stat` refused with any other errno this platform names + (`EACCES`, `EPERM`, `EIO`, `ENAMETOOLONG` and `EINVAL` among them) is + raised, for the caller to refuse by name.""" + module = placement if which == "siblings" else boot + raised = [] + for code in OTHER_ERRNOS: + refusing(monkeypatch, "stat", "probe", + OSError(code, os.strerror(code))) + try: + module._is_a(Path("probe"), stat.S_ISREG) + except OSError as exc: + raised.append(exc.errno) + assert raised == OTHER_ERRNOS + + +@BOTH_FILES +def test_a_name_the_system_cannot_take_is_no_file(placement, boot, family, + which): + """A NUL in a name is a `ValueError` the system raises before any + `stat`, and `_is_a` answers False, as `exists()` did: a row whose + `path:` holds one reads as no member there, never a traceback.""" + module = placement if which == "siblings" else boot + nul = chr(0) + assert module._is_a(Path(f"a{nul}b"), stat.S_ISREG) is False + row = {"project": "IRSS", "path": f"members/IR{nul}SS"} + if which == "siblings": + assert placement.tracking_branch(family["root"], row)[0] is None + else: + assert boot.checked_out(family["root"], row) is False