Skip to content
Merged
9 changes: 5 additions & 4 deletions scaffold-project.py
Original file line number Diff line number Diff line change
Expand Up @@ -1725,10 +1725,11 @@ def _neutral_pin_entry(product: str) -> str:

THE READER IS ASKED, as `family.py` asks it of a member's id (#259),
rather than a list of the words it takes for something else, and it is
asked of the LIST ENTRY: ` - -` is a list of its own, where `id: -` is
the text. A name it reads back bare, `openGlass` and every other plain
name, is the line it always was; `007`, `2026`, `1.5`, `true` and `null`
are double-quoted, and a double-quoted name always reads back.
asked of the LIST ENTRY: ` - - a` is a list of its own, where `id: - a`
is the text (#715; `id: -` is refused since #393). A name it reads back
bare, `openGlass` and every other plain name, is the line it always was;
`007`, `2026`, `1.5`, `true` and `null` are double-quoted, and a
double-quoted name always reads back.
"""
try:
if parse_yaml(f"neutral_product_pins:\n - {product}\n") \
Expand Down
19 changes: 18 additions & 1 deletion scripts/bump-leg.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@
COMMIT_RE, SAFE_PATH_RE, Refusal, checked_value, git_out, load_yaml,
recorded_gitlink, repo_basename, shown_path, spelled, tree_digest,
)
# On its own line, so the list above, which other changes extend, is
# not a line two of them both rewrite (#715).
from shape_materialize import readable_branch # noqa: E402
from shape_materialize import ( # noqa: E402
CommandFailed, captured_child_environment, check_program, commit_trailers,
escape_what_stdout_cannot_encode, is_nested_key, pin_values_in,
Expand Down Expand Up @@ -243,7 +246,21 @@ def display_name(self) -> str:

@property
def tracking(self) -> str:
return str(self.data.get("tracking_branch") or "main")
"""The tracking branch, read through `readable_branch` (#715): a
name holding a character that does not print or is white space is
`unseen-branch`, exit 2, and any other name is taken as it is.

Compared raw, `tracking_branch: main` followed by U+00A0, which the
reader reads as YAML does since #715, was not `main`, so
`_branch_to_bump_from` let a commit be made on `main`. This tool
never hands the name to a command: it compares it
(`_branch_to_bump_from`, the dry run's last line) and prints it. So
`checked_value`'s pattern, which #823's first fix round read it
through, is not asked: it refused `feature/` and CJK, `a&b` and
every other name git creates and the root's own gate passes. Every
run asks this before it fetches or writes."""
return readable_branch("tracking_branch",
self.data.get("tracking_branch") or "main")


# ---------------------------------------------------------------------------
Expand Down
100 changes: 78 additions & 22 deletions scripts/repo_shape.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,10 +207,11 @@ def __init__(self, message: str, remediation: str = ""):
# with the `-` and `+` chomping indicators accepted and `-` honoured).
#
# REFUSED (raises YamlError): tabs used for indentation, anchors and aliases
# (`&`, `*`), tags (`!`), merge keys (`<<`), explicit keys (`? `), a
# single-pair mapping in a flow sequence (`[a: b]`, #392), and more than one
# document. Each of those has a meaning this reader would have to invent, and
# an invented meaning in a validator is a wrong answer with a confident tone.
# (`&`, `*`), tags (`!`), merge keys (`<<`), explicit keys (`? `), an empty
# block key (`: a`, #715), a single-pair mapping in a flow sequence (`[a: b]`,
# #392), and more than one document. Each of those has a meaning this reader
# would have to invent, and an invented meaning in a validator is a wrong
# answer with a confident tone.
#
# A QUOTE OPENS A QUOTED SCALAR ONLY WHERE A SCALAR BEGINS, as in YAML; in
# the middle of a plain scalar (`it's`) it is a character of it (#249). A
Expand All @@ -231,7 +232,22 @@ def __init__(self, message: str, remediation: str = ""):
# `@a: 1`, and `|` or `>` opening a plain scalar. See `_scalar`,
# `_refuse_entry_indicator` and `_split_flow`.

_DASH_RE = re.compile(r"^-(\s+|$)")
#: YAML'S WHITE SPACE IS THE SPACE AND THE TAB (#715), and every strip of a
#: line below names it. `str.strip()` with no argument, like the regex `\s`,
#: also takes U+001F and sixteen Unicode spaces (U+00A0 NO-BREAK SPACE,
#: U+1680, U+2000 to U+200A, U+202F, U+205F and U+3000 IDEOGRAPHIC SPACE),
#: which YAML keeps as text. So `k: \u3000]` was read as `k: ]` and refused
#: quoting `']'`, a text the file does not hold, `k: [a,\u3000,b]` was
#: refused as an empty entry, and `k: \xa0` read as no value, where PyYAML
#: reads `'\u3000]'`, `['a', '\u3000', 'b']` and `'\xa0'`. U+001F, a control
#: character, is now text, as a raw ESC or NUL already is. The other
#: characters `str.strip()` takes are line breaks to `str.splitlines`, which
#: ends a line at them, so none of them reaches a strip.
_YAML_WHITE = " \t"

#: A sequence entry's `-`, then YAML's white space (`_YAML_WHITE`, #715) or
#: the end of the line: `-\u3000a` is a plain scalar, and no entry.
_DASH_RE = re.compile(r"^-([ \t]+|$)")


class _Rec:
Expand Down Expand Up @@ -329,8 +345,8 @@ def _strip_comment(text: str) -> str:
(`_unquoted`), at the start of the text or after a space or tab."""
for i, ch in _unquoted(text):
if ch == "#" and (i == 0 or text[i - 1] in " \t"):
return text[:i].rstrip()
return text.rstrip()
return text[:i].rstrip(_YAML_WHITE)
return text.rstrip(_YAML_WHITE)


def _split_key(text: str) -> tuple[str, str] | None:
Expand All @@ -347,14 +363,15 @@ def _split_key(text: str) -> tuple[str, str] | None:
bracket = False
for i, ch in _unquoted(text):
if ch in "[{":
if not text[:i].strip():
if not text[:i].strip(_YAML_WHITE):
return None # a flow collection cannot be a key in this subset
bracket = True
elif ch == ":" and (i + 1 == len(text) or text[i + 1] in " \t"):
key = text[:i].strip(_YAML_WHITE)
if bracket:
raise YamlError("a key holding `[` or `{` is not supported: "
f"{text[:i].strip()!r}")
return _unquote_key(text[:i].strip()), text[i + 1:].strip()
f"{key!r}")
return _unquote_key(key), text[i + 1:].strip(_YAML_WHITE)
return None


Expand Down Expand Up @@ -551,7 +568,7 @@ def _scalar(raw: str) -> Any:
a scalar (`k: a]`, `- see [1]`, `k: a, b`), and every quoted scalar
(`k: "]"`), read as before.
"""
text = raw.strip()
text = raw.strip(_YAML_WHITE)
if text == "" or text in ("null", "~", "Null", "NULL"):
return None
if text[0] in "\"'":
Expand Down Expand Up @@ -613,9 +630,19 @@ def _refuse_entry_indicator(text: str, remedy: bool = True) -> None:
indicator, as in YAML and #252: `[-]`, `[-a]`, `{-: 1}` and `{?a: b}`
read as they did. A `]` or `}` is left to `_refuse_flow_indicator`, which
names it in #301's words.

A KEY THAT IS A LONE `?` IS REFUSED WITH THEM (#715): a `?` whose `:`
opens a value, with a space, a tab or nothing after it. `k: {?: 1}` read
the key `'?'`, where PyYAML reads an explicit key that has no key,
`{None: 1}`, while the lone `?` of `[?]`, `{?}` and `{a: ?}` was
refused as one; `[?: 1]` was refused as a single pair. Both are now
#252's refusal. `{?:a}` holds no `: `, and `[?:a]` is a `?` with text
after it, the text #252 keeps for `?a`; a block `?: 1` is the key `'?'`
to PyYAML too.
"""
first, after = text[:1], text[1:2]
if (first in ":#@`%" or first in "-?" and after in (" ", "\t")
or text.startswith("?:") and text[2:3] in ("", " ", "\t")
or text == "?"):
refusal = _indicator_refusal(text)
if not remedy:
Expand Down Expand Up @@ -648,26 +675,44 @@ def _flow_value(text: str, item: bool = False) -> Any:
cannot spell. So
`pins: [openGlass: x]` is a typo, refused as one. `{a: b}` in a sequence
is a flow mapping and reads, and `[a:b]` is the text `a:b`, as before.

AN ITEM WITH A SECOND `:` OPENING A VALUE IS NO PAIR AT ALL (#715).
YAML ends the pair's plain value at that `:`, and PyYAML refuses
`[a: b: c]` as invalid, so the single-pair words named a mapping the
file does not hold. It is refused as invalid YAML. `[a:b: c]`, whose key
is `a:b`, and `[a: "b: c"]`, whose value is quoted, are single pairs and
keep those words.
"""
if text.startswith(("[", "{")):
return _flow(text)
_refuse_flow_indicator(text)
if item:
_refuse_entry_indicator(text)
if _split_key(text) is not None:
pair = _split_key(text)
if pair is not None:
# A QUOTED item skipped the test above, so its value is asked
# here, before it is split again (#715): `["a": b[: c]` names
# the `[` in #301's words, not a key holding it. A value that
# OPENS a flow collection is not a plain scalar: `["a": [b]]`
# is a single pair to YAML, and keeps the single-pair words.
if not pair[1].startswith(("[", "{")):
_refuse_flow_indicator(pair[1])
if _split_key(pair[1]) is not None:
raise YamlError("a flow sequence entry with a second `:` "
f"opening a value is not valid YAML: {text!r}")
raise YamlError("a single-pair mapping in a flow sequence is not "
f"supported: {text!r}")
return _scalar(text)


def _flow(text: str) -> Any:
text = text.strip()
text = text.strip(_YAML_WHITE)
if text.startswith("[") and text.endswith("]"):
body = text[1:-1].strip()
body = text[1:-1].strip(_YAML_WHITE)
return [] if not body else [_flow_value(p, item=True)
for p in _split_flow(body)]
if text.startswith("{") and text.endswith("}"):
body = text[1:-1].strip()
body = text[1:-1].strip(_YAML_WHITE)
out: dict[str, Any] = {}
if body:
for part in _split_flow(body):
Expand Down Expand Up @@ -740,9 +785,9 @@ def _split_flow(body: str) -> list[str]:
elif ch in "]}":
depth -= 1
elif ch == "," and depth == 0:
parts.append(body[start:i].strip())
parts.append(body[start:i].strip(_YAML_WHITE))
start = i + 1
parts.append(body[start:].strip())
parts.append(body[start:].strip(_YAML_WHITE))
if len(parts) > 1 and parts[-1] == "":
parts.pop()
if "" in parts:
Expand All @@ -766,9 +811,9 @@ def _significant_text(raw: str, line: int) -> str | None:
refused outright, and a blank line or a whole-line comment carries no
record at all.
"""
if "\t" in raw[: len(raw) - len(raw.lstrip())]:
if "\t" in raw[: len(raw) - len(raw.lstrip(_YAML_WHITE))]:
raise YamlError(f"line {line}: tab used for indentation")
stripped = raw.strip()
stripped = raw.strip(_YAML_WHITE)
if not stripped or stripped.startswith("#"):
return None
return stripped
Expand Down Expand Up @@ -807,16 +852,25 @@ def _peel_dashes(recs: list[_Rec], indent: int, content: str,


def _refuse_unsupported(content: str, line: int) -> None:
"""The two constructs this reader will not guess at, refused by name.
"""The three constructs this reader will not guess at, refused by name.

Split out of `_tokenise` for #135. An explicit key and a merge key each
have a meaning the reader would have to invent, and an invented meaning in
a validator is a wrong answer with a confident tone.

SO DOES AN EMPTY KEY (#715): a line, or a sequence item, that opens with
a `:` opening a value, `: a` or `- :`. PyYAML refuses it, and this
reader read the key `''`, which no reader gives: `- :` was
`[{'': None}]`. Its flow twin, `{: a}`, is refused by
`_refuse_entry_indicator` (#392). A quoted empty key, `"": a`, is the
key `''` to PyYAML too, and still reads.
"""
if content.startswith("? "):
raise YamlError(f"line {line}: explicit keys are not supported")
if content.startswith("<<"):
raise YamlError(f"line {line}: merge keys are not supported")
if content.startswith(":") and content[1:2] in ("", " ", "\t"):
raise YamlError(f"line {line}: empty keys are not supported")


def _opens_block_scalar(kv: tuple[str, str] | None) -> bool:
Expand All @@ -838,13 +892,15 @@ def _block_scalar_body(raw_lines: list[str], i: int,
Split out of `_tokenise` for #135. The body is every following line MORE
INDENTED than the key, with the first such line's column taken as the
block's own indentation and stripped from all of them. A blank line
belongs to the body whatever its column, because it has none.
belongs to the body whatever its column, because it has none. A line of
Unicode spaces at the block's own indentation or deeper is not blank
(#715): it is text, as YAML reads it.
"""
body: list[str] = []
block_indent = None
while i < len(raw_lines):
nxt = raw_lines[i]
if nxt.strip() == "":
if nxt.strip(_YAML_WHITE) == "":
body.append("")
i += 1
continue
Expand Down
100 changes: 96 additions & 4 deletions scripts/shape_materialize.py
Original file line number Diff line number Diff line change
Expand Up @@ -1413,6 +1413,82 @@ def refuse_bad_branch(refusal: str, what: str, value) -> None:
raise Refusal(refusal, problem, remediation)


# ---------------------------------------------------------------------------
# A tracking branch a person can read (#715)
# ---------------------------------------------------------------------------
#
# What `bump-leg.py` and `update-shape.py` ask of a manifest's
# `tracking_branch` before they compare it or hand it to `gh` (Brett Heap's
# option A on RV823's SHOULD-1, 2026-10-17): only what no person can see in
# it, never `checked_value`'s alphabet.
#
# IT LIVES HERE AND NOT IN `scripts/repo_shape.py` for the reason the leg-path
# rule and `refuse_bad_branch` do (RV823's round-2 SHOULD-1): that file is a
# SHAPE COPY every root digest-pins, and no root calls this. Both tools
# already import this module.

#: Why a branch name holding such a character is refused, in the words
#: `validate-manifest.py`'s `_branch_finding` and `bootstrap.py`'s `UNSEEN`
#: say it in (#715).
UNSEEN = "a character that does not print or is white space"


def readable_branch(what: str, value) -> str:
"""A BRANCH NAME read out of a manifest, refused only for what no person
can see in it (#715; Brett Heap's option A on RV823's SHOULD-1).

The rule is the root's own: `validate-manifest.py`'s `_branch_finding`
and `bootstrap.py`'s `_argument_fault` refuse a branch holding a
character for which `str.isprintable()` is false or `str.isspace()` is
true, so `main` followed by U+00A0, which the reader reads as YAML does
since #715, is never taken for `main`. Each of those two spells the rule
itself rather than importing it: a root's validator and bootstrap run
beside the `repo_shape.py` that root holds, which may be older than they
are (the skew rule, `validate-manifest.py`'s `_library_takes_remedies`,
#661), and an older one has no such function. The words are theirs: a
NUL and a lone surrogate (#280) have their own, and any other such
character is `UNSEEN`.

It is NOT `checked_value`'s `SAFE_ARG_RE`. A name in any script, or one
holding `&`, is a name git creates and the root's own gate passes.
`bump-leg.py` only compares it and prints it, and `update-shape.py`
compares it, hands it to `gh pr create --base` as one argument and
prints it shell-quoted in the line a person pastes (#209). #823's first
fix round read it through `checked_value`, and refused those names.

A leading `-` is refused too, as `unsafe-value`: `update-shape.py` hands
the name to `gh pr create --base`, where it could be read as an option,
and git creates no branch whose name begins with one, so no root needs
it. The refusal's words say only the second, which holds for both
tools.
"""
text = str(value)
if text.startswith("-"):
raise Refusal(
"unsafe-value",
f"{what} is {text!r}, which begins with `-`, and git creates no "
"branch whose name begins with one",
"Remediation: give it a branch name git can create, such as "
"`main` or `release/1.2`.")
char = next((c for c in text if not c.isprintable() or c.isspace()),
None)
if char is None:
return text
if char == "\x00":
kind = "a NUL: no argument, file name or ref name can hold one"
elif "\ud800" <= char <= "\udfff":
kind = ("a lone surrogate: no UTF-8 file or console can hold it, so "
"no branch name is spelled with one")
else:
kind = (f"{UNSEEN}: a person reading the name cannot tell it from the "
"name without it")
raise Refusal(
"unseen-branch",
f"{what} is {text!r}, which holds U+{ord(char):04X}, {kind}",
"Remediation: give it a name with no such character, such as "
"`main` or `release/1.2`, and run this again.")


# ---------------------------------------------------------------------------
# What a run asks before its first write: the folders it makes (#609)
# ---------------------------------------------------------------------------
Expand Down Expand Up @@ -4388,8 +4464,18 @@ def _leg_path_lines(templates: Path) -> tuple[tuple[str, str, str], ...]:
def _read_back(tree: str, file: str, rendered: str):
"""What the project's reader gives back from one rendered `key: value`
line, read on its own: its scalar reads the same there as inside its
block."""
read = parse_yaml(rendered + "\n")
block.

A LINE THE READER REFUSES GIVES BACK THE REFUSAL, its `YamlError`, as
`_reading_of` gives it for a manifest's line (#715). Uncaught, it was a
traceback where the tool owes a refusal of the value: `checked_value`
lets a leg path open with `@`, which opens no plain scalar (#393), so a
bare leg-path line holding `--spec-path @x` is a line the project's
reader refuses. All four such lines are quoted today (#259)."""
try:
read = parse_yaml(rendered + "\n")
except YamlError as exc:
return exc
if not (isinstance(read, dict) and len(read) == 1):
raise Refusal("template-leg-path-line",
f"templates/{tree}/{file}: `{rendered}` is not one "
Expand All @@ -4409,7 +4495,10 @@ def _listed(names: list[str]) -> str:
def _read_as(value) -> str:
"""What the YAML reader made of a value, in words: `the number 1.5`.
None is `NOT_TEXT_KINDS`' "no value at all", as for a leg path read out
of a plan as no value (#181)."""
of a plan as no value (#181). A refusal (`_read_back`) is "nothing at
all", in `_reading_in_words`' words (#715)."""
if isinstance(value, YamlError):
return _reading_in_words(value)
if isinstance(value, bool):
return f"the boolean {str(value).lower()}"
if isinstance(value, (int, float)):
Expand All @@ -4429,7 +4518,10 @@ def _misread_reason(text: str) -> str | None:
quoted one (#259) reads it back. The sentence quotes each line that
misreads, as rendered. Asked after `checked_value`, whose alphabet holds
no space, quote, backslash, bracket, brace, `#`, `:`, `&`, `*` or `!`, so
each line parses, bare or quoted, and a bare value is a plain scalar.
a quoted line always parses and a bare value is a plain scalar. The
alphabet holds `@`, though, which opens no plain scalar (#393): a bare
line of `@x` is refused, and the refusal is its reading (`_read_back`),
said as "nothing at all" (#715).
"""
values = dict.fromkeys(LEG_PATH_PLACEHOLDERS, text)
misread: dict[tuple[str, str], list[str]] = {}
Expand Down
Loading
Loading