diff --git a/AGENTS.md b/AGENTS.md
index ee82ff3b..9e125f7a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -267,8 +267,8 @@ its last line names that directory, the human's to delete once those have run.
never changes the verdict: it says whether THIS workstation can run the fixes
the other rows named. Relay a missing prerequisite; `openRepoShape
--preflight` offers only the `gh` set-up, and the human types that yes.
-7. `--json` is the same report as one object, alone on stdout, each row
- carrying its `id`, for when you have to act on a row rather than quote it.
+7. `--json` is the same report as one object, alone on stdout, each row with
+ its `id`, to act on a row. A `next` with `$'…'` needs bash or zsh, not `sh`.
## Updating a project's shape
diff --git a/README.md b/README.md
index b0d3a957..5f4496ab 100644
--- a/README.md
+++ b/README.md
@@ -159,6 +159,8 @@ typed `yes`, which its preflight cannot take there. Printed commands are quoted
for a POSIX shell, so paste them into Git Bash. Only `shape-doctor.py`'s lines,
`setup-project.py`'s family lines and `family.py`'s `NEXT`, `switch -c`, `add`
and `git rm -r --cached` lines are quoted for PowerShell, on Windows.
+Off Windows, a path `shape-doctor.py` cannot print as itself is written `$'…'`:
+bash, zsh and ksh read that; dash (`/bin/sh` on Debian and Ubuntu) does not.
diff --git a/docs/handbook.html b/docs/handbook.html
index 8df78127..827c88bd 100644
--- a/docs/handbook.html
+++ b/docs/handbook.html
@@ -472,7 +472,7 @@
-
+
@@ -612,7 +612,7 @@
The one thing to read before electing it
-
+
Setup
Start here
@@ -738,7 +738,9 @@ The tool offers the rest
shape-doctor.py’s lines, setup-project.py’s family
lines and family.py’s NEXT, switch -c,
add and git rm -r --cached lines are quoted for PowerShell, on
- Windows.
+ Windows. Off Windows, a path shape-doctor.py cannot print as itself is
+ written $'…': bash, zsh and ksh read that; dash (/bin/sh
+ on Debian and Ubuntu) does not.
@@ -1087,7 +1089,7 @@ What you have afterwards — the same on every platform
-
+
Setup
If it fails part-way
@@ -1124,7 +1126,7 @@ If it fails part-way
-
+
@@ -1187,7 +1189,7 @@ Reading private legs in CI
-
+
@@ -1443,7 +1445,7 @@ The InkRouter example
-
+
@@ -1742,7 +1744,7 @@ Is this repository compliant? openRepoShape --doctor
-
+
diff --git a/scripts/shape_advisory.py b/scripts/shape_advisory.py
index 64a489e9..0d19f450 100644
--- a/scripts/shape_advisory.py
+++ b/scripts/shape_advisory.py
@@ -169,13 +169,15 @@
def printable(text: str) -> str:
"""`text` as ASCII, so printing it can never be what fails a run.
- THE SAME FLATTENING `shape-doctor.py`'s `ascii_text` applies to every row,
- for the same reason, here for the two tools that print the advisory
- themselves: a record's `kind:` is a person's text and may carry anything,
- and a console in an ANSI code page -- or a `PYTHONIOENCODING=ascii`
- pipe -- raises `UnicodeEncodeError` on the first character it cannot
- write. The typography this repository writes is spelled out; anything
- else becomes `?` rather than an exception.
+ THE FLATTENING `shape-doctor.py`'s `ascii_text` applied to every row
+ until #785, for the same reason, here for the two tools that print the
+ advisory themselves: a record's `kind:` is a person's text and may carry
+ anything, and a console in an ANSI code page -- or a
+ `PYTHONIOENCODING=ascii` pipe -- raises `UnicodeEncodeError` on the first
+ character it cannot write. The typography this repository writes is
+ spelled out; anything else becomes `?` rather than an exception. (The
+ doctor's now keeps a character its stdout can show, because a row names
+ paths a reader pastes; a `kind:` is never pasted.)
"""
for source, target in (("—", "--"), ("–", "-"),
("‘", "'"), ("’", "'"),
diff --git a/scripts/shape_materialize.py b/scripts/shape_materialize.py
index a26d2390..1171227d 100644
--- a/scripts/shape_materialize.py
+++ b/scripts/shape_materialize.py
@@ -162,11 +162,11 @@ def escape_what_stdout_cannot_encode(stream=None) -> None:
file it is, and two names that differ only outside the code page do not
both print as `??.md`. (A backslash is not escaped, so a name that holds
the six characters `\\u65e5` prints as one holding `日` does.) ONCE, AT
- THE START OF `main()`, and not at each `print`: `shape-doctor.py`'s
- `ascii_text` and `shape_advisory.printable` flatten their own text to
- ASCII on every console, which a name cannot afford -- it would change
- what a UTF-8 terminal shows -- and a guard at each site is a guard the
- next `print` goes without.
+ THE START OF `main()`, and not at each `print`: `shape_advisory.printable`
+ flattens its own text to ASCII on every console, as `shape-doctor.py`'s
+ `ascii_text` did until #785, which a name cannot afford -- it would
+ change what a UTF-8 terminal shows -- and a guard at each site is a
+ guard the next `print` goes without.
GUARDED, because failing to set a handler is no reason to refuse the
run: a stream a caller replaced may have no `reconfigure`, a closed or
diff --git a/shape-doctor.py b/shape-doctor.py
index 1e41af46..b3527bab 100755
--- a/shape-doctor.py
+++ b/shape-doctor.py
@@ -333,9 +333,14 @@
red validator: the conflict it leaves is what turns the other rows red, and
none of their fixes can be committed until it is finished.
-STANDARD LIBRARY ONLY, like everything else shipped here. Printed text is
-ASCII: a verdict a cp1252 console cannot render is a verdict nobody reads, and
-this tool is run on every platform the standard supports.
+STANDARD LIBRARY ONLY, like everything else shipped here. The report's own
+words are ASCII: a verdict a cp1252 console cannot render is a verdict nobody
+reads, and this tool is run on every platform the standard supports. A NAME
+is not the report's own words (#785): a path printed in a command is spelled
+so that the reader's shell turns it back into that path, as itself where the
+output can show it and escaped where it cannot (`quote_arg`), and a sentence
+shows it as itself where the output can and with `?` where it cannot
+(`ascii_text`).
"""
from __future__ import annotations
@@ -420,7 +425,8 @@ def not_in_the_standard(detail: str) -> int:
#: reason, in place of a copy of `plan`'s regex (`origin_org`, #521).
#: `spelled` is how `siblings.py` prints the values that row quotes (#673).
from repo_shape import ( # noqa: E402
- COMMIT_RE, NamingPolicy, PYTHON, Refusal, accepts_role, git_out,
+ COMMIT_RE, NamingPolicy, POWERSHELL_SINGLE_QUOTES, PYTHON,
+ Refusal, accepts_role, git_out,
load_yaml, recorded_gitlink, redacted, remote_local_path,
resolved_remote, same_repository, spelled, url_repository,
)
@@ -662,20 +668,93 @@ def not_in_the_standard(detail: str) -> int:
)
-def ascii_text(text: str) -> str:
- """`text` with the typography this repository is written in flattened.
+#: The typography this repository's prose is written in, and the ASCII
+#: `ascii_text` spells each one with. `quote_arg` never prints one of them as
+#: itself in a value (#785): `Row.__init__` passes every command and every
+#: `why` through `ascii_text`, which would make the value another path.
+FLATTENED = {"—": "--", "–": "-", "‘": "'", "’": "'", "“": '"', "”": '"',
+ "…": "...", "\N{NO-BREAK SPACE}": " "}
+
+
+#: The encoding the report is written for where that is not stdout's: UTF-8
+#: for `--json`, which `main` sets (#785, review S2 on PR #792). `json.dumps`
+#: writes ASCII whatever stdout's encoding is, and every character as an
+#: escape its reader decodes, so a JSON reader gets each character a UTF-8
+#: name holds as itself -- under `LC_ALL=C` and a `PYTHONIOENCODING=cp1252`
+#: pipe as under a UTF-8 terminal. None for the table, which IS stdout.
+REPORT_ENCODING: str | None = None
+
+
+def printed_encoding(encoding: str | None = None) -> str:
+ """`encoding`, or the one the report is written for: `REPORT_ENCODING`
+ where `main` set one, else `sys.stdout`'s, which `pythonw` and a stream
+ a caller replaced may not have (#785)."""
+ return (encoding or REPORT_ENCODING
+ or getattr(sys.stdout, "encoding", None) or "utf-8")
+
+
+def prints_as_itself(char: str, encoding: str) -> bool:
+ """Whether a report written in `encoding` shows `char` as itself (#785):
+ a printable character that `ascii_text` does not flatten, and that the
+ encoding writes and reads back unchanged.
+
+ PRINTABLE, because a control, a line separator or a bidirectional mark
+ is not seen as itself on any terminal: it moves the line it is printed
+ on, and a reader cannot copy what they cannot see. And not U+FFFD, the
+ mark a lossy read leaves where a byte did not decode, which a row has
+ printed as `?` since #385: it stands for no character a path holds."""
+ if (char in FLATTENED or char == "\N{REPLACEMENT CHARACTER}"
+ or not char.isprintable()):
+ return False
+ try:
+ return char.encode(encoding).decode(encoding) == char
+ except (UnicodeError, LookupError):
+ return False
+
+
+def ascii_text(text: str, encoding: str | None = None) -> str:
+ """`text` with the typography this repository is written in flattened,
+ and each character the report cannot show as itself replaced by `?`.
Validator output is quoted into rows, and this repository's prose is full
of em dashes, curly quotes and ellipses. A Windows console in the machine's
- ANSI code page raises on the first of them, in the middle of a report whose
+ ANSI code page raised on the first of them, in the middle of a report whose
whole job is to be read -- so the few characters that actually occur are
- spelled out and anything else is replaced rather than raised on.
- """
- for source, target in (("—", "--"), ("–", "-"), ("‘", "'"),
- ("’", "'"), ("“", '"'), ("”", '"'),
- ("…", "..."), (" ", " ")):
+ spelled out in ASCII on every console.
+
+ ANY OTHER CHARACTER IS REPLACED ONLY WHERE IT CANNOT BE SHOWN (#785). It
+ was replaced on every console, so `é` was `?` under a UTF-8 locale, whose
+ terminal shows it, and so was every character of a path `quote_arg` had
+ quoted for a command: a keep line named `caf?`, which is not there. Now a
+ character is kept where `prints_as_itself` says `encoding` (the report's
+ own when None, `printed_encoding`) shows it, and is `?` elsewhere, as
+ every one was: an ASCII console still reads an ASCII report, and a UTF-8
+ one reads the name. A value `quote_arg` spelled holds no character this
+ changes, so this pass leaves it whole.
+ """
+ for source, target in FLATTENED.items():
text = text.replace(source, target)
- return text.encode("ascii", "replace").decode("ascii")
+ encoding = printed_encoding(encoding)
+ return "".join(char if char.isascii() or prints_as_itself(char, encoding)
+ else "?" for char in text)
+
+
+def path_bytes(char: str) -> bytes:
+ """The bytes one character of a path stands for on a POSIX disk (#785).
+
+ A byte of a name that is not in the encoding it was read with is carried
+ by `surrogateescape` as U+DC80..U+DCFF, how `os.fsdecode` reads a path
+ and how this file reads one git prints, and stands for that byte. Any
+ other character is in the filesystem encoding, which is how Python names
+ a path on disk, or in UTF-8 where that encoding has no such character,
+ which is how git wrote a name it printed (`LC_ALL=C` with Python's
+ UTF-8 mode off makes the filesystem encoding ASCII)."""
+ if 0xDC80 <= ord(char) <= 0xDCFF:
+ return bytes([ord(char) - 0xDC00])
+ try:
+ return char.encode(sys.getfilesystemencoding())
+ except UnicodeEncodeError:
+ return char.encode("utf-8", "surrogatepass")
#: A value `sh` needs no quoting for: letters, digits, and the punctuation a
@@ -703,7 +782,7 @@ def ascii_text(text: str) -> str:
def quote_arg(value, platform: str | None = None, *,
- always: bool = False) -> str:
+ always: bool = False, encoding: str | None = None) -> 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
@@ -760,20 +839,41 @@ def quote_arg(value, platform: str | None = None, *,
become an argument to `git`, where every command is a list run with
`shell=False` and the threat is argument injection, not a shell.
- AND IT NORMALIZES BEFORE IT QUOTES, NOT AFTER. `Row.__init__` runs
- `ascii_text` again, over the WHOLE assembled `next_command`, once every
- value in it already sits quoted -- so a curly apostrophe that reached
- this function unconverted came out the far side of THAT pass as a bare,
- undoubled one, breaking the very quoting it sat inside (Copilot, PR
- #102): `'O'Neill'` is an unterminated quotation to `sh`, and an
- un-doubled apostrophe is exactly as broken to PowerShell's own verbatim
- string. Normalizing HERE first leaves that later pass nothing to touch:
- `ascii_text` maps this repository's own typography to ASCII, so calling
- it twice on an already-ASCII string is a no-op. What the reader then sees
- is the ASCII FLATTENING of their path rather than its own bytes -- which
- is this file's PURE ASCII rule, stated at the top and true of every other
- string in the report, and a command that names a path plainly and is
- refused by name beats one no shell will parse.
+ A VALUE THAT IS NOT ASCII IS SPELLED AS THE PATH IT IS (#785). It was
+ flattened to ASCII first (Copilot, PR #102), because `Row.__init__`
+ runs `ascii_text` again over the whole assembled command, and a curly
+ apostrophe that pass found came out of it bare, breaking the quoting it
+ sat in. But the flattening named another path: every character that is
+ not ASCII became `?`, so a keep line under `Projets/Équipe` named
+ `Projets/?quipe`, which is not there, and the pasted line failed. Now
+ each character of such a value is one of two things:
+
+ - ITSELF, inside the quoting above, where `prints_as_itself` says the
+ report shows it: printable, not one of the typography `ascii_text`
+ flattens, and written by `encoding` (the report's own when None,
+ `printed_encoding`) -- for `sh`, as the very bytes the path holds
+ (`path_bytes`), so that what a terminal shows is what it pastes back;
+ - ESCAPED otherwise, and then the whole value is spelled that way. For
+ `sh` that is `$'...'`, every byte of such a character a three-digit
+ octal escape (`$'caf\303\251'`, `$'caf\351'` for a name that is not
+ UTF-8), which bash, zsh, ksh and a POSIX.1-2024 `sh` turn back into
+ those bytes; dash 0.5.12 (Ubuntu's `/bin/sh`, whose login shell is
+ bash) reads the `$` as itself. For PowerShell, which has no escape
+ inside a verbatim string, it is an expression in parentheses,
+ `('C:\Users\' + [char]0x00C9 + 'quipe')`, which is one argument to a
+ native command and a value to assign alike (`powershell_expression`).
+ On Windows that is rare: Python writes a console as UTF-8, which shows
+ every printable character, and only a stdout redirected to a file or a
+ pipe is in the ANSI code page.
+
+ So `ascii_text`'s later pass finds nothing in a value to change. The
+ report's encoding is stdout's for the table, and UTF-8 for `--json`,
+ whatever stdout's is (`REPORT_ENCODING`, review S2 on PR #792): a JSON
+ reader decodes each `\u` escape `json.dumps` writes into the character
+ it stands for, so a row's `next` holds a UTF-8 name as itself, and only
+ a byte that is not UTF-8, or a character `prints_as_itself` refuses, is
+ escaped there. The table escapes what its own encoding cannot write. A
+ value made of ASCII is quoted exactly as before, in both.
AND THE ALPHABET CHECK MUST COVER THE VALUE WHOLE. `$` is content to
match just before a trailing newline, so `.match` alone waved a value
@@ -806,10 +906,11 @@ def quote_arg(value, platform: str | None = None, *,
answer. PowerShell's dialect is NOT changed: it expands no `=word`, so
`UNQUOTED_NT` keeps its bare `=`, as in any other place in a word.
"""
- # ASCII first, so the alphabet check and the quoting below it both see
- # what `Row.__init__` will (Copilot, PR #102); see the docstring above.
- text = ascii_text(str(value))
+ text = str(value)
windows = (os.name if platform is None else platform) == "nt"
+ if not text.isascii():
+ # Never bare: neither alphabet holds a character that is not ASCII.
+ return quoted_beyond_ascii(text, windows, printed_encoding(encoding))
# `.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). And a leading `=` is never bare for `sh` (#532).
@@ -827,6 +928,94 @@ def quote_arg(value, platform: str | None = None, *,
return "'" + text.replace("'", "'\\''") + "'"
+def quoted_beyond_ascii(text: str, windows: bool, encoding: str) -> str:
+ """`quote_arg` for a value holding a character that is not ASCII (#785):
+ quoted as any other value where every such character `prints_as_itself`
+ -- for `sh`, as the bytes the path holds -- and escaped whole where one
+ does not. See `quote_arg`'s docstring.
+
+ ON POSIX A NAME IS READ AS UTF-8 FIRST (review S2 on PR #792). Under
+ `LC_ALL=C` with Python's UTF-8 mode off the filesystem encoding is
+ ASCII, so `os.fsdecode` carries each byte of `é` as a `surrogateescape`
+ byte of its own, and a report in UTF-8 -- `--json` -- could not show
+ it. Those bytes are read again as UTF-8, which joins each run that
+ spells a character into that character and leaves every other byte as
+ it was. A value that holds a surrogate `surrogateescape` never makes
+ (outside U+DC80..U+DCFF) cannot be written back to bytes like that, and
+ is left as it is: such a character is not printable, so it is escaped.
+
+ AND THE JOINED NAME IS KEPT ONLY WHERE IT NAMES THE SAME BYTES (review
+ M1 on PR #792, round 2). `path_bytes` writes a joined character in the
+ filesystem encoding, which is ASCII or UTF-8 under `C` and gives the
+ bytes back; but under a legacy multibyte one -- EUC-JP, Big5, EUC-KR --
+ a directory named `日` in UTF-8 (`\\xe6\\x97\\xa5`) is read in as three
+ escaped bytes, and the joined `日` is written `\\xc6\\xfc` in EUC-JP:
+ another directory. So the join is checked byte for byte against the
+ value as it was read, and the escaped bytes stay where it differs."""
+ if not windows:
+ try:
+ joined = text.encode("utf-8", "surrogateescape").decode(
+ "utf-8", "surrogateescape")
+ except UnicodeEncodeError:
+ joined = text
+ if (b"".join(map(path_bytes, joined))
+ == b"".join(map(path_bytes, text))):
+ text = joined
+
+ def shown(char: str) -> bool:
+ if char.isascii():
+ return True
+ if not prints_as_itself(char, encoding):
+ return False
+ return windows or char.encode(encoding) == path_bytes(char)
+ if all(shown(char) for char in text):
+ if windows:
+ return powershell_verbatim(text)
+ return "'" + text.replace("'", "'\\''") + "'"
+ if windows:
+ return powershell_expression(text, shown)
+ return "$'" + "".join(
+ char if char.isascii() and char.isprintable() and char not in "\\'"
+ else "".join(f"\\{byte:03o}" for byte in path_bytes(char))
+ for char in text) + "'"
+
+
+def powershell_verbatim(text: str) -> str:
+ """`text` in PowerShell's verbatim string, each of the single quotes
+ PowerShell reads as one doubled, as `repo_shape.shell_quoted` doubles
+ them: the curly ones end the string too (#486)."""
+ return "'" + "".join(char * 2 if char in POWERSHELL_SINGLE_QUOTES
+ else char for char in text) + "'"
+
+
+def powershell_expression(text: str, shown) -> str:
+ """`text` as a PowerShell expression, `('...' + [char]0x00C9 + '...')`,
+ where a character of it is not `shown` (#785).
+
+ Each such character is a `[char]` of each of its UTF-16 code units, and
+ the characters between them are verbatim strings, joined by `+`. The
+ FIRST operand is always a string -- `''` where the value starts with an
+ escaped character -- because `+` on two `[char]`s adds them as numbers,
+ and a string on the left makes every `+` after it a concatenation. A
+ printable ASCII character is always shown; a control is escaped too, as
+ it is in `sh`'s `$'...'`."""
+ parts, run = [], ""
+ for char in text:
+ if char.isprintable() and shown(char):
+ run += char
+ continue
+ if run or not parts:
+ parts.append(powershell_verbatim(run))
+ run = ""
+ units = char.encode("utf-16-le", "surrogatepass")
+ for at in range(0, len(units), 2):
+ unit = int.from_bytes(units[at:at + 2], "little")
+ parts.append(f"[char]0x{unit:04X}")
+ if run:
+ parts.append(powershell_verbatim(run))
+ return "(" + " + ".join(parts) + ")"
+
+
# ---------------------------------------------------------------------------
# A row, a check, and the registry they live in
# ---------------------------------------------------------------------------
@@ -853,9 +1042,10 @@ def __init__(self, check_id: str, label: str, status: str, reason: str,
self.label = label
self.status = status
self.reason = ascii_text(reason)
- # `quote_arg` ascii-normalizes every value it quotes BEFORE it
- # quotes it (Copilot, PR #102), so this pass has nothing left to do
- # to a `next_command` already built from it -- it only does its
+ # `quote_arg` spells every value it quotes in characters this pass
+ # keeps -- each one itself only where `ascii_text` keeps it, and the
+ # value escaped otherwise (#785) -- so this pass has nothing to do
+ # to a value in a `next_command` or a `why`. It only does its
# ordinary job, flattening this repository's own typography, on the
# literal words a caller wrote around those values.
self.next_command = ascii_text(next_command) if next_command else None
@@ -10080,6 +10270,9 @@ def main(argv: list[str] | None = None) -> int:
# a name stdout cannot encode rather than raising on it (#214).
escape_what_stdout_cannot_encode()
args = build_parser().parse_args(argv)
+ # Before any row is built: `quote_arg` and `ascii_text` read it (#785).
+ global REPORT_ENCODING
+ REPORT_ENCODING = "utf-8" if args.as_json else None
if [marker for marker in SHAPE_MARKERS
if not (SHAPE_ROOT / marker).exists()]:
return not_in_the_standard("")
diff --git a/tests/readme-cap.d/785.txt b/tests/readme-cap.d/785.txt
new file mode 100644
index 00000000..6433e6ef
--- /dev/null
+++ b/tests/readme-cap.d/785.txt
@@ -0,0 +1,11 @@
++2
+2026-10-17
+Two lines for one sentence under "Windows, natively" (#785, review S1 on #792).
+Since #785 `shape-doctor.py` writes a path it cannot print as itself, in a
+POSIX line, as `$'…'` with an octal escape for each byte, so the line names
+that path rather than one with `?` in it. bash, zsh and ksh read that form;
+dash, which is `/bin/sh` on Debian and Ubuntu, reads the `$` as itself and
+hands the command another path. The section says printed commands are quoted
+for a POSIX shell, and `sh -c` is dash on those systems, so it has to say
+which shells read the form. The two lines name the form, the three shells
+that read it and the one that does not, and nothing else.
diff --git a/tests/test_doctor_non_ascii_paths.py b/tests/test_doctor_non_ascii_paths.py
new file mode 100644
index 00000000..6ba006b6
--- /dev/null
+++ b/tests/test_doctor_non_ascii_paths.py
@@ -0,0 +1,435 @@
+# SPDX-License-Identifier: Apache-2.0
+"""`shape-doctor.py` prints a path that is not ASCII as THAT path, in every
+command it hands a reader (#785).
+
+THE DEFECT. `ascii_text` ended in `encode("ascii", "replace")`, and both
+`quote_arg` and `Row` ran it, so each character of a path that is not ASCII
+printed as `?`, on every console, in the table and in `--json` alike. A
+holder under `Projets/Équipe` was handed `--root '.../Projets/?quipe/...'`
+and the keep line `git -C '.../?quipe/InkRouter/members/IRRS' branch NAME`;
+no such directory is there, so the pasted line failed -- under a UTF-8
+locale, whose terminal shows `é`, as much as under `LC_ALL=C`. A name that
+is not UTF-8 at all (`\\xc9quipe`, a legacy code page) fared the same.
+
+WHAT IT DOES NOW. `quote_arg` spells such a value as itself where the
+report's encoding writes the bytes the path holds, and escapes it whole
+where it does not: `$'...'` with octal escapes for `sh`, a `[char]`
+expression for PowerShell. `ascii_text` replaces only what the report cannot
+show. Each line is asserted here as a reader uses it: the table's bytes and
+the `--json` value, pasted into bash -- `sh` on Ubuntu is dash, which has no
+`$'...'` -- under a UTF-8 locale, under `LC_ALL=C`, and under `LC_ALL=C`
+with Python's UTF-8 mode off, where its filesystem encoding is ASCII, for a
+directory named in UTF-8 and one named in Latin-1. The NEXT is also read
+with stdout in cp1252 and in ASCII, an encoding that is not the filesystem's
+(review S3 on PR #792), and run in dash where the report shows the name as
+itself, which is what README says dash can run.
+
+The spellings are asserted on every OS. The runs are `WINDOWS_SKIP`: a
+Windows command line is text and cannot carry the byte 0xC9 alone. No
+PowerShell is run here; its spelling is read back by `powershell_value`.
+
+THE FIXTURES ARE `test_shape_doctor`'s, imported rather than rebuilt.
+"""
+
+from __future__ import annotations
+
+import json
+import os
+import re
+import shutil
+import subprocess
+import sys
+
+from pathlib import Path
+
+import pytest
+
+from conftest import REPO, WINDOWS_SKIP, blank_unnamed_pin_sources
+from test_doctor_gitmodules_not_utf8 import listed_locale
+from test_doctor_legs_members_next import (
+ paste_env, commit_in, detached_member, emptied_with_its_repository_kept,
+)
+from test_family_printed_quoting import powershell_words
+from test_shape_doctor import ( # noqa: F401
+ DOCTOR, FAMILY_MEMBER, FAMILY_NAME, doctor_module, holder, standard,
+)
+
+#: A byte of a name that is not UTF-8, as `surrogateescape` reads it in.
+LATIN_1_E_ACUTE = chr(0xDCC9)
+#: The apostrophe this repository's own prose is written with, which
+#: `ascii_text` flattens, so a value holding it is always escaped.
+CURLY = chr(0x2019)
+
+#: `id: (value, encoding, spelling, bytes)` for `sh`: what `quote_arg`
+#: prints for `value` to a report in `encoding`, and the bytes a shell makes
+#: of that spelling.
+POSIX_CASES = {
+ "utf-8-shown": ("/srv/Équipe", "utf-8", "'/srv/Équipe'",
+ "/srv/Équipe".encode("utf-8")),
+ "ascii-escaped": ("/srv/Équipe", "ascii", r"$'/srv/\303\211quipe'",
+ "/srv/Équipe".encode("utf-8")),
+ # cp1252 holds `É`, as the byte 0xC9: not the bytes this path holds.
+ "cp1252-escaped": ("/srv/Équipe", "cp1252", r"$'/srv/\303\211quipe'",
+ "/srv/Équipe".encode("utf-8")),
+ "latin-1-byte": (f"/srv/{LATIN_1_E_ACUTE}quipe", "utf-8",
+ r"$'/srv/\311quipe'", b"/srv/\xc9quipe"),
+ "curly-apostrophe": (f"/srv/Dana{CURLY}s work", "utf-8",
+ r"$'/srv/Dana\342\200\231s work'",
+ f"/srv/Dana{CURLY}s work".encode("utf-8")),
+ "apostrophe-and-backslash": ("/srv/l'Équipe\\x", "ascii",
+ r"$'/srv/l\047\303\211quipe\134x'",
+ "/srv/l'Équipe\\x".encode("utf-8")),
+ "outside-cp1252": ("/srv/日本", "cp1252",
+ r"$'/srv/\346\227\245\346\234\254'",
+ "/srv/日本".encode("utf-8")),
+ # `é` as `os.fsdecode` reads it with an ASCII filesystem encoding: two
+ # `surrogateescape` bytes, read again as the UTF-8 they spell.
+ "utf-8-read-byte-by-byte": (f"/srv/caf{chr(0xDCC3)}{chr(0xDCA9)}",
+ "utf-8", "'/srv/café'",
+ "/srv/café".encode("utf-8")),
+ # A surrogate `surrogateescape` never makes: escaped, never raised on.
+ "stray-surrogate": (f"/srv/a{chr(0xD800)}", "utf-8",
+ r"$'/srv/a\355\240\200'", b"/srv/a\xed\xa0\x80"),
+}
+
+#: The same cases, as `pytest` parameters: the first three, or the last.
+SPELLED = [pytest.param(*case[:3], id=name)
+ for name, case in POSIX_CASES.items()]
+SPELLED_AND_RUN = [pytest.param(*case[1:], id=name)
+ for name, case in POSIX_CASES.items()]
+
+#: `(value, encoding, spelling)` for PowerShell, which compares no bytes:
+#: a Windows path is text.
+WINDOWS_CASES = [
+ pytest.param("C:\\Équipe", "utf-8", "'C:\\Équipe'", id="utf-8-shown"),
+ pytest.param("C:\\Équipe", "cp1252", "'C:\\Équipe'", id="cp1252-shown"),
+ pytest.param("C:\\Équipe", "ascii",
+ "('C:\\' + [char]0x00C9 + 'quipe')", id="ascii-escaped"),
+ pytest.param("Équipe", "ascii", "('' + [char]0x00C9 + 'quipe')",
+ id="first-operand-a-string"),
+ pytest.param("C:\\日本", "cp1252",
+ "('C:\\' + [char]0x65E5 + [char]0x672C)",
+ id="outside-cp1252"),
+ pytest.param(f"C:\\Dana{CURLY}s", "utf-8",
+ "('C:\\Dana' + [char]0x2019 + 's')", id="curly-apostrophe"),
+ pytest.param("C:\\" + chr(0x1F600), "cp1252",
+ "('C:\\' + [char]0xD83D + [char]0xDE00)",
+ id="two-code-units"),
+ pytest.param("C:\\l'É" + chr(0x201A), "utf-8",
+ "'C:\\l''É" + chr(0x201A) * 2 + "'",
+ id="quotes-doubled"),
+]
+
+
+@pytest.fixture(scope="module")
+def module():
+ return doctor_module(REPO)
+
+
+@pytest.mark.parametrize(("value", "encoding", "spelling"), SPELLED)
+def test_785_a_value_is_spelled_for_sh(module, value, encoding, spelling):
+ assert module.quote_arg(value, "posix", encoding=encoding) == spelling
+
+
+#: A legacy multibyte filesystem encoding, and a character it holds that a
+#: directory may be named in UTF-8 instead: the joined character would be
+#: written in that encoding, as other bytes (review M1 on PR #792).
+LEGACY = [pytest.param("euc_jp", "日", id="euc-jp"),
+ pytest.param("big5", "日", id="big5"),
+ pytest.param("euc_kr", "€", id="euc-kr")]
+
+
+@pytest.mark.parametrize("report", ["filesystem", "utf-8"])
+@pytest.mark.parametrize(("filesystem", "char"), LEGACY)
+def test_785_a_utf_8_name_under_a_legacy_filesystem_keeps_its_bytes(
+ module, monkeypatch, report, filesystem, char):
+ """The UTF-8 bytes of `char`, read in as escaped bytes under that
+ filesystem encoding, stay those bytes in the table (written in that
+ encoding) and in `--json` (in UTF-8): never `char` itself, whose
+ `path_bytes` there are another directory's."""
+ monkeypatch.setattr(sys, "getfilesystemencoding", lambda: filesystem)
+ raw = char.encode("utf-8")
+ value = "/srv/" + raw.decode(filesystem, "surrogateescape")
+ escaped = "".join(f"\\{byte:03o}" for byte in raw)
+ encoding = filesystem if report == "filesystem" else report
+ assert module.quote_arg(value, "posix", encoding=encoding) == (
+ f"$'/srv/{escaped}'")
+
+
+@pytest.mark.parametrize(("value", "encoding", "spelling"), WINDOWS_CASES)
+def test_785_a_value_is_spelled_for_powershell(module, value, encoding,
+ spelling):
+ assert module.quote_arg(value, "nt", encoding=encoding) == spelling
+
+
+@pytest.mark.parametrize(("value", "encoding", "spelling"), SPELLED)
+def test_785_the_rows_own_pass_leaves_a_spelling_whole(module, value,
+ encoding, spelling):
+ """`Row.__init__` runs `ascii_text` over every command and `why`, after
+ the values in it are quoted: it must find nothing in one to change."""
+ spelled = module.quote_arg(value, "posix", encoding=encoding)
+ assert module.ascii_text(spelled, encoding) == spelling
+
+
+def powershell_value(spelling: str) -> str:
+ """The value a PowerShell spelling of `quote_arg`'s stands for: one
+ verbatim string, or an expression of verbatim strings and `[char]`
+ code units joined by `+`. A reader of those two forms, not of
+ PowerShell."""
+ if not spelling.startswith("("):
+ return powershell_words(spelling)[0]
+ units = bytearray()
+ for part in re.findall(r"'(?:[^']|'')*'|\[char\]0x[0-9A-F]{4}",
+ spelling[1:-1]):
+ if part.startswith("'"):
+ units += powershell_words(part)[0].encode("utf-16-le")
+ else:
+ units += int(part[8:], 16).to_bytes(2, "little")
+ return units.decode("utf-16-le")
+
+
+@pytest.mark.parametrize(("value", "encoding", "spelling"), WINDOWS_CASES)
+def test_785_a_powershell_spelling_stands_for_the_value(module, value,
+ encoding, spelling):
+ assert powershell_value(module.quote_arg(value, "nt",
+ encoding=encoding)) == value
+
+
+def test_785_a_sentence_keeps_what_the_report_can_show(module):
+ assert module.ascii_text("members/café: ok", "utf-8") == (
+ "members/café: ok")
+
+
+@pytest.mark.parametrize(("text", "encoding", "shown"), [
+ pytest.param("members/café", "ascii", "members/caf?", id="ascii"),
+ pytest.param(f"caf{LATIN_1_E_ACUTE}", "utf-8", "caf?", id="not-utf-8"),
+ pytest.param(f"a{chr(0x202E)}b", "utf-8", "a?b", id="bidi-control"),
+ # The mark a lossy read leaves, printed as `?` since #385.
+ pytest.param(f"caf{chr(0xFFFD)}", "utf-8", "caf?", id="lossy-read"),
+ pytest.param(f"a {chr(0x2014)} {CURLY}b{CURLY}", "utf-8", "a -- 'b'",
+ id="typography"),
+])
+def test_785_a_sentence_replaces_what_the_report_cannot_show(module, text,
+ encoding, shown):
+ assert module.ascii_text(text, encoding) == shown
+
+
+@WINDOWS_SKIP
+@pytest.mark.parametrize("shell", ["bash", "zsh"])
+@pytest.mark.parametrize(("encoding", "spelling", "expected"),
+ SPELLED_AND_RUN)
+def test_785_a_shell_turns_the_spelling_back_into_the_paths_bytes(
+ shell, encoding, spelling, expected):
+ """The spelling as a report in `encoding` printed it, as bytes, so the
+ one that shows `É` hands the shell the bytes a terminal would; and
+ `expected`, the bytes of the path it was spelled from."""
+ if shutil.which(shell) is None:
+ pytest.skip(f"no {shell} here")
+ line = f"printf '%s\\0' {spelling}".encode(encoding)
+ printed = subprocess.run([shell, "-c", line],
+ capture_output=True, check=True,
+ stdin=subprocess.DEVNULL)
+ assert printed.stdout == expected + b"\0"
+
+
+# --- the doctor's own lines, pasted -----------------------------------------
+
+#: The directory a holder sits in, named in UTF-8 and in Latin-1.
+PARENTS = [pytest.param("Équipe".encode("utf-8"), id="utf-8-name"),
+ pytest.param(b"\xc9quipe", id="latin-1-name")]
+
+#: The locales the doctor and the pasted line run under: a UTF-8 one, `C`
+#: (where Python turns its UTF-8 mode on), and `C` with it off.
+LOCALES = [pytest.param("UTF-8", {}, id="utf-8-locale"),
+ pytest.param("C", {}, id="C"),
+ pytest.param("C", {"PYTHONUTF8": "0"}, id="C-ascii-fs")]
+
+#: Those, and a UTF-8 locale whose stdout is in cp1252 or in ASCII: Linux's
+#: stand-in for a Windows stdout redirected in the ANSI code page, an
+#: encoding that is not the filesystem's (review S3 on PR #792).
+STDOUTS = [*LOCALES,
+ pytest.param("UTF-8", {"PYTHONIOENCODING": "cp1252"},
+ id="cp1252-stdout"),
+ pytest.param("UTF-8", {"PYTHONIOENCODING": "ascii"},
+ id="ascii-stdout")]
+
+#: Where a line is read from: the table's bytes, or the `--json` value,
+#: which a reader's UTF-8 terminal hands the shell as UTF-8.
+SOURCES = ["table", "json"]
+
+#: A keep line naming a real directory: `git -C branch NAME`, or
+#: `git <--git-dir=...> [<--work-tree=...>] branch NAME `.
+KEEP = re.compile(rb"git (?:-C )?\S+(?: \S+)? branch NAME(?: [0-9a-f]{40})?")
+
+
+def environ(codeset: str, changes: dict) -> dict:
+ """`paste_env()` under `codeset`, with no locale or Python encoding the
+ person running the suite set, no lane and no pin-source override."""
+ env = {key: value for key, value in paste_env().items()
+ if not key.startswith(("LC_", "LANG", "PYTHONIO", "PYTHONUTF8"))}
+ env.update(LANES_LANE="", LC_ALL=listed_locale(codeset), **changes)
+ return blank_unnamed_pin_sources(env, {})
+
+
+def holder_under(holder, tmp_path: Path, parent: bytes) -> Path:
+ """A copy of the holder at `//InkRouter`, the directory
+ named in `parent`'s own bytes, its member detached."""
+ where = Path(os.fsdecode(os.path.join(os.fsencode(tmp_path), parent)))
+ where.mkdir()
+ root = where / FAMILY_NAME
+ shutil.copytree(holder["root"], root, symlinks=True)
+ detached_member(root)
+ return root
+
+
+def members_line(standard, root: Path, env: dict, source: str,
+ field: str) -> bytes:
+ """The members row's NEXT or WHY (`field`), as the bytes a reader
+ pastes: the table's line, or the `--json` value in UTF-8."""
+ if source == "json":
+ rows = json.loads(run_doctor(standard, root, env, "--json"))["rows"]
+ row = next(row for row in rows if row["id"] == "members")
+ return row[field.lower()].encode("utf-8")
+ label = None
+ for line in run_doctor(standard, root, env).splitlines():
+ if re.match(rb" \S", line):
+ label = line.split()[1]
+ elif label == b"members" and line.split()[:1] == [field.encode()]:
+ return line.split(field.encode(), 1)[1].strip()
+ raise AssertionError(f"no {field} on the members row")
+
+
+def run_doctor(standard, root: Path, env: dict, *args: str) -> bytes:
+ result = subprocess.run(
+ [sys.executable, os.fsencode(standard / DOCTOR), "--root",
+ os.fsencode(root), *args], capture_output=True, env=env,
+ stdin=subprocess.DEVNULL, check=False)
+ assert b"Traceback" not in result.stderr, result.stderr
+ return result.stdout
+
+
+def bash(line: bytes, env: dict, cwd: Path,
+ shell: str = "bash") -> subprocess.CompletedProcess:
+ """`line` pasted into bash, or `shell`, from a directory that is not
+ the root."""
+ cwd.mkdir(exist_ok=True)
+ return subprocess.run([shell, "-c", line], cwd=str(cwd), env=env,
+ capture_output=True, check=False,
+ stdin=subprocess.DEVNULL)
+
+
+def next_root(standard, holder, tmp_path: Path, parent: bytes, env: dict,
+ source: str, shell: str = "bash") -> tuple[bytes, bytes]:
+ """`(the --root word as `shell` reads the members row's NEXT, the root's
+ own bytes)`, for a holder under `parent` whose member is detached with
+ a commit on it: `printf` in place of the interpreter prints each word."""
+ root = holder_under(holder, tmp_path, parent)
+ commit_in(root / "members" / FAMILY_MEMBER)
+ line = members_line(standard, root, env, source, "NEXT")
+ words = bash(b"printf '%s\\0' " + line.split(b" ", 1)[1], env,
+ tmp_path / "away", shell).stdout.split(b"\0")
+ return words[words.index(b"--root") + 1], os.fsencode(root.resolve())
+
+
+@WINDOWS_SKIP
+@pytest.mark.parametrize("source", SOURCES)
+@pytest.mark.parametrize("state", ["detached", "emptied"])
+@pytest.mark.parametrize("parent", PARENTS)
+@pytest.mark.parametrize(("codeset", "changes"), LOCALES)
+def test_785_a_keep_line_naming_the_path_keeps_the_commit_as_printed(
+ standard, holder, tmp_path, source, state, parent, codeset,
+ changes):
+ """A commit on the member's detached HEAD, in its mount (`git -C`,
+ #413 b) or in the repository git kept when the mount was emptied
+ (`--git-dir`, #413 a): the keep line the row's WHY names keeps it on a
+ branch, run as printed."""
+ root = holder_under(holder, tmp_path, parent)
+ mount = f"members/{FAMILY_MEMBER}"
+ commit = (commit_in(root / mount) if state == "detached"
+ else emptied_with_its_repository_kept(root, mount))
+ env = environ(codeset, changes)
+ keeps = [line for line in KEEP.findall(
+ members_line(standard, root, env, source, "WHY"))
+ if b"LEG" not in line and b"REPO" not in line]
+ assert len(keeps) == 1, keeps
+ kept = bash(keeps[0].replace(b"NAME", b"kept"), env, tmp_path / "away")
+ assert kept.returncode == 0, kept.stderr
+ branch = subprocess.run(
+ ["git", "--git-dir", os.fsencode(root / ".git" / "modules" / mount),
+ "rev-parse", "--verify", "-q", "refs/heads/kept"],
+ capture_output=True, check=False)
+ assert branch.stdout.decode().strip() == commit
+
+
+@WINDOWS_SKIP
+@pytest.mark.parametrize("source", SOURCES)
+@pytest.mark.parametrize("parent", PARENTS)
+@pytest.mark.parametrize(("codeset", "changes"), STDOUTS)
+def test_785_the_members_next_names_the_real_root(standard, holder, tmp_path,
+ source, parent, codeset,
+ changes):
+ """The NEXT's `--root`, as bash reads it, is the root's own bytes, and
+ with stdout in cp1252 or ASCII too: a table that printed `É` in
+ cp1252 would hand the shell 0xC9, which is not the byte this path
+ holds."""
+ word, root = next_root(standard, holder, tmp_path, parent,
+ environ(codeset, changes), source)
+ assert word == root
+
+
+#: The cells dash can run, which are the ones that show a UTF-8 name as
+#: itself: the table under a UTF-8 locale and under `C`, where Python's UTF-8
+#: mode is on, and `--json` under every stdout (review N1 on PR #792, both
+#: rounds).
+DASH_CELLS = [*(pytest.param("table", *cell.values, id=f"table-{cell.id}")
+ for cell in LOCALES[:2]),
+ *(pytest.param("json", *cell.values, id=f"json-{cell.id}")
+ for cell in STDOUTS)]
+
+
+@WINDOWS_SKIP
+@pytest.mark.parametrize(("source", "codeset", "changes"), DASH_CELLS)
+def test_785_dash_runs_a_next_that_shows_the_name_as_itself(
+ standard, holder, tmp_path, source, codeset, changes):
+ """dash has no `$'...'`, as README says, so the lines it can run are
+ the ones that show a name as itself: a UTF-8 name, in the table where
+ stdout can show it, and in `--json` whatever stdout is."""
+ if shutil.which("dash") is None:
+ pytest.skip("no dash here")
+ word, root = next_root(standard, holder, tmp_path,
+ "Équipe".encode("utf-8"),
+ environ(codeset, changes), source, "dash")
+ assert word == root
+
+
+@WINDOWS_SKIP
+@pytest.mark.parametrize(("codeset", "changes"), STDOUTS)
+def test_785_json_holds_a_utf_8_name_as_itself_whatever_stdout_holds(
+ standard, holder, tmp_path, codeset, changes):
+ """`--json` is written for a reader who decodes it, so its `next`
+ holds a UTF-8 name as itself whatever stdout's encoding is, and under
+ `LC_ALL=C` with Python's UTF-8 mode off, where the name is read in as
+ bytes (review S2 on PR #792)."""
+ root = holder_under(holder, tmp_path, "Équipe".encode("utf-8"))
+ commit_in(root / "members" / FAMILY_MEMBER)
+ line = members_line(standard, root, environ(codeset, changes), "json",
+ "NEXT")
+ assert b"--root '" + os.fsencode(root.resolve()) + b"'" in line
+
+
+@WINDOWS_SKIP
+@pytest.mark.parametrize("parent", PARENTS)
+@pytest.mark.parametrize(("codeset", "changes"), LOCALES)
+def test_785_the_members_next_runs_as_printed_and_cures_the_row(
+ standard, holder, tmp_path, parent, codeset, changes):
+ """The holder's own bootstrap, pasted from the table, brings the
+ detached member back to its pin, and the row is asked again."""
+ root = holder_under(holder, tmp_path, parent)
+ commit_in(root / "members" / FAMILY_MEMBER)
+ env = environ(codeset, changes)
+ result = bash(members_line(standard, root, env, "table", "NEXT"), env,
+ tmp_path / "away")
+ assert result.returncode == 0, result.stdout + result.stderr
+ rows = json.loads(run_doctor(standard, root, env, "--json"))["rows"]
+ assert next(row for row in rows if row["id"] == "members")[
+ "status"] == "ok"
diff --git a/tests/test_shape_doctor.py b/tests/test_shape_doctor.py
index e48685aa..10722cfd 100644
--- a/tests/test_shape_doctor.py
+++ b/tests/test_shape_doctor.py
@@ -10089,22 +10089,26 @@ def test_the_interpreter_named_is_the_asked_for_platforms(platform, expected):
CURLY_APOSTROPHE = "O\u2019Neill"
-def test_a_curly_apostrophe_is_normalized_before_it_is_quoted():
- """`quote_arg` must ascii-normalize FIRST, or `Row.__init__` breaks it.
+@pytest.mark.parametrize(("platform", "spelled"), [
+ ("posix", r"$'O\342\200\231Neill'"),
+ ("nt", "('O' + [char]0x2019 + 'Neill')"),
+])
+def test_a_curly_apostrophe_is_escaped_where_the_row_cannot_reach_it(
+ platform, spelled):
+ """`quote_arg` must leave `Row.__init__` nothing to change in a value.
`Row.__init__` runs `ascii_text` again, after quoting, over the whole
assembled command -- so a curly apostrophe that reached this function
unconverted came back out of THAT pass as a bare, undoubled one, on the
far side of the quoting it sat inside. `'O'Neill'` is an unterminated
quotation to `sh`, and PowerShell's own verbatim string is exactly as
- broken by an apostrophe that never got doubled. Normalizing here first
- means the value quoted is already plain ASCII, so that later pass has
- nothing left to do.
+ broken by an apostrophe that never got doubled (Copilot, PR #102). It was
+ flattened to `'` here first, which named another path; it is ESCAPED
+ now, in ASCII the later pass leaves alone, and stands for this one
+ (#785, `tests/test_doctor_non_ascii_paths.py` runs it).
"""
module = doctor_module(REPO)
- posix = module.quote_arg(CURLY_APOSTROPHE, "posix")
- assert shlex.split(posix) == ["O'Neill"], posix
- assert module.quote_arg(CURLY_APOSTROPHE, "nt") == "'O''Neill'"
+ assert module.quote_arg(CURLY_APOSTROPHE, platform) == spelled
def test_a_trailing_newline_is_quoted_not_waved_through():
diff --git a/tests/test_stdout_encoding.py b/tests/test_stdout_encoding.py
index 621b43c7..252d1d51 100644
--- a/tests/test_stdout_encoding.py
+++ b/tests/test_stdout_encoding.py
@@ -312,7 +312,9 @@ def _adopt_check(tmp_path: Path, request) -> tuple:
def _doctor(tmp_path: Path, request) -> tuple:
- """The doctor's rows are ASCII already; its `root` line was not."""
+ """The doctor's `root` line names the root as it is. (Its rows spell a
+ name too since #785: as itself where stdout can show it, escaped in a
+ command and `?` in a sentence where it cannot.)"""
root = tmp_path / NAME
root.mkdir()
(root / "README.md").write_text("# here\n", encoding="utf-8")