diff --git a/shape-doctor.py b/shape-doctor.py index 71d07000..a7648923 100755 --- a/shape-doctor.py +++ b/shape-doctor.py @@ -56,7 +56,12 @@ manifest the project's own `scripts/validate-manifest.py` when it has one -- it is the pinned copy, and that is the point -- else the standard's template copy, and the row says - which ran + which ran; the template's NEXT line carries the + `PYTHONPATH` the doctor ran it under, because it + imports `repo_shape` from the standard's `scripts/` and + a bare path pasted anywhere else dies on that import, + and the project's own copy is run and printed with none, + as its own gate runs it (#238) pins the project's own `scripts/validate-pins.py` likewise: the lockstep invariant, and the shape-copy digests manifest kinds every `kind:`-bearing manifest in the root, against @@ -528,7 +533,8 @@ def ascii_text(text: str) -> str: UNQUOTED_NT = re.compile(r"^[A-Za-z0-9_./:%+=\\-]+$") -def quote_arg(value, platform: str | None = None) -> str: +def quote_arg(value, platform: str | None = None, *, + always: bool = False) -> str: # A RAW docstring, because the Windows examples below are Windows paths: # `C:\Users\...` is a `\U` escape to the interpreter, and a docstring that # had to spell a reader's path with doubled backslashes would be a @@ -606,6 +612,17 @@ def quote_arg(value, platform: str | None = None) -> str: and false to the point of it (Copilot, PR #102). `.fullmatch` is what actually requires every character to be in the alphabet, not only the ones before wherever `$` was willing to stop. + + `always` QUOTES A VALUE THE ALPHABET WOULD LEAVE BARE, and exists for the + one position where bare is not a value at all: the right-hand side of a + PowerShell assignment. `$env:PYTHONPATH = C:\shape\scripts` is not the + string `C:\shape\scripts`, it is an attempt to RUN it, because what + follows `=` is a pipeline and a bareword in one is a command name (#238). + Every other position this file interpolates into is an argument, where + bare is the value; so it is off unless a caller asks, and the quoting + itself is still this function's alone -- one place that knows the + apostrophe is doubled, rather than a second copy of the rule beside the + one that needs it. """ # ASCII first, so the alphabet check and the quoting below it both see # what `Row.__init__` will (Copilot, PR #102); see the docstring above. @@ -614,7 +631,8 @@ def quote_arg(value, platform: str | None = None) -> str: # `.fullmatch`, not `.match`: a pattern ending in `$` still matches just # before a trailing newline, so `.match` alone would call "Atlas\n" bare # (Copilot, PR #102). - if (UNQUOTED_NT if windows else UNQUOTED_POSIX).fullmatch(text): + if not always and (UNQUOTED_NT if windows else UNQUOTED_POSIX).fullmatch( + text): return text if windows: # PowerShell's verbatim string: nothing inside is expanded, and the @@ -865,22 +883,67 @@ def adopt(self): # --------------------------------------------------------------------------- -def run_validator(ctx: Context, script: Path, args: list[str]) -> tuple: +def validator_scripts(shape: Path, own_copy: bool) -> Path | None: + """The directory a validator run has on `PYTHONPATH`, or None for a run + that has none: the standard's own `scripts/` for the standard's TEMPLATE + copy, and nothing for the root's OWN copy (#238). + + ONE DEFINITION OF THE DIRECTORY AND OF THE CONDITION, for the two places + that have to agree about both. `run_validator` sets what this returns on + the doctor's child and `validator_command` prints what it returns in the + line a row hands over, and each is asked with the one `own_copy` that + `validator_for` made -- so the run and the line apply `PYTHONPATH` under + the same condition, and there is no second test of `own_copy` to drift + from the first (review S1 on #238). + + THE TEMPLATE'S IS THE STANDARD'S `scripts/`, because that is the only + place `repo_shape.py` is: the template has none beside it. THE ROOT'S OWN + COPY GETS NOTHING, because it is the project's own gate and the doctor's + job is to run it as the project runs it -- as a path, importing + `repo_shape` from beside itself. A root that has lost its own + `scripts/repo_shape.py` has a gate that cannot import it, and the row + must say what that gate says (`ModuleNotFoundError` as a finding), not a + "passes" the project's own gate would never give. + """ + return None if own_copy else shape / "scripts" + + +def run_validator(ctx: Context, script: Path, args: list[str], + pythonpath: Path | None = None) -> tuple: """`(exit code, first line worth quoting, whole output)`. - THE PYTHONPATH IS THE POINT OF THE `env` BLOCK. A project's own + THE PYTHONPATH IS THE POINT OF THE `env` BLOCK, AND IT IS SET ONLY WHEN + A CALLER ASKS: `pythonpath` is the directory to put on it, and + `validator_row` passes `validator_scripts`'s answer. A project's own `scripts/validate-pins.py` sits beside its own copy of `repo_shape.py` and - imports it from there. The STANDARD's template copy has no `repo_shape.py` - beside it -- that file is copied out of the shape itself, not out of the - template -- so when this command falls back to the template it must put - `/scripts` on the path or the fallback dies on an ImportError that - says nothing about the project being checked. + imports it from there, so it is run with nothing added -- the way the + project's own gate runs it. The STANDARD's template copy has no + `repo_shape.py` beside it -- that file is copied out of the shape itself, + not out of the template -- so when a row falls back to the template it + must put `/scripts` on the path or the fallback dies on an + ImportError that says nothing about the project being checked. The + standard's own scripts (naming, the machine's preflight) are not given + one either: the naming script inserts its own directory ahead of its + `repo_shape` import, and the machine's preflight needs nothing from + `repo_shape` at all and never imports it (#238). + + A PASTED COMMAND IS A SECOND PROCESS, AND THIS `env` IS NOT IN IT. What a + row prints as its NEXT is run later, by a person, in their own shell -- + so the `PYTHONPATH` set here reaches the doctor's child and never the + reader's, and `validator_command` is what puts the same directory in the + printed line (#238). Both are `validator_scripts`'s, asked with the same + `own_copy`, so the run and the line apply `PYTHONPATH` under one + condition: a template copy gets it in both and the root's own copy gets + it in neither. A root's own copy that cannot import `repo_shape` from + beside itself therefore fails here as it fails when pasted, and the row + reports that failure rather than a pass the pasted line would not give. """ env = dict(os.environ) - existing = env.get("PYTHONPATH") - scripts = str(ctx.shape / "scripts") - env["PYTHONPATH"] = (f"{scripts}{os.pathsep}{existing}" if existing - else scripts) + if pythonpath is not None: + existing = env.get("PYTHONPATH") + scripts = str(pythonpath) + env["PYTHONPATH"] = (f"{scripts}{os.pathsep}{existing}" if existing + else scripts) # UTF-8 whatever the console is: these validators print this repository's # own prose, and `ascii_text` flattens it for the report afterwards. env["PYTHONIOENCODING"] = "utf-8" @@ -911,18 +974,98 @@ def run_validator(ctx: Context, script: Path, args: list[str]) -> tuple: def validator_for(ctx: Context, own: str, template: str) -> tuple: - """`(the validator a row runs, how the row names it)`: the root's own - copy when it has one, else the standard's template copy. + """`(the validator a row runs, how the row names it, whether it is the + root's own copy)`: the root's own copy when it has one, else the + standard's template copy. One choice, made in one place, because two rows make it: `validator_row` runs what this picks, and `unloaded_manifest_row` names it as the next command (#205). A root told to run one copy by one row and another by the next would be told two different things about one gate. + + THE THIRD VALUE IS WHAT `validator_command` NEEDS to spell the line, and + it is returned here, from the one place that made the choice, instead of + being re-derived from the path by a caller (#238). It is also the row's + `detail.own_copy`. """ own_path = ctx.root / own if own_path.is_file(): - return own_path, f"the project's own {own}" - return ctx.shape / template, f"the standard's {template}" + return own_path, f"the project's own {own}", True + return ctx.shape / template, f"the standard's {template}", False + + +def validator_command(shape: Path, used: Path, own_copy: bool, args: list, + platform: str | None = None) -> str: + r"""The line that runs `used` on `args`, spelled for the READER'S shell, + and carrying the environment the doctor ran it under (#238). + + A NEXT IS A COMMAND, and a command pasted into the shell it was quoted + for, from any directory and with no `PYTHONPATH` in the environment, has + to do what its row says: print the validator's own output and exit with + the code the row records. The root's OWN copy does, for as long as the + project keeps its own `repo_shape.py` beside it, as a project copied from + the shape does: it imports it from there, so that line is the bare + interpreter, path and arguments it has always been, byte for byte. It is + the project's own gate and is run, and printed, as the project runs it. A + root that has lost that file has a gate that dies on the import, pasted + or not, and the row says so as a finding, because the doctor ran it the + same way (`validator_scripts`). + + THE STANDARD'S TEMPLATE COPY DOES NOT. It imports `repo_shape` from beside + itself, and `repo_shape.py` is copied INTO a project's `scripts/` out of + the standard's `scripts/`, never kept beside the template (`REPO_SHAPE`, + `scripts/shape_materialize.py`). The doctor's own child runs it with + `/scripts` on `PYTHONPATH` (`run_validator`, through + `validator_scripts`); the line a row printed carried only the path, so + pasting it died on `ModuleNotFoundError: No module named 'repo_shape'` + with exit 1 -- the code a validator uses for "findings", so anything keyed + on the exit code read a crash as the finding the row had already + reported, and the one command the row hands over to "read its output in + full" printed a traceback that named nothing about the project. A reader + whose own shell already exported a `PYTHONPATH` that reached the standard + never saw it, which is how it went unfound. + + SO THE TEMPLATE'S LINE SETS IT, in the reader's dialect, which is the + same two this file already speaks (`quote_arg`, `copy_command`, + `scaffold_command`): `PYTHONPATH=/scripts python3