diff --git a/docs/cli.md b/docs/cli.md index 1c02bb97..96c12492 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1115,7 +1115,8 @@ options: the caller's words for what would let LINK's tree be read, offered in place of this script's own where that link is declared-unverified. Repeatable; the last - value for a link wins. + value for a link wins, and a value no holder used is + noted (with --link-source-label, it is not). --explain print every family each name satisfies, why one of them won, and what the others are recorded as --quiet diff --git a/scripts/validate-repository-naming.py b/scripts/validate-repository-naming.py index b24b29c1..f5c764d0 100755 --- a/scripts/validate-repository-naming.py +++ b/scripts/validate-repository-naming.py @@ -74,12 +74,19 @@ where the chains begin at more than one link, keep this script's own flag. `--link-remedy LINK=TEXT` is what would let LINK's tree be read in that caller's sources, offered in place of this script's own for a link that is -declared-unverified. A project's own runs pass neither. Every `--link-source` -value and path this prints, and the path of a `SHAPE_PIN_SOURCE_` -or of a checkout beside the project it names, is named as it is, or as -Python's `repr` spells it where a character in it is not printable -(`_shown`). A caller's FLAG and the words it offers for a link, a chain -link's name, and the `--project` and `--policy` paths are printed as given. +declared-unverified. LINK is the longest text before an `=` that names a +link of a chain this run reads and leaves words after it (#907), so a link +holding `=` keeps it, and the words are stripped. A `--link-source` key is +not read so: it is the text before the value's first `=`, so a link holding +`=` is never one. A `--link-remedy` no holder used is a NOTE saying why, +except where the caller gave a label: its words are its own, not the +person's. A project's own runs pass neither. +Every `--link-source` and `--link-remedy` value and path this prints, and +the path of a `SHAPE_PIN_SOURCE_` or of a checkout beside the +project it names, is named as it is, or as Python's `repr` spells it where a +character in it is not printable (`_shown`). A caller's FLAG and the words +it offers for a link, a chain link's name, and the `--project` and +`--policy` paths are printed as given. EXIT CODES, the same three in every validator this standard ships: 0 every name classified (and, with `--project`, every declared role and @@ -102,7 +109,7 @@ answers that validator's digest question too, from a checkout that need not carry a manifest, and a source that cannot answer is passed over for the next one in the order, which may. A `--link-source` no link used is a NOTE saying -why. +why, and so is a `--link-remedy` no holder used (#907). The 1/2 split is deliberate. "This name is wrong" and "I could not read the policy" are different facts and a gate that renders them identically invites a @@ -171,17 +178,19 @@ def _library_takes_remedies() -> bool: return False -def _link_remedy(value: str) -> tuple[str, str]: - """One `--link-remedy LINK=TEXT`: the link, by `repo_basename`, and - the words. The text before the first `=` is the link; a value with no - `=`, no link or no words is refused, exit 2.""" +def _link_remedy(value: str) -> str: + """One `--link-remedy LINK=TEXT`, as typed; a value with no `=`, no + link before its first `=` or no words after it is refused, exit 2. + WHICH `=` ENDS THE LINK is decided once the chains this run reads are + known (`LinkSources._read_remedy`, #907): the last one that leaves a + link of theirs before it and words after it. A `--link-source` key + ends at the value's first `=`, whatever the chains name.""" link, sep, text = value.partition("=") - link = repo_basename(link.strip()) - if not (sep and link and text.strip()): + if not (sep and repo_basename(link.strip()) and text.strip()): raise argparse.ArgumentTypeError( f"{_shown(value)} is not LINK=TEXT: name a chain link, then `=`, " "then the words for what would let its tree be read") - return link, text + return value class Target: @@ -271,18 +280,36 @@ def __init__(self, values: list[str], links, firsts=(), #: The caller's own name for the bare PATH it passes, or None #: (`--link-source-label`, #661). self.label = label - #: `{pin_product_key(link): the caller's words}` from - #: `--link-remedy`, the last value given for a link wins (#661). - self.remedies: dict[str, str] = { - pin_product_key(link): text for link, text in remedies} #: Every link of every chain this run reads, in its first spelling. self.links: list[str] = [] - names: dict[str, str] = {} + #: `{pin_product_key: first spelling}` of those links. + self.names: dict[str, str] = {} + names = self.names for link in links: name = repo_basename(str(link)) if name and pin_product_key(name) not in names: names[pin_product_key(name)] = name self.links.append(name) + #: `{pin_product_key(link): the caller's words}` from + #: `--link-remedy`, the last value given for a link wins (#661), + #: each read by `_read_remedy` (#907). + self.remedies: dict[str, str] = {} + #: Every distinct `--link-remedy` value in the order given, and the + #: link it was read as for. + self.remedy_reading: dict[str, str] = {} + #: `{typed: the later value for its link that took its place}`. + self.remedy_replaced: dict[str, str] = {} + last: dict[str, str] = {} + for typed in remedies: + link, words = self._read_remedy(typed) + key = pin_product_key(link) + self.remedy_reading[typed] = link + self.remedies[key] = words + earlier = last.get(key) + last[key] = typed + self.remedy_replaced.pop(typed, None) + if earlier is not None and earlier != typed: + self.remedy_replaced[earlier] = typed #: `{pin_product_key: first spelling}` of the first link of every #: chain a name here reads (#441). `link_pins` adds a chain's first #: link as it reads it, which matters only for names read from stdin: @@ -330,6 +357,32 @@ def __init__(self, values: list[str], links, firsts=(), #: or a checkout beside the project that could not answer. self.unanswered: dict[tuple, list[str]] = {} + def _read_remedy(self, typed: str) -> tuple[str, str]: + """`(link, words)` for one `--link-remedy` (#907): the text before + an `=` is a LINK only where it names a link of a referent chain this + run reads, by `pin_product_key`, and leaves words after it. The LAST + `=` that does so ends the link, so the longest link is read first + and a link holding `=` keeps it: setup's step (4) gives `open=y` its + words as `--link-remedy=open=y=`, and splitting at the first + `=` made them `y=` for a link `open` that no chain names. In + a chain naming `openXdox` and `openXdox=y` both, each is read for + its own words, and from the first `=` `openXdox=y=` was + `y=` for `openXdox`. Where no `=` does, the text before the + first one is the link, which no chain here names, and `unused_lines` + says so. A `--link-source` key is not read so: it is the text before + its first `=` alone (`__init__`). The link is spelled as the chain + spells it, and the words are stripped: `openXdox= padded` offered + ` padded`.""" + at = typed.rfind("=") + while at >= 0: + link = repo_basename(typed[:at].strip()) + words = typed[at + 1:].strip() + if words and pin_product_key(link) in self.names: + return self.names[pin_product_key(link)], words + at = typed.rfind("=", 0, at) + link, _sep, words = typed.partition("=") + return repo_basename(link.strip()), words.strip() + def link_pins(self, policy: NamingPolicy, target: Target) -> dict: """What the chain's links declare, for the links whose trees answer, with what each lookup asked recorded against the values given. @@ -487,7 +540,8 @@ def unused_lines(self) -> list[str]: name records a chain, none reads a link's declaration, or the one link a bare PATH answers for had a more specific source (`_not_asked_why`). A value holding `=` that was read as a PATH says - so too.""" + so too. Then a NOTE for each `--link-remedy` no holder used + (`_unused_remedy_lines`).""" lines = [] for typed, link in self.reading.items(): if typed in self.used or typed in self.unread: @@ -508,6 +562,39 @@ def unused_lines(self) -> list[str]: lines.append(" ".join(filter(None, ( f"{flag} {_shown(typed)}: not used — {why}.", self.read_as_path(typed))))) + return lines + self._unused_remedy_lines() + + def _unused_remedy_lines(self) -> list[str]: + """A NOTE for each `--link-remedy` no holder used (#907), named as + typed, with why: a later value for its link replaced it, no chain + here names its link, or no name classified here reads that link's + own declaration, so no lookup asked for the tree the words are + about. A holder that was asked used its words, whether its tree + answered or not: the words are offered only where it did not, and + that is the disk's answer, not a mistake in the value. + + NOTHING IS SAID UNDER A LABEL. A caller that passes + `--link-source-label` speaks for someone else (#661), and its + remedies are its own words: setup's step (4) passes one per holder + of the chain it hands over, and a NOTE would name `--link-remedy`, + a flag the person never typed and setup does not take.""" + if self.label: + return [] + lines = [] + for typed, link in self.remedy_reading.items(): + later = self.remedy_replaced.get(typed) + if later is None and pin_product_key(link) in self.asked: + continue + if later is not None: + why = f"the later `--link-remedy {_shown(later)}` replaced it" + elif not self.links: + why = "no name classified here records a referent chain" + elif pin_product_key(link) not in self.names: + why = (f"`{_shown(link)}` is not a link of a referent chain " + f"here (links: {', '.join(self.links)})") + else: + why = f"no name classified here reads {link}'s own declaration" + lines.append(f"--link-remedy {_shown(typed)}: not used — {why}.") return lines def _flag(self, link: str | None) -> str: @@ -1174,7 +1261,9 @@ def main(argv: list[str] | None = None) -> int: "tree be read, offered in place of this " "script's own where that link is " "declared-unverified. Repeatable; the last " - "value for a link wins.") + "value for a link wins, and a value no holder " + "used is noted (with --link-source-label, it " + "is not).") parser.add_argument("--explain", action="store_true", help="print every family each name satisfies, why one " "of them won, and what the others are recorded as") diff --git a/setup-project.py b/setup-project.py index 2c50210b..fcf133c9 100755 --- a/setup-project.py +++ b/setup-project.py @@ -351,7 +351,7 @@ def child_environment(capture: bool = False) -> dict: def run(argv, cwd=None, capture: bool = False, - stdin=None) -> subprocess.CompletedProcess: + stdin=None, env=None) -> subprocess.CompletedProcess: """One spelling of "run a program", with the output decoding pinned. `encoding="utf-8", errors="replace"`: a child's bytes are its own business @@ -368,6 +368,9 @@ def run(argv, cwd=None, capture: bool = False, (`plan_and_confirm`) and the plan run again, captured (`why_the_plan_did_not_go_through`), the scaffold (`scaffold`) and `scripts/bootstrap.py` (`clone_and_bootstrap`) - and the next one too. + A caller that hands `env` hands one built from it: the naming check's, + whose `SHAPE_PIN_SOURCE_` paths are joined to the standard's + checkout because it runs elsewhere (`pin_sources_joined`, #907). EVERY ARGV THAT CARRIES A VALUE FROM THE COMMAND LINE WAS CHECKED AT PARSE TIME. `argv` is a list and `shell=False`, so there is no shell to inject @@ -380,7 +383,9 @@ def run(argv, cwd=None, capture: bool = False, return subprocess.run(argv, cwd=str(cwd) if cwd is not None else None, capture_output=capture, text=True, encoding="utf-8", errors="replace", check=False, - stdin=stdin, env=child_environment(capture)) + stdin=stdin, + env=(child_environment(capture) if env is None + else env)) except OSError: # A PROGRAM THAT IS NOT THERE IS A FAILED RUN, not a traceback. # `is_shape_checkout()` asks git a question before the preflight has @@ -2394,30 +2399,61 @@ def passes_a_dry_run(passthrough: list) -> bool: for word in scaffold_words(passthrough)) -def as_a_bare_link_source(path: str) -> str: - """`path` as the naming validator reads a `--link-source` that names no - product: it takes the text before the first `=` for a product when that - is a link of the chain, so `openXdox=layer`, which the scaffold reads as - the one path it is, would be read as `layer` for `openXdox`. A path that - starts `=` is spelled `./=`; one with a separator or a drive - before its first `=` cannot be a link's name, and stays as it is. +def as_the_plan_reads_it(shape_root: Path, path: str) -> str: + """A `--pin-source` `path` as step (5) reads and names it: joined to the + standard's checkout, the folder the scaffold runs in, and made absolute + by `Path.resolve()`, as the scaffold's `_resolved_pin_source` makes it. + Where `resolve()` fails (a symlink loop, before Python 3.13) the + scaffold names the value as typed, and this leaves it joined and + unresolved: a spelling the two still differ in, because a path the + naming check is given has to be absolute. Python 3.9 for Windows may + give another (the comment below). + + ONE VALUE, ONE NAME (#907, U171). Step (4) gave the naming check the + value as typed, and the plan names the path it resolved, so + `--pin-source ./nowhere/` was `nowhere (--pin-source)` in step (4) and + `/nowhere` in step (5), and a blank one was a blank and + `/ `. Step (4) now names the path step (5) names. It has to be + absolute anyway: the naming check runs from an empty folder + (`check_names`), not from the checkout a relative path is read from. + + AND THE NAMING CHECK CANNOT READ IT AS `LINK=PATH`. It takes the text + before a `--link-source`'s first `=` for a product when that is a link + of the chain, so `openXdox=layer`, which the scaffold reads as the one + path it is, would be read as `layer` for `openXdox`. An absolute path + has a separator or a drive before its first `=`, which no link's name + holds (a link is read at its last `/`), so it is one path to the naming + check too, as `./openXdox=layer` was. AN EMPTY PATH IS NEVER GIVEN TO IT. Since #570 the scaffold takes `--pin-source` as text and refuses an exactly empty value as `pin-source-no-path`, before anything exists (`pin_source_path`), so it - no longer reads `''` as the directory it runs in: `.` would be a folder - the plan never reads, and `--link-source=` is refused by the validator as - `link-source-no-path` (#576). Either would end step (4) naming a flag the - person never typed, where the plan refuses the value by name. - `naming_declarations` leaves an empty source out. A BLANK path is a path: - the scaffold reads `Path(' ')`, so it goes as it is.""" - return "./" + path if re.match(r"[^/\\:=]+=", path) else path - - -def naming_declarations(passthrough: list, link_remedies=None) -> list: + no longer reads `''` as the directory it runs in: the standard's + checkout would be a folder the plan never reads, and `--link-source=` is + refused by the validator as `link-source-no-path` (#576). Either would + end step (4) naming a flag the person never typed, where the plan + refuses the value by name. `naming_declarations` leaves an empty source + out. A BLANK path is a path: the scaffold reads `Path(' ')`, which is + `/ ` once resolved, and so is this.""" + joined = Path(shape_root, path) + try: + # ON PYTHON 3.9 FOR WINDOWS `resolve()` MAY RETURN A RELATIVE PATH + # UNCHANGED where its first part does not exist (bpo-38671, fixed in + # 3.10), so the scaffold, which resolves the value as typed, may name + # `nowhere` there where this, resolving it joined, names + # `\nowhere`. Joined is what the naming check can read + # from the folder it runs in, so it stays joined. + return str(joined.resolve()) + except (OSError, RuntimeError): + return str(joined) + + +def naming_declarations(passthrough: list, shape_root: Path, + link_remedies=None) -> list: """The naming validator's arguments for what the passthrough declares about the project (#538): the products of its `--pin`s, and its - `--referent-chain`, with the tree its links are read from (#608). + `--referent-chain`, with the tree its links are read from (#608), named + as step (5) names it (`as_the_plan_reads_it`, from `shape_root`). A `` name is a descendant only when the project pins its referent, and the scaffold judges the names with the pins it is @@ -2463,7 +2499,12 @@ def naming_declarations(passthrough: list, link_remedies=None) -> list: `--link-remedy==`: what step (5) reads, said as step (5) says it. The chain is read as the scaffold reads it (`referent_chain_links`: split on commas, each stripped, an empty one - dropped). + dropped). A link holding `=` (`openXdox,open=y,openDox`, which step (5) + refuses as `chain-link-malformed`) is read whole by the naming check, + which ends a remedy's link at the last `=` that leaves a link of the + chain before it, the longest link first (#907, U170), so `open=y` is + offered step (5)'s words too, and not the naming check's `--link-source + open=y=`. """ products = [name for name in ( pin.strip().split("@", 1)[0].rsplit("/", 1)[-1] @@ -2477,7 +2518,8 @@ def naming_declarations(passthrough: list, link_remedies=None) -> list: spoken = link_remedies(chain) if has_links and link_remedies else None return ((["--pins=" + ",".join(products)] if products else []) + (["--referent-chain=" + chains[-1]] if chains else []) - + (["--link-source=" + as_a_bare_link_source(sources[-1])] + + (["--link-source=" + as_the_plan_reads_it(shape_root, + sources[-1])] if has_links and names_a_source else []) + (["--link-source-label=--pin-source"] + ["--link-remedy=%s=%s" % pair for pair in spoken.items()] @@ -2488,17 +2530,126 @@ def naming_declarations(passthrough: list, link_remedies=None) -> list: # 4. the names, before anything exists # --------------------------------------------------------------------------- +#: What step (4) says when it cannot make the empty folder the naming check +#: runs from (`check_names`): before anything exists, and with the way on. +NO_EMPTY_FOLDER = ("the naming check runs from an empty folder, and none " + "could be made in the temporary folder (%s), so nothing " + "was created. Point TMPDIR (TEMP on Windows) at a folder " + "you can write in, then run this again.") + +#: What step (4) says when the folder it made for the naming check cannot be +#: taken away again (`take_away_the_naming_folder`): a `[??]`, as the write +#: probe's is (`refuse_a_parent_nobody_can_write_in`), and the run goes on. +NAMING_FOLDER_LEFT = ("%s, the folder made in the temporary folder for the " + "naming check to run from, could not be taken away " + "again (%s); setup put nothing in it but an empty " + "folder, and it is yours to delete.") + +#: The variables a chain link's tree is read from, `SHAPE_PIN_SOURCE_`: +#: `scripts/repo_shape.py`'s `PIN_SOURCE_ENV_PREFIX`, which this file cannot +#: import (`child_environment` says why). +PIN_SOURCE_ENV_PREFIX = "SHAPE_PIN_SOURCE_" + + +def pin_sources_joined(shape_root: Path, env: dict) -> dict: + """`env` with each `SHAPE_PIN_SOURCE_` that names a path joined to + the standard's checkout (#907), for the naming check, which + `check_names` runs from an empty folder elsewhere. + + STEP (5) READS A RELATIVE ONE FROM THE STANDARD'S CHECKOUT, the folder + the scaffold runs in, and the naming check read it from the folder it + runs in. So `SHAPE_PIN_SOURCE_OPENYDOX=../yy`, a tree beside the + standard, verified `openYdox` in step (5) and was "no such file or + directory" in step (4), which offered the very variable that was set; + and where `yy` broke the chain, step (4) passed a name that step (5) + then refused as `chain-not-declared`. Joined, the two read one tree. + + `os.path.join` leaves an absolute path as it is, and gives a Windows + path that names no drive the drive of the standard's checkout, as the + scaffold's folder does. AN EMPTY VARIABLE STAYS EMPTY: neither step + reads one, and joined it would name the checkout itself. Every other + variable is handed on as it is.""" + return {name: (os.path.join(str(shape_root), value) + if name.startswith(PIN_SOURCE_ENV_PREFIX) and value + else value) + for name, value in env.items()} + + +def take_away_the_naming_folder(aside: str) -> None: + """`aside`, the folder `check_names` made in the temporary folder, and + the empty `here` it made in it for the naming check to run from, taken + away again BEST-EFFORT (#907, rv949 on #949). + + A FOLDER THAT CANNOT BE TAKEN AWAY IS A `[??]`, NEVER A TRACEBACK. On + Windows an indexer or an antivirus can hold a handle on a new folder for + a moment, as the write probe says (`refuse_a_parent_nobody_can_write_in`), + and `TemporaryDirectory`'s own clean-up raises `PermissionError` then, + out of step (4) and past `main`, which catches a `Refusal`. Its + `ignore_cleanup_errors=` is Python 3.10's, and this file runs on 3.9, + so the folder is made by `mkdtemp` and taken away here. A folder left + is named, and the run goes on: the names were judged, and the folder + is the person's to delete. A `here` that was never made (`os.mkdir` + refused it, and `check_names` says so) is not looked for. `rmdir`, not + `rmtree`: setup put nothing in either, and takes away nothing else.""" + here = os.path.join(aside, "here") + try: + if os.path.isdir(here): + os.rmdir(here) + os.rmdir(aside) + except OSError as exc: + warn(NAMING_FOLDER_LEFT + % (aside, exc.strerror or exc.__class__.__name__)) + + def check_names(shape_root: Path, project: str, family: str = "", declared=()) -> None: """The three names against the naming policy, with what the project declares about itself (`naming_declarations`) as the scaffold will - judge them.""" + judge them. + + FROM AN EMPTY FOLDER, NEVER FROM THE STANDARD'S CHECKOUT (#907, U061, + U169). For names on its command line the naming check takes the folder + it runs in as the project's root, and reads `/../` as a + chain link's tree before a bare `--link-source`. Run from the checkout, + a checkout of a link sitting beside the standard - or beside the + temporary clone self-bootstrap makes - answered for that link in step + (4) and never in step (5): the scaffold reads no checkout beside + anything. With `openXdox/` declaring `openDox` there, and `--pin-source` + a tree declaring `openYdox`, step (4) called the `--pin-source` tree not + used and the chain `openXdox -> openYdox -> openDox` broken, and step + (5) read that tree and called the name a descendant through it. So the + check runs in a folder made empty inside a temporary folder made for + it, which holds nothing else: an empty folder straight inside the + system's temporary folder would have that folder's other entries + beside it. Both are taken away again, best-effort, whatever happens + (`take_away_the_naming_folder`). + + SO NOTHING IT READS MAY DEPEND ON THAT FOLDER. Every path it is given is + absolute (`as_the_plan_reads_it`), every `SHAPE_PIN_SOURCE_` it + reads is joined to the standard's checkout, where step (5) reads it + (`pin_sources_joined`), and it finds its policy and its library beside + itself. It is given names, so it reads no stdin and no `--project`, and + it runs no git.""" say("") say("(4) naming policy") - proc = run([PYTHON, - str(shape_root / "scripts" / NAMING_VALIDATOR), *declared, - "--explain", project, project + "-spec", project + "-code"], - cwd=shape_root) + aside = None + try: + aside = tempfile.mkdtemp(prefix="openreposhape-naming-") + here = os.path.join(aside, "here") + os.mkdir(here) + except OSError as exc: + if aside is not None: + take_away_the_naming_folder(aside) + die(NO_EMPTY_FOLDER % (exc.strerror or exc.__class__.__name__)) + try: + proc = run([PYTHON, + str(shape_root / "scripts" / NAMING_VALIDATOR), + *declared, "--explain", project, project + "-spec", + project + "-code"], + cwd=here, + env=pin_sources_joined(shape_root, child_environment())) + finally: + take_away_the_naming_folder(aside) if proc.returncode != 0: die("the names do not satisfy the naming policy (see above). A naming " "mistake caught here costs a message; caught later it costs three " @@ -2754,8 +2905,22 @@ def finish_by_hand(parent: str, assembly: str, project: str, passes: the legs are cloned from the plain paths `.gitmodules` records, the file transport, which git refuses a submodule without it. The project name is a word already: step (4) held it to the naming policy. + + ON WINDOWS IT CARRIES `-c core.autocrlf=false` TOO (#926, U041), as + setup's own clone does there, so a clone finished by hand checks the + pinned copies out as that one does, LF whatever Git for Windows' + `core.autocrlf=true` default says: `contracts/shape-pin.yaml` records + digests of LF bytes. The project's own `.gitattributes` (#51) asks for + LF as well, and neither clone leans on it. Asked of `os.name` when the + lines are made, as the clone asks it, and not of the shell they are + pasted into: Git Bash runs the same git. """ - clone = "git -c protocol.file.allow=always clone" if local else "git clone" + clone = "git" + if local: + clone += " -c protocol.file.allow=always" + if os.name == "nt": + clone += " -c core.autocrlf=false" + clone += " clone" return (" cd %s\n" " %s --recurse-submodules %s\n" " cd %s && %s %s" @@ -3093,7 +3258,7 @@ def _main(argv, invocation_dir: str) -> int: clone = landing.clone_path(opts.project) refuse_a_taken_clone_path(clone, opts.project) check_names(shape_root, opts.project, opts.family, - naming_declarations(opts.passthrough, + naming_declarations(opts.passthrough, shape_root, scaffold_link_remedies(shape_root))) args = scaffold_args(opts, org) if not plan_and_confirm(opts, shape_root, org, args, clone): diff --git a/tests/conftest.py b/tests/conftest.py index c964dcd2..9d8c26a5 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2245,6 +2245,10 @@ def assert_a_chain_of_one_link_has_no_source_to_note(run, "three-links-no-source": ("", "openXdox,openYdox,openDox", None, False), "blank": (" ", "openXdox,openDox", None, False), "holds-an-equals": ("openXdox=layer", "openXdox,openDox", None, False), + "relative": ("./nowhere/", "openXdox,openDox", None, False), + "relative-to-the-parent": ("../nowhere", "openXdox,openDox", None, False), + "a-link-holding-an-equals": ("tree", "openXdox,open=y,openDox", "open=y", + False), "variable-first": ("tree", "openXdox,openDox", "openDox", True), } @@ -2265,7 +2269,17 @@ def assert_step_four_speaks_in_the_plans_words(run, tmp_path: Path, `=` is not said to have been read as a PATH. On main step (4) offered `--link-source =` and labelled the source `(--link-source)`; typed as printed, the offer ended step (5) in `unrecognized - arguments`.""" + arguments`. + + AND IT NAMES THE `--pin-source` STEP (5) NAMES (#907, U171): the value + joined to the standard's checkout, this one, and resolved. Step (4) + named it as typed, `nowhere (--pin-source)` for `./nowhere/`, and step + (5) `/nowhere`; a blank value was a blank and `/ `. + `../nowhere` is named `/nowhere` in both, which + tells a resolved path from one only joined, `/../nowhere`: + pathlib folds `./nowhere/` to `/nowhere` either way (rv949 on + #949). A chain link holding `=` is offered step (5)'s words too (U170). + """ clear_ambient_pin_sources(monkeypatch) source, chain, declares, variable = STEP_FOUR_WORDS[case] commit = "0123456789abcdef" * 2 + "01234567" @@ -2309,16 +2323,32 @@ def assert_step_four_speaks_in_the_plans_words(run, tmp_path: Path, f"{LATER_LINK_WORDS}). The recorded chain still " "classifies."] assert "REFUSED pin-source-no-path:" in result.stderr + elif case == "a-link-holding-an-equals": + # U170 (#907): the naming check reads the link `open=y` whole, so it + # is offered step (5)'s words; split at its first `=` they were + # words for a link `open`, and `open=y` was offered the naming + # check's own `--link-source open=y=`. Step (5) refuses the + # link, as it refuses any that is not a neutral product's name. + assert offered == [ + "WARNING declared-unverified: open=y declaring a pin on openDox " + "is a fact in open=y's own tree, which is not reachable here " + "(SHAPE_PIN_SOURCE_OPEN_Y would let it be read). The recorded " + "chain still classifies."], step_four + assert "REFUSED chain-link-malformed:" in result.stderr, result.stderr elif case == "variable-first": assert "[verified]" in step_four, step_four - assert (f"NOTE --pin-source {source}: not used — each link whose own " - "declaration is read had a more specific source " - "(SHAPE_PIN_SOURCE_, or a checkout beside the " - "project).") in result.stderr, result.stderr + assert (f"NOTE --pin-source {Path(REPO, source).resolve()}: not used " + "— each link whose own declaration is read had a more " + "specific source (SHAPE_PIN_SOURCE_, or a checkout " + "beside the project).") in result.stderr, result.stderr else: - assert (f"WARNING link-source-unreadable: {source} (--pin-source) " + named = Path(REPO, source).resolve() + assert (f"WARNING link-source-unreadable: {named} (--pin-source) " "could not answer for openXdox: no such file or directory" ) in result.stderr, result.stderr + assert (f"REFUSED pin-source-unreadable: {named} could not answer for " + f"opensoft/openXdox @ {commit}: no such directory" + ) in result.stderr, result.stderr assert "read as a PATH" not in result.stderr, result.stderr assert offered, step_four assert offered[0].endswith( diff --git a/tests/test_chain_remedy_callers.py b/tests/test_chain_remedy_callers.py index 343ee7a9..43fe937d 100644 --- a/tests/test_chain_remedy_callers.py +++ b/tests/test_chain_remedy_callers.py @@ -16,6 +16,12 @@ `tests/conftest.py` (`assert_step_four_speaks_in_the_plans_words`). * L2-69. The gate named `--link-source` values and paths raw, so an ESC reached the terminal. One row per print, each with an ESC. + * #907. `--link-remedy` split its value at the first `=`, so a link + holding one lost its words to a link no chain names (U170), or to a + shorter link a chain names too (rv949 on #949); it kept the + blanks around its words, and a remedy no holder used was dropped with + no NOTE (U173). And no row gave a labelled run a KEYED `--link-source`, + so naming that by the label too passed every row (U174). No network and no GitHub. """ @@ -208,6 +214,7 @@ def test_the_gate_offers_each_holder_the_callers_words(tmp_path, monkeypatch): UNREACHED.format(holder="openYdox", held="openInk", offer="LATER would do")] assert "--link-source" not in result.stderr + assert "NOTE" not in result.stderr, result.stderr def test_a_holder_given_no_words_keeps_the_gates_own(tmp_path, monkeypatch): @@ -284,6 +291,173 @@ def test_an_unused_labelled_source_is_noted_by_its_label(tmp_path, assert "--link-source" not in result.stderr +def test_a_labelled_run_names_a_keyed_source_by_its_own_flag(tmp_path, + monkeypatch): + """#907, U174: the label names the bare PATH and nothing else. A keyed + value is the person's own `--link-source =PATH`, so it is named + `(--link-source openXdox=...)` under a label too. Every labelled row + above passes a bare value, so naming a keyed one `(--pin-source)`, a + flag nobody typed for it, passed them all.""" + clear_ambient_pin_sources(monkeypatch) + missing = tmp_path / "missing" + result = naming(tmp_path, *THREE, "--link-source-label=--pin-source", + "--link-source", f"openXdox={missing}", "codexInk") + assert (f"WARNING link-source-unreadable: {missing} (--link-source " + "openXdox=...) could not answer for openXdox: no such file or " + "directory\n") in result.stderr, result.stderr + assert "(--pin-source)" not in result.stderr, result.stderr + + +# --- #907: --link-remedy's link is the longest one a chain here names ------- + +#: A chain whose middle link holds `=`, as setup's step (4) hands one over +#: (U170). Step (5) refuses it as `chain-link-malformed`; the gate asks no +#: link's form. +EQUALS = ("--pins", "openXdox", "--referent-chain", "openXdox,open=y,openInk") + + +@pytest.mark.parametrize("chain, remedy, holder, held, words", [ + pytest.param(EQUALS, "open=y=OURS for y", "open=y", "openInk", + "OURS for y", id="a-link-holding-an-equals"), + pytest.param(EQUALS, "OPEN=Y=OURS for y", "open=y", "openInk", + "OURS for y", id="in-capitals"), + pytest.param(EQUALS, " open=y =OURS for y", "open=y", "openInk", + "OURS for y", id="padded"), + pytest.param(THREE, "openXdox=a=b", "openXdox", "openYdox", "a=b", + id="words-holding-an-equals"), +]) +def test_a_remedys_link_is_the_one_a_chain_names(tmp_path, monkeypatch, chain, + remedy, holder, held, words): + """U170: the link ends at the LAST `=` that leaves a link of a chain + here before it, stripped, and the rest is the words, `=` and all. Split + at the first `=`, `open=y=OURS for y` was the words `y=OURS for y` for a + link `open` that no chain names, and `open=y` was offered the gate's own + `--link-source open=y=`, a flag setup does not take. Unstripped, + ` open=y ` names no link, and the value fell back to `open`.""" + clear_ambient_pin_sources(monkeypatch) + result = naming(tmp_path, *chain, "--link-remedy", remedy, "codexInk") + assert result.returncode == 0, result.stderr + assert UNREACHED.format(holder=holder, held=held, offer=words) in ( + gate_warnings(result)), result.stderr + assert f"--link-source {holder}=" not in result.stderr, result.stderr + assert "NOTE" not in result.stderr, result.stderr + + +def test_each_link_is_given_its_own_words_where_one_holds_another( + tmp_path, monkeypatch): + """NIT-1 (rv949 on #949): in a chain naming `openXdox` and `openXdox=y` + both, the longest link is read first, so each is given its own words. + Read from the first `=`, `openxdox=y=SECOND words` was `y=SECOND words` + for `openXdox`, which replaced its `FIRST words`, and `openXdox=y` kept + the gate's own. Step (5) refuses such a chain anyway.""" + clear_ambient_pin_sources(monkeypatch) + result = naming(tmp_path, "--pins", "openXdox", "--referent-chain", + "openXdox,openXdox=y,openInk", + "--link-remedy=openxdox=FIRST words", + "--link-remedy=openxdox=y=SECOND words", "codexInk") + assert result.returncode == 0, result.stderr + assert gate_warnings(result) == [ + UNREACHED.format(holder="openXdox", held="openXdox=y", + offer="FIRST words"), + UNREACHED.format(holder="openXdox=y", held="openInk", + offer="SECOND words")], result.stderr + assert "NOTE" not in result.stderr, result.stderr + + +def test_a_remedys_words_are_stripped(tmp_path, monkeypatch): + """U173: the words are offered without the blanks around them, as the + link is read without its own. `openXdox= padded ` offered + `( padded )`.""" + clear_ambient_pin_sources(monkeypatch) + result = naming(tmp_path, *THREE, "--link-remedy", "openXdox= padded ", + "codexInk") + assert gate_warnings(result)[0] == UNREACHED.format( + holder="openXdox", held="openYdox", offer="padded"), result.stderr + + +@pytest.mark.parametrize("args, why", [ + pytest.param((*THREE, "--link-remedy", "openNope=words", "codexInk"), + "`openNope` is not a link of a referent chain here (links: " + "openXdox, openYdox, openInk)", id="a-link-no-chain-names"), + pytest.param((*THREE, "--link-remedy", "openNope=words", + "--link-source-label=--pin-source", "codexInk"), + None, id="under-a-label"), + pytest.param((*THREE, "--link-remedy", "openInk=words", "codexInk"), + "no name classified here reads openInk's own declaration", + id="the-last-link"), + # Named as the chain spells it, not as typed (rv949 on #949, G3). + pytest.param((*THREE, "--link-remedy", "OPENINK=words", "codexInk"), + "no name classified here reads openInk's own declaration", + id="the-last-link-in-capitals"), + # `open=y` leaves no words, so it is not read as the link, and the text + # before the first `=` is, which no chain names (rv949 on #949, G1). + pytest.param((*EQUALS, "--link-remedy", "open=y=", "codexInk"), + "`open` is not a link of a referent chain here (links: " + "openXdox, open=y, openInk)", id="a-link-left-no-words"), + pytest.param((*THREE, "--link-remedy", "openXdox=words", "Atlas"), + "no name classified here reads openXdox's own declaration", + id="a-holder-no-name-reads"), + pytest.param(("--link-remedy", "openXdox=words", "codexInk"), + "no name classified here records a referent chain", + id="no-chain"), +]) +def test_a_remedy_no_holder_used_is_noted(tmp_path, monkeypatch, args, why): + """U173: a `--link-remedy` no holder's lookup asked for is a NOTE, as a + `--link-source` no link used is, named as typed, with why. Under a + label it is not: a caller's remedies are its own words (setup's step + (4) passes one per holder), and the NOTE would name `--link-remedy`, a + flag the person never typed. On main each was dropped without a word.""" + clear_ambient_pin_sources(monkeypatch) + result = naming(tmp_path, *args) + noted = [line for line in result.stderr.splitlines() + if line.startswith("NOTE --link-remedy ")] + remedy = args[args.index("--link-remedy") + 1] + assert noted == ([] if why is None else [ + f"NOTE --link-remedy {remedy}: not used — {why}."]), result.stderr + + +@pytest.mark.parametrize("remedies, words, noted", [ + pytest.param(("openXdox=A", "OPENXDOX=B"), "B", + ["NOTE --link-remedy openXdox=A: not used — the later " + "`--link-remedy OPENXDOX=B` replaced it."], id="a-b"), + # A, typed again, is the last value: it replaced B, and nothing replaced + # it (rv949 on #949, G4). + pytest.param(("openXdox=A", "openXdox=B", "openXdox=A"), "A", + ["NOTE --link-remedy openXdox=B: not used — the later " + "`--link-remedy openXdox=A` replaced it."], id="a-b-a"), + # The same value twice is one value, which nothing replaced (G5). + pytest.param(("openXdox=A", "openXdox=A"), "A", [], id="a-a"), +]) +def test_a_remedy_a_later_one_replaced_is_noted(tmp_path, monkeypatch, + remedies, words, noted): + """The last value for a link wins, found by `pin_product_key`, and the + one it replaced is a NOTE saying so, as a replaced `--link-source` is. + A value typed again is the one that wins, and is never said to have + been replaced, by another value or by itself.""" + clear_ambient_pin_sources(monkeypatch) + result = naming(tmp_path, *THREE, *(arg for remedy in remedies + for arg in ("--link-remedy", remedy)), + "codexInk") + assert gate_warnings(result)[0] == UNREACHED.format( + holder="openXdox", held="openYdox", offer=words), result.stderr + assert [line for line in result.stderr.splitlines() + if line.startswith("NOTE ")] == noted, result.stderr + + +def test_a_remedy_for_a_holder_whose_tree_answered_is_not_noted(tmp_path, + monkeypatch): + """A holder that was asked used its words, whether its tree answered or + not: the words are offered only where it did not, which is the disk's + answer and not a mistake in the value.""" + clear_ambient_pin_sources(monkeypatch) + first = tree(tmp_path / "trees" / "openXdox", "openYdox") + result = naming(tmp_path, *THREE, "--link-source", f"openXdox={first}", + "--link-remedy", "openXdox=words", "codexInk") + assert result.returncode == 0, result.stderr + assert "words" not in result.stderr, result.stderr + assert "NOTE" not in result.stderr, result.stderr + + # --- L2-69: every --link-source value and path the gate prints is spelled --- def assert_clean(result) -> None: @@ -381,6 +555,21 @@ def test_an_unused_link_source_and_its_replacement_are_spelled(tmp_path, "classified here records a referent chain.") in result.stderr +@CONTROL_ARGV +@pytest.mark.parametrize("name", CONTROL_NAMES) +def test_an_unused_remedy_and_its_link_are_spelled(tmp_path, monkeypatch, + name): + """`_unused_remedy_lines` (#907): the value as typed, and the text read + as its link, which no chain names.""" + clear_ambient_pin_sources(monkeypatch) + result = naming(tmp_path, *THREE, "--link-remedy", f"{name}=words", + "codexInk") + assert_clean(result) + assert (f"NOTE --link-remedy {shown_path(name + '=words')}: not used — " + f"`{shown_path(name.strip())}` is not a link of a referent chain " + "here") in result.stderr, result.stderr + + def test_a_printable_value_is_named_as_typed(tmp_path): """The control: a printable value and its replacement, byte for byte.""" result = naming(tmp_path, "--link-source", "a", "--link-source", diff --git a/tests/test_setup_project_py.py b/tests/test_setup_project_py.py index eacf1c0f..04bdcab2 100644 --- a/tests/test_setup_project_py.py +++ b/tests/test_setup_project_py.py @@ -70,7 +70,8 @@ assert_an_empty_pin_source_is_the_plans_to_refuse) from conftest import (STEP_FOUR_WORDS, load_without_bytecode, - assert_step_four_speaks_in_the_plans_words) + assert_step_four_speaks_in_the_plans_words, + clear_ambient_pin_sources) sys.path.insert(0, str(REPO / "scripts")) import shape_materialize # noqa: E402 @@ -2152,18 +2153,75 @@ def test_the_changed_paste_lines_are_posix_quoted_on_windows_too( assert shlex.split(closing) == ["cd", clone], closing assert local == ( " cd %s\n" - " git -c protocol.file.allow=always clone --recurse-submodules %s\n" + " git -c protocol.file.allow=always -c core.autocrlf=false clone " + "--recurse-submodules %s\n" " cd Sample && %s scripts/bootstrap.py" % (spelled, assembly_spelled, module.PYTHON_CMD)), local assert [shlex.split(line) for line in local.splitlines()[:2]] == [ ["cd", clone], - ["git", *FILE_PROTOCOL, "clone", "--recurse-submodules", assembly]] + ["git", *FILE_PROTOCOL, *LF_ON_WINDOWS, "clone", + "--recurse-submodules", assembly]] assert github.splitlines()[:2] == [ " cd " + spelled, - " git clone --recurse-submodules " + " git -c core.autocrlf=false clone --recurse-submodules " "https://github.com/demoorg/Sample.git"], github +#: What setup's own clone passes on Windows (`clone_and_bootstrap`), and +#: since #926 its finish-by-hand clone too. +LF_ON_WINDOWS = ["-c", "core.autocrlf=false"] + + +class _OsNamed: + """This process's `os`, answering `name` with another platform's, for + the module under test alone: `pathlib` keeps reading the real one, and + on Python 3.12 a `Path` made while `os.name` itself says `nt` is a + `WindowsPath`, which cannot be made on a POSIX host.""" + + def __init__(self, name: str) -> None: + self.name = name + + def __getattr__(self, attr): + return getattr(os, attr) + + +@pytest.mark.parametrize("name", ["nt", "posix"]) +@pytest.mark.parametrize("local", [True, False], ids=["local", "github"]) +def test_the_backstops_clone_checks_out_as_setups_own_does(monkeypatch, + tmp_path, name, + local): + """#926 (U041): under an emulated `nt` the finish-by-hand clone carries + `-c core.autocrlf=false`, as setup's own clone does, and on any other + host neither does. The module's `os` answers `name` (`_OsNamed`), so + this holds on any host; the printed line is read back by `shlex.split`, + as Git Bash reads it, and setup's own clone is the argv + `clone_and_bootstrap` hands `run`. On main the printed line had no + `-c core.autocrlf=false` on Windows, and a clone made by hand under Git + for Windows' default `core.autocrlf=true` was not the clone setup + makes.""" + module = entry_point_module() + runs = _Runs() + monkeypatch.setattr(module, "run", runs) + flags = ["--project", "Sample", "--org", "demoorg", + "--into", str(tmp_path / "work")] + if local: + flags += ["--local-remote-dir", str(tmp_path / "remotes")] + opts = module.parse_args(flags, str(tmp_path)) + assembly = (os.path.join(opts.local_remote_dir, "Sample.git") if local + else "https://github.com/demoorg/Sample.git") + monkeypatch.setattr(module, "os", _OsNamed(name)) + module.clone_and_bootstrap(opts, "demoorg", + module.Landing(Path(opts.into), "", None)) + printed = module.finish_by_hand(opts.into, assembly, "Sample", local) + [own] = [argv for argv in runs.calls if "clone" in argv] + lf = LF_ON_WINDOWS if name == "nt" else [] + assert shlex.split(printed.splitlines()[1]) == [ + "git", *(FILE_PROTOCOL if local else []), *lf, "clone", + "--recurse-submodules", assembly], printed + assert any(own[at:at + 2] == LF_ON_WINDOWS + for at in range(own.index("clone"))) is (name == "nt"), own + + @pytest.mark.parametrize("case", ["a-file-two-levels-up", "a-link-to-nothing", "a-link-to-a-folder", "nothing-there"]) def test_what_stands_above_a_clone_path(tmp_path, case): @@ -2383,7 +2441,9 @@ def test_a_dry_run_is_read_as_the_scaffold_reads_one(passthrough, dry): # ...as the scaffold's `Path` reads it: a value that starts with `-` is # not given as the next word (argparse reads it as a flag), and is kept # after an `=`. A value `PRODUCT=PATH` would be read as keyed by the - # naming check where the scaffold reads one path, so it is spelled `./`. + # naming check where the scaffold reads one path; joined to the + # standard's checkout it has a separator before its first `=`, which no + # link's name holds, so it is one path to the naming check too (#907). (["--referent-chain", "openXdox,openDox", "--pin-source", "--wat"], ["--referent-chain=openXdox,openDox"]), (["--referent-chain", "openXdox,openDox", "--pin-source"], @@ -2392,11 +2452,11 @@ def test_a_dry_run_is_read_as_the_scaffold_reads_one(passthrough, dry): ["--referent-chain=openXdox,openDox", "--link-source=-odd"]), (["--referent-chain", "openXdox,openDox", "--pin-source", "openXdox=layer"], - ["--referent-chain=openXdox,openDox", "--link-source=./openXdox=layer"]), + ["--referent-chain=openXdox,openDox", "--link-source=openXdox=layer"]), (["--referent-chain", "openXdox,openDox", "--pin-source", "a/b=c"], ["--referent-chain=openXdox,openDox", "--link-source=a/b=c"]), # ...and where `=` follows a Windows separator or a drive, the text before - # it cannot be a link's name, so nothing is added: `[^/\\:=]` holds each. + # it cannot be a link's name either. (["--referent-chain", "openXdox,openDox", "--pin-source", "a\\b=c"], ["--referent-chain=openXdox,openDox", "--link-source=a\\b=c"]), (["--referent-chain", "openXdox,openDox", "--pin-source", "C:x=y"], @@ -2452,8 +2512,20 @@ def test_the_declarations_are_read_as_the_scaffold_reads_them(passthrough, declared): """#538: each `--pin`'s product, at its last `/` and before `@`; the last `--referent-chain`, in any spelling the scaffold takes for it; and - nothing from a flag that only shares a prefix with one of them.""" - assert entry_point_module().naming_declarations(passthrough) == declared + nothing from a flag that only shares a prefix with one of them. A + `--link-source` in the table is the `--pin-source` as typed, and goes + as the plan names it (`as_named_by_the_plan`).""" + assert entry_point_module().naming_declarations( + passthrough, REPO) == as_named_by_the_plan(declared) + + +def as_named_by_the_plan(declared: list) -> list: + """`declared` with each `--link-source=` as step (4) passes it + since #907 (U171): joined to the standard's checkout, the folder the + scaffold runs in, and resolved, as step (5) names it.""" + return [("--link-source=%s" % Path(REPO, arg.partition("=")[2]).resolve() + if arg.startswith("--link-source=") else arg) + for arg in declared] @pytest.mark.parametrize("passthrough,flag,values", [ @@ -2949,7 +3021,8 @@ def test_each_holder_is_given_the_plans_words(passthrough, spoken): check `--pin-source` is the flag and what each holder is offered.""" module = entry_point_module() assert module.naming_declarations( - passthrough, shape_materialize.scaffold_link_remedies) == spoken + passthrough, REPO, shape_materialize.scaffold_link_remedies + ) == as_named_by_the_plan(spoken) def test_step_four_and_the_plan_share_one_set_of_words(): @@ -2967,3 +3040,300 @@ def test_step_four_and_the_plan_share_one_set_of_words(): def test_step_four_speaks_in_the_plans_words(tmp_path, monkeypatch, case): assert_step_four_speaks_in_the_plans_words(run_entry, tmp_path, monkeypatch, case) + + +# --- #907 (U061, U169): step (4) reads no checkout beside the standard ------ + +#: What step (4) runs, out of this checkout: the naming check, the library +#: it imports and the policy it finds beside itself. +NAMING_CHECK_FILES = ("scripts/validate-repository-naming.py", + "scripts/repo_shape.py", + "contracts/repository-naming.yaml") + + +def declaring(tree: Path, pin: str) -> Path: + """`tree`, a chain link's checkout as the naming check reads one: a + `project.yaml` that declares a pin on `pin`.""" + tree.mkdir(parents=True) + (tree / "project.yaml").write_bytes( + ("schema_version: 1\nkind: project-manifest\n" + f"neutral_product_pins:\n - {pin}\n").encode("utf-8")) + return tree + + +def test_step_four_reads_no_checkout_beside_the_standard(tmp_path, + monkeypatch, capfd): + """The issue's own row: a standard whose parent folder holds `openXdox/` + declaring `openDox`, and a `--pin-source` declaring `openYdox`, with the + chain `openXdox,openYdox,openDox`. Step (5) reads no checkout beside + anything, so it reads the `--pin-source` tree for `openXdox` and the + name is a descendant through the chain. On main step (4) ran the naming + check from the standard's checkout, which read `openXdox/` beside it + first: the `--pin-source` tree was "not used" and the chain broken, + exit 1. It runs from an empty folder now, inside a temporary folder + holding nothing else, and the temporary folders are made in that same + parent, so a run from an empty folder with the parent beside it would + read `openXdox/` too. Both are gone when the check is done.""" + clear_ambient_pin_sources(monkeypatch) + module = entry_point_module() + parent = tmp_path / "parent" + standard = parent / "standard" + for name in NAMING_CHECK_FILES: + (standard / name).parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(REPO / name, standard / name) + declaring(parent / "openXdox", "openDox") + source = declaring(tmp_path / "source", "openYdox") + monkeypatch.setattr(tempfile, "tempdir", str(parent)) + declared = module.naming_declarations( + ["--pin", f"openXdox@{A_COMMIT}", + "--referent-chain", "openXdox,openYdox,openDox", + "--pin-source", str(source)], + standard, shape_materialize.scaffold_link_remedies) + module.check_names(standard, "codexDox", "", declared) + said = capfd.readouterr() + heard = said.out + said.err + assert "codexDox: domain-descendant" in said.out, heard + assert ("CHAIN openXdox → openYdox → openDox " + "[declared-unverified]") in said.out, heard + assert "broken" not in heard, heard + assert "NOTE" not in heard, heard + assert sorted(entry.name for entry in parent.iterdir()) == [ + "openXdox", "standard"] + + +#: Why no folder could be made for the naming check, and the reason step +#: (4)'s refusal gives for it: any `OSError`, with its `strerror`, or its +#: class's name where it has none (rv949 on #949: S1, S3). +NO_FOLDER = { + "permission-denied": (PermissionError(errno.EACCES, "Permission denied"), + "Permission denied"), + "no-such-folder": (FileNotFoundError(errno.ENOENT, + "No such file or directory"), + "No such file or directory"), + "no-reason": (OSError("odd"), "OSError"), +} + + +def no_empty_folder(reason: str) -> str: + """Step (4)'s refusal where no folder could be made, for `reason`.""" + return ("the naming check runs from an empty folder, and none could be " + f"made in the temporary folder ({reason}), so nothing was " + "created. Point TMPDIR (TEMP on Windows) at a folder you can " + "write in, then run this again.") + + +@pytest.mark.parametrize("case", NO_FOLDER) +def test_step_four_without_an_empty_folder_is_refused_with_the_way_on( + monkeypatch, case): + """No empty folder can be made for the naming check to run from: a + refusal naming the system's reason and the way on, before anything + exists, and no naming check is run. Not a traceback.""" + module = entry_point_module() + runs = _Runs() + monkeypatch.setattr(module, "run", runs) + raised, reason = NO_FOLDER[case] + + def no_folder(*_args, **_kwargs): + raise raised + + monkeypatch.setattr(module.tempfile, "mkdtemp", no_folder) + with pytest.raises(module.Refusal) as refused: + module.check_names(REPO, "Sample") + assert refused.value.code == 2 + assert str(refused.value) == no_empty_folder(reason) + assert runs.calls == [], f"something was run: {runs.calls}" + + +class _OsWith: + """This process's `os`, with the functions named replaced, for the + module under test alone, as `_OsNamed` answers `name`: `tempfile`, which + makes the temporary folder, keeps the real ones.""" + + def __init__(self, **replaced) -> None: + self.__dict__.update(replaced) + + def __getattr__(self, attr): + return getattr(os, attr) + + +def test_step_four_takes_its_folder_away_when_it_cannot_make_one_inside( + tmp_path, monkeypatch): + """The temporary folder was made and the empty folder inside it was + not: the same refusal, no naming check run, and the temporary folder + taken away again (rv949 on #949, S2). It was left to + `TemporaryDirectory`'s finalizer, a `ResourceWarning` at exit.""" + module = entry_point_module() + runs = _Runs() + monkeypatch.setattr(module, "run", runs) + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + + def no_room(path, *_args, **_kwargs): + raise OSError(errno.ENOSPC, "No space left on device", path) + + monkeypatch.setattr(module, "os", _OsWith(mkdir=no_room)) + with pytest.raises(module.Refusal) as refused: + module.check_names(REPO, "Sample") + assert str(refused.value) == no_empty_folder("No space left on device") + assert runs.calls == [], f"something was run: {runs.calls}" + assert list(tmp_path.iterdir()) == [] + + +def test_step_four_names_a_folder_it_cannot_take_away(tmp_path, monkeypatch, + capfd): + """SHOULD-1 (rv949 on #949): a folder that cannot be taken away once the + names are judged, as on Windows while an indexer or an antivirus holds a + handle on it, is a `[??]` naming it, and step (4) goes on. Under + `TemporaryDirectory` it was a `PermissionError` traceback out of + `check_names`, which `main` does not catch.""" + module = entry_point_module() + ran = [] + + def run(argv, cwd=None, capture=False, stdin=None, env=None): + ran.append(cwd) + return subprocess.CompletedProcess(argv, 0, "", "") + + monkeypatch.setattr(module, "run", run) + monkeypatch.setattr(tempfile, "tempdir", str(tmp_path)) + held = ("The process cannot access the file because it is being used by " + "another process") + + def in_use(path, *_args, **_kwargs): + raise PermissionError(errno.EACCES, held, path) + + monkeypatch.setattr(module, "os", _OsWith(rmdir=in_use)) + assert module.check_names(REPO, "Sample") is None + [aside] = tmp_path.iterdir() + assert ran == [os.path.join(str(aside), "here")] + assert [entry.name for entry in aside.iterdir()] == ["here"] + assert capfd.readouterr().err == ( + f" [??] {aside}, the folder made in the temporary folder for the " + f"naming check to run from, could not be taken away again ({held}); " + "setup put nothing in it but an empty folder, and it is yours to " + "delete.\n") + + +# --- #907, rv949 on #949 (MUST-1): a relative SHAPE_PIN_SOURCE_ ------- + +#: `SHAPE_PIN_SOURCE_OPENYDOX` names `openYdox`'s tree relative to the +#: standard's checkout, and `--pin-source` is `openXdox`'s, declaring +#: `openYdox`: each case's project, chain, what `openYdox`'s tree declares, +#: and the verdict on the chain that steps (4) and (5) both give. +RELATIVE_VARIABLE = { + "verified": ("codexDox", "openXdox,openYdox,openDox", "openDox", + "verified"), + "breaking": ("codexInk", "openXdox,openYdox,openInk", "openZdox", + "broken"), +} + + +def step_four_verdict(step_four: str) -> str: + """What step (4)'s naming check says of the assembly root's chain: the + status its first `CHAIN` line ends with.""" + statuses = re.findall(r"^\s*CHAIN .*\[([a-z-]+)\]$", step_four, + re.MULTILINE) + return statuses[0] if statuses else "no CHAIN line" + + +def step_five_verdict(plan: str, stderr: str) -> str: + """What step (5)'s plan says of the chain: broken where it refused it, + declared-unverified where it warned so, and verified where its + `declared` line reaches the referent through the chain and says + neither.""" + if "REFUSED chain-not-declared:" in stderr: + return "broken" + if "WARNING declared-unverified:" in stderr: + return "declared-unverified" + declared = [line for line in plan.splitlines() + if line.startswith("declared ")] + if declared and "through the declared pin chain" in declared[0] and ( + "declared-unverified" not in declared[0]): + return "verified" + return "no verdict" + + +@pytest.mark.parametrize("case", RELATIVE_VARIABLE) +def test_step_four_reads_a_relative_variable_where_step_five_does( + tmp_path, monkeypatch, case): + """MUST-1: step (5) reads a relative `SHAPE_PIN_SOURCE_` from the + standard's checkout, the folder the scaffold runs in, and step (4) now + reads it there too (`pin_sources_joined`), so the two steps give the + chain one verdict. Read from the empty folder step (4) runs in, the + variable named nothing: the chain step (5) verified was + `[declared-unverified]` in step (4), which offered the variable that + was set, and the chain step (5) refused as `chain-not-declared` passed + step (4) as a descendant. On main step (4) ran in the checkout and + agreed. The variable is the path from this checkout to the tree, which + leaves it by `..`.""" + clear_ambient_pin_sources(monkeypatch) + project, chain, declares, verdict = RELATIVE_VARIABLE[case] + first = declaring(tmp_path / "openXdox", "openYdox") + git("init", "-q", "-b", "main", cwd=first) + git("add", "project.yaml", cwd=first) + git("commit", "-q", "-m", "seed", cwd=first) + commit = git("rev-parse", "HEAD", cwd=first).stdout.strip() + later = declaring(tmp_path / "yy", declares) + try: + relative = os.path.relpath(later.resolve(), REPO.resolve()) + except ValueError: + pytest.skip("the temporary folder is on another drive than this " + "checkout, so no relative path leads from one to the " + "other") + result = run_entry("--project", project, "--org", "demoorg", + "--local-remote-dir", str(tmp_path / "remotes"), + "--into", str(tmp_path / "work"), + "--", "--pin", f"openXdox@{commit}", + "--referent-chain", chain, "--pin-source", str(first), + "--dry-run", + extra_env={"SHAPE_PIN_SOURCE_OPENYDOX": relative}) + heard = result.stdout + result.stderr + assert not os.path.isabs(relative), relative + assert "(5) the plan" in result.stdout, heard + step_four, _, plan = result.stdout.partition("(4) naming policy")[ + 2].partition("(5) the plan") + assert step_four_verdict(step_four) == step_five_verdict( + plan, result.stderr) == verdict, heard + assert "SHAPE_PIN_SOURCE_OPENYDOX would let it be read" not in heard, ( + heard) + assert "link-source-unreadable" not in result.stderr, result.stderr + assert not (tmp_path / "remotes").exists() + + +def test_only_a_pin_source_variable_that_names_a_path_is_joined(): + """`pin_sources_joined` joins a relative `SHAPE_PIN_SOURCE_` to the + standard's checkout and leaves an absolute one as it is. An empty one + stays empty: neither step reads it, and joined it would name the + checkout itself. Every other variable is handed on as it is, one whose + name only holds the prefix too.""" + module = entry_point_module() + elsewhere = str(Path(REPO, os.pardir, "elsewhere").resolve()) + relative = os.path.join(os.pardir, "yy") + unjoined = {"SHAPE_PIN_SOURCE_OPENXDOX": elsewhere, + "SHAPE_PIN_SOURCE_OPENDOX": "", + "PYTHONIOENCODING": "utf-8:backslashreplace", + "NOT_SHAPE_PIN_SOURCE_OPENYDOX": relative} + assert module.pin_sources_joined(REPO, { + "SHAPE_PIN_SOURCE_OPENYDOX": relative, **unjoined}) == { + "SHAPE_PIN_SOURCE_OPENYDOX": os.path.join(str(REPO), relative), + **unjoined} + + +@pytest.mark.parametrize("raised", [ + pytest.param(RuntimeError("Symlink loop from 'loop'"), id="runtime-error"), + pytest.param(OSError(errno.ELOOP, "Too many levels of symbolic links"), + id="os-error"), +]) +def test_a_pin_source_that_cannot_be_resolved_goes_joined(monkeypatch, + raised): + """Where `resolve()` fails, as on a symlink loop, the `--pin-source` goes + to the naming check joined to the standard's checkout and unresolved, + and step (4) goes on: the scaffold names it as typed then + (`_resolved_pin_source`), and a path the naming check is given has to + be absolute (rv949 on #949, S4).""" + module = entry_point_module() + + def cannot(self, strict=False): + raise raised + + monkeypatch.setattr(module.Path, "resolve", cannot) + assert module.as_the_plan_reads_it(REPO, "loop") == str(Path(REPO, + "loop"))