Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 42 additions & 16 deletions scripts/repo_shape.py
Original file line number Diff line number Diff line change
Expand Up @@ -189,10 +189,10 @@ class YamlError(Exception):
`line` AND `remediation` ARE FOR `load_yaml`, WHICH NAMES A FILE (#708).
Where the fix is one edit of the value, the same in every file, the
refusal carries it (`_indicator_refusal`, and `_scalar`'s for a number
it cannot make, #1212); every other refusal carries none. Such a
refusal is raised where the value is read, which knows no line, so the
block reader records the line it read the value on (`_block_value`),
and `load_yaml` names it. It stays out of the words:
it cannot make, #1212 and #1260); every other refusal carries none.
Such a refusal is raised where the value is read, which knows no line,
so the block reader records the line it read the value on
(`_block_value`), and `load_yaml` names it. It stays out of the words:
a tool that asks the reader about ONE rendered line (`k: @a`) quotes
those words in a sentence of its own, where `line 1:` would be noise.
"""
Expand Down Expand Up @@ -227,8 +227,9 @@ def __init__(self, message: str, remediation: str = ""):
# integer of more digits than `int()` takes (`sys.get_int_max_str_digits()`,
# 4300 by default), which ended every tool in a `ValueError` traceback, and
# a decimal too large for a 64-bit float to hold, which was read as the float
# `inf`, a number this reader reads no plain scalar as (`inf` is text). See
# `_scalar`.
# `inf`, a number this reader reads no plain scalar as (`inf` is text). The
# other end is refused the same way (#1260): a decimal with a nonzero digit
# that a 64-bit float can only make `0.0` of. See `_scalar`.
#
# A BLOCK SCALAR'S BODY IS READ AS YAML READS IT, OR REFUSED BY ITS LINE
# (#1004). `+` keeps the empty lines at the block's end, where it was read
Expand Down Expand Up @@ -598,6 +599,27 @@ def _indicator_refusal(text: str) -> YamlError:
"run the command again.")


def _plain_decimal(text: str) -> float:
"""The float of the plain decimal `text` that `_scalar` read, refused
where a 64-bit float cannot hold it: infinite (#1212) or zero with a
nonzero digit (#1260). `_scalar`'s docstring says why."""
number = float(text)
if math.isinf(number):
raise YamlError(f"a plain decimal of {len(text.lstrip('-')) - 1} "
"digits is too large to read as a number: a "
"64-bit float cannot hold it, so it would be "
"infinite", _UNMADE_NUMBER_REMEDY)
# A float is falsy exactly when it is 0.0 or -0.0: the pattern makes no
# nan and inf is refused above, so `not number` asks whether it is zero.
if not number and any(
int(digit) for digit in text.lstrip("-").replace(".", "")):
raise YamlError(f"a plain decimal of {len(text.lstrip('-')) - 1} "
"digits is too small to read as a number: a "
"64-bit float cannot hold it, so it would be "
"zero", _UNMADE_NUMBER_REMEDY)
return number


def _scalar(raw: str) -> Any:
"""One scalar's value: null, a quoted scalar's text, a bool, a number, or
a plain scalar's text.
Expand All @@ -623,6 +645,16 @@ def _scalar(raw: str) -> Any:
second "the number inf". The value can be thousands of characters long,
so the refusal says how many digits it holds, and `load_yaml` names its
line.

THE OTHER END IS REFUSED THE SAME WAY (#1260): a decimal that holds a
nonzero digit and whose float is zero (`0.` and four hundred `0`s and a
`1`), which was read as `0.0`, a number the file does not hold. It
changes kind as the large end does, from nonzero to zero, where rounding
keeps the kind: a subnormal such as 5e-324 is nonzero, so it is a number
still, and so are `0.0`, `-0.0` and a decimal of only zeros. The test of
a nonzero digit asks each digit its value and does not look for `1` to
`9`, because the pattern above takes the digits of every script and
`float()` reads each as its value.
"""
text = raw.strip(_YAML_WHITE)
if text == "" or text in ("null", "~", "Null", "NULL"):
Expand All @@ -645,13 +677,7 @@ def _scalar(raw: str) -> Any:
"digits is too long for this Python to read as a "
"number", _UNMADE_NUMBER_REMEDY) from exc
if re.fullmatch(r"-?\d+\.\d+", text):
number = float(text)
if math.isinf(number):
raise YamlError(f"a plain decimal of {len(text.lstrip('-')) - 1} "
"digits is too large to read as a number: a "
"64-bit float cannot hold it, so it would be "
"infinite", _UNMADE_NUMBER_REMEDY)
return number
return _plain_decimal(text)
return text


Expand Down Expand Up @@ -1308,9 +1334,9 @@ def _block_value(text: str, line: int) -> Any:
open with a backtick ...`, among the hundreds of lines a plan holds.
The line is recorded on the refusal (`YamlError.line`), never put in
its words, where the refusal carries a remedy (`_indicator_refusal`'s,
or `_UNMADE_NUMBER_REMEDY`, #1212): one that tells a person to edit a
value says where it is. The others keep the words they had, which the
naming gate's `declared-unverified` reason and the root's
or `_UNMADE_NUMBER_REMEDY`, #1212 and #1260): one that tells a person to
edit a value says where it is. The others keep the words they had, which
the naming gate's `declared-unverified` reason and the root's
`validate-pins.py` print, and tests quote, whole.
"""
try:
Expand Down
177 changes: 176 additions & 1 deletion tests/test_plan_number_limits.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# SPDX-License-Identifier: Apache-2.0
"""A plain number the plan's reader cannot make is refused by its line, and a
float it does make is said in digits a plan can hold (#1212, which follows
#1114 and #1198).
#1114 and #1198), at the small end of a decimal as at the large (#1260).

RV1198 found both on #1198, and both are older than it.

Expand All @@ -19,6 +19,14 @@
`load_yaml` names the line; and `_read_as` says a float in the digits of its
`repr`, written out with no exponent, which the reader reads back as it.

#1260 is the other end of a decimal. `float()` makes `0.0` of a plain decimal
that holds a nonzero digit and is too small for a 64-bit float (`0.` and four
hundred `0`s and a `1`), a number the file does not hold, where it makes
`inf` of one too large. `_scalar` refuses it as it refuses that one: the same
`yaml-unparsable` by its line, with the same remedy, in words that say it
would be zero. `0.0`, `-0.0`, a decimal of only zeros and a subnormal such as
5e-324 are numbers still.

The reader's rows run in this process. The plan's rows edit a plan `plan`
wrote and run `check` and `execute --yes` on it, with a stub `gh` first on
PATH and `--local-remote-dir`: neither asks `gh` anything or makes the
Expand Down Expand Up @@ -62,6 +70,22 @@
LONGEST = "1" * DEFAULT_DIGITS
INFINITE = "1" * 400 + ".5"

#: W1212's decimal that is too small: `0.`, four hundred `0`s and a `1`.
#: `float()` makes `0.0` of it, a number nothing in the file holds.
TINY = "0." + "0" * 400 + "1"

#: The decimals a digit either side of half the smallest float, 5e-324, which
#: `float()` rounds to the nearer of 0.0 and 5e-324: the one under half is
#: `0.0`, so it is refused, and the one over half is 5e-324, a number.
BELOW_HALF = "0." + "0" * 323 + "24703282292062327"
ABOVE_HALF = "0." + "0" * 323 + "24703282292062328"

#: Arabic-Indic zero and one: the pattern `_scalar` reads a decimal by takes
#: the digits of every script, and `float()` reads each as its value. Escapes,
#: so that this file stays ASCII.
ZERO_OF_ANOTHER_SCRIPT = "\u0660"
ONE_OF_ANOTHER_SCRIPT = "\u0661"


def too_long(digits: int) -> str:
return (f"a plain integer of {digits} digits is too long for this Python "
Expand All @@ -73,6 +97,11 @@ def too_large(digits: int) -> str:
"number: a 64-bit float cannot hold it, so it would be infinite")


def too_small(digits: int) -> str:
return (f"a plain decimal of {digits} digits is too small to read as a "
"number: a 64-bit float cannot hold it, so it would be zero")


needs_the_setting = pytest.mark.skipif(
not hasattr(sys, "set_int_max_str_digits"),
reason="this Python has no limit on an integer's digits")
Expand Down Expand Up @@ -176,6 +205,86 @@ def test_the_refusal_follows_this_pythons_setting(tmp_path, default_digits):
assert parse_yaml(f"k: {LONG}\n") == {"k": int(LONG)}


# --- the small end: a decimal a float can only make zero of (#1260) ----------

#: `(the file, the line the number is on, how many digits it holds)`, by test
#: id: W1212's decimal and the same with a minus sign, in each shape a plain
#: value is read in, and the three edges: the decimal a digit under half the
#: smallest float, one in the digits of another script, one whose nonzero
#: digit is not the last, with more digits than the default limit lets
#: `int()` read in one piece (a test of the last digit alone, or of all the
#: digits as one integer, reads that one wrong), and one whose nonzero digit
#: has thousands of zeros after it and hundreds before it (a test of only
#: the last few digits reads that one wrong). Every one has a nonzero digit
#: and a float of zero.
TOO_SMALL = {
"block-value": (f"a: 1\nk: {TINY}\n", 2, 402),
"block-value-negative": (f"a: 1\nk: -{TINY}\n", 2, 402),
"sequence-item": (f"a: 1\nk:\n - {TINY}\n", 3, 402),
"sequence-item-negative": (f"a: 1\nk:\n - -{TINY}\n", 3, 402),
"flow-sequence": (f"a: 1\nk: [1.5, {TINY}]\n", 2, 402),
"flow-sequence-negative": (f"a: 1\nk: [1.5, -{TINY}]\n", 2, 402),
"flow-mapping": (f"a: 1\nk: {{x: {TINY}}}\n", 2, 402),
"flow-mapping-negative": (f"a: 1\nk: {{x: -{TINY}}}\n", 2, 402),
"nested": (f"a:\n b:\n k: {TINY}\n", 3, 402),
"nested-negative": (f"a:\n b:\n k: -{TINY}\n", 3, 402),
"below-half-the-smallest": (f"k: {BELOW_HALF}\n", 1, 341),
"another-script": (
f"k: 0.{ZERO_OF_ANOTHER_SCRIPT * 400}{ONE_OF_ANOTHER_SCRIPT}\n", 1,
402),
"nonzero-digit-not-last": (f"k: 0.{'0' * 4400}10\n", 1, 4403),
"nonzero-digit-not-last-negative": (f"k: -0.{'0' * 4400}10\n", 1, 4403),
"nonzero-digit-then-many-zeros": (
f"k: 0.{'0' * 323}1{'0' * 4400}\n", 1, 4725),
"nonzero-digit-then-many-zeros-negative": (
f"k: -0.{'0' * 323}1{'0' * 4400}\n", 1, 4725),
}


@pytest.mark.parametrize("case", sorted(TOO_SMALL))
def test_a_decimal_a_float_can_only_make_zero_of_is_refused_by_its_line(
tmp_path, default_digits, case):
"""`yaml-unparsable`, naming the file and the line, with the remedy and
words that say it would be zero, as the large end's say it would be
infinite: in every place a plain value is read, with a minus sign or
none, where the digits are another script's, and where the nonzero digit
is not the last, or is followed by thousands of zeros: read at Python's
default limit on an integer's digits, whatever this host's setting is."""
text, line, digits = TOO_SMALL[case]
refusal = refusal_of(tmp_path, text)
assert refusal.code == "yaml-unparsable"
assert refusal.detail == (
f"{tmp_path / 'file.yaml'}: line {line}: {too_small(digits)}"), (
refusal.detail)
assert refusal.remediation == REMEDY
assert isinstance(refusal.__cause__, YamlError)


#: `(the decimal, what the reader makes of it)`, by test id: the controls.
#: A decimal of only zeros, in either script and either sign, is a number
#: with no nonzero digit; 5e-324 is a nonzero float, spelled in its 326
#: characters, and so is the decimal a digit over half of it.
STILL_NUMBERS = {
"zero": ("0.0", 0.0),
"negative-zero": ("-0.0", -0.0),
"zeros": ("0.000", 0.0),
"many-zeros": ("0." + "0" * 400, 0.0),
"many-zeros-negative": ("-0." + "0" * 400, -0.0),
"zeros-of-another-script": (
f"{ZERO_OF_ANOTHER_SCRIPT}.{ZERO_OF_ANOTHER_SCRIPT * 400}", 0.0),
"the-smallest": ("0." + "0" * 323 + "5", 5e-324),
"the-smallest-negative": ("-0." + "0" * 323 + "5", -5e-324),
"above-half-the-smallest": (ABOVE_HALF, 5e-324),
}


@pytest.mark.parametrize("case", sorted(STILL_NUMBERS))
def test_a_decimal_a_float_can_hold_is_still_read_as_a_number(case):
"""`repr` of the whole, so that a `-0.0` read as `0.0` is not the same."""
spelled, value = STILL_NUMBERS[case]
assert repr(parse_yaml(f"k: {spelled}\n")) == repr({"k": value})


# --- `_read_as`: a float in digits the reader reads back as it --------------


Expand Down Expand Up @@ -290,6 +399,72 @@ def test_a_commit_too_large_to_be_finite_is_refused_by_its_line(
too_large(401))


# --- the small end, through `check` and `execute` (#1260) -------------------


@pytest.mark.parametrize("sign", ["", "-"], ids=["positive", "negative"])
def test_a_commit_too_small_to_be_nonzero_is_refused_by_its_line(
answered, tmp_path, sign):
"""`check` and `execute` read W1212's decimal in a `commit:` as "the
number 0.0" and said it was not text; they now refuse it, by its line, as
they refuse one too large to be finite."""
raw = sign + TINY
text = pinned(answered, a_pin(commit=raw))
refused_unparsable(text, tmp_path, line_of(text, f" commit: {raw}"),
too_small(402))


def test_a_key_outside_pins_too_small_to_be_nonzero_is_refused_by_its_line(
tmp_path):
"""A key no pin holds was read as `0.0`, and the plan was a good one:
`check` printed no finding, and `execute` made the legs. It is the
reader's fault, not the pin rows', so it is refused the same way.

The row makes a source of its own, not the module's: where the reader
read it as `0.0`, `execute` ran the plan and left its split branch in the
source, and every row after this one would find `adopt-branch-exists`."""
own = tmp_path / "own"
own.mkdir()
planned_text = planned(make_source_repo(own / "Thing"), own,
dict(ANSWERS)).read_text(encoding=UTF8)
text = planned_text + f"probe_extra: {TINY}\n"
refused_unparsable(text, tmp_path, len(text.splitlines()), too_small(402))


def test_a_follow_up_too_small_to_be_nonzero_is_refused_by_its_line(
answered, tmp_path):
"""A `follow_ups:` item was read as `0.0` and `check` found it is a
number, not text; the reader refuses it first."""
assert answered.count("\nfollow_ups:\n") == 1
text = answered.replace("\nfollow_ups:\n",
"\nfollow_ups:\n - " + TINY + "\n", 1)
refused_unparsable(text, tmp_path, line_of(text, f" - {TINY}"),
too_small(402))


#: `(the commit as the plan spells it, how the finding says it)`, by test
#: id: decimals a float can hold, which are numbers still, and so are said.
HELD = {
"zeros": ("0." + "0" * 400, "0.0"),
"the-smallest": ("0." + "0" * 323 + "5", "0." + "0" * 323 + "5"),
}


@needs_filter_repo
@pytest.mark.parametrize("case", sorted(HELD))
def test_a_commit_a_float_can_hold_is_still_a_finding(answered, tmp_path,
case):
"""The controls: a decimal of only zeros and the smallest float are
numbers, and the `commit:` is said as #1198 says one that is not text."""
raw, digits = HELD[case]
found = one_finding(answered, tmp_path, (a_pin(commit=raw),),
"plan-pin-malformed")
assert found.startswith(
f"FINDING plan-pin-malformed: {ENTRY_NAMED} records `commit:` as the "
f"number {digits}, which is not text. {FROM}, and nothing else: "), (
found[:400])


@needs_filter_repo
def test_a_commit_of_4300_digits_is_still_a_finding(answered, tmp_path,
default_digits):
Expand Down
Loading