Files
glibc/stdio-common/tst-printf-format.py
Matt Turner 71e40f25cf stdio-common: Handle subnormal values in the printf format tests
The reference implementation used to verify the a and A conversions
assumed the value it was given was normal, splitting it into a
significand of the full width and an exponent.  That is not true below
the smallest normal value, where the exponent can go no lower and the
significand loses bits from the top instead, which is what makes the
leading hexadecimal digit of a subnormal come out as zero.  Given a
subnormal it would have produced a normalized result such as 0x1p-1070
where we print 0x0.000000000001p-1022.

Telling the two apart needs the minimum exponent for the type, which was
not among the data the test program supplies, so add it as a MINEXP
definition reported in a record of its own next to the working
precision. Then stop normalizing once that exponent is reached, and pad
the digits produced on the left, as there are no longer enough of them
to fill the field on their own.  The remaining conversions are
unaffected: they work from the value itself and never needed it
decomposed.

How far the exponent has to be shifted to sit after the significand
depends on the working precision, so hold MINEXP as reported and combine
the two only once a value is due to be converted, rather than requiring
the records to arrive in a particular order.  Where no MINEXP record
arrives the type has no subnormals and no clamping is applied.

None of the values iterated over were subnormal, so this could not be
observed.  Add DBL_TRUE_MIN and LDBL_TRUE_MIN to cover it, which also
exercises the smallest exponent with the remaining conversions.  How
many bits the leading hexadecimal digit holds varies with the type, one
for a 53 bit significand and four for a 64 bit one, so both are needed:
the wider case lands on a different exponent than the minimum for the
type, with LDBL_TRUE_MIN coming out as 0x0.000000000000001p-16385 rather
than at the p-16382 that the leading digit of a normal value would sit
at.  One sign is enough for either, as nothing in the sign handling
depends on the value being subnormal, and the records these produce are
among the most expensive in the test suite.

Tested on x86_64-linux-gnu, where all 672 results pass.

Reviewed-by: Adhemerval Zanella  <adhemerval.zanella@linaro.org>
2026-09-02 12:37:47 -05:00

529 lines
19 KiB
Python

#!/usr/bin/python3
# Verification of formatted printf output.
# Copyright (C) 2024-2026 Free Software Foundation, Inc.
# This file is part of the GNU C Library.
#
# The GNU C Library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 2.1 of the License, or (at your option) any later version.
#
# The GNU C Library is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with the GNU C Library; if not, see
# <https://www.gnu.org/licenses/>.
"""Verify records of formatted printf output.
The record stream produced by tst-printf-format-skeleton.c is read from
standard input and each record's output is checked against a reference
value computed here. Diagnostics for records that do not match are written
to standard error and a nonzero exit status is returned.
This is deliberately an independent implementation of the ISO C format
processing rules: every conversion is computed with exact integer and
rational arithmetic and nothing defers to the C library, so a bug in printf
cannot verify itself. Working exactly also means no dependence on the
range or precision of any host floating-point type, so the wider types are
handled without arbitrary-precision support having to be built into the
interpreter.
"""
import io
import os
import sys
from fractions import Fraction
# Conversions grouped by the C type of the corresponding argument.
FLOAT_CONVS = frozenset("aAeEfFgG")
INT_CONVS = frozenset("bBdiouxX")
# Conversions whose output is in upper case.
UPPER_CONVS = frozenset("AEFG")
# The conversion specifier selects the base an integer is written in.
INT_FORMATS = {"b": "b", "B": "b", "o": "o", "u": "d", "x": "x", "X": "X"}
# Flags and length modifiers accepted in a conversion specification.
FLAG_CHARS = "-+ #0"
LENGTH_CHARS = "hlLqjzt"
# log10(2), used only to seed the decimal exponent search.
LOG10_2 = 0.3010299956639812
def diagnose(text):
"""Report TEXT on standard error.
Records hold arbitrary bytes, taken as Latin-1 on the way in, so put
them back the same way rather than through whatever encoding the
locale would otherwise select."""
sys.stderr.buffer.write(text.encode("latin-1") + b"\n")
sys.stderr.buffer.flush()
def round_ratio(num, den):
"""Round the non-negative ratio NUM/DEN to the nearest integer, with
ties going to even, matching the default rounding mode."""
quot, rem = divmod(num, den)
rem *= 2
if rem > den or (rem == den and quot & 1):
quot += 1
return quot
def scale_ratio(num, den, power):
"""Return NUM/DEN multiplied by 10**POWER, as a pair of integers."""
if power >= 0:
return num * 10 ** power, den
return num, den * 10 ** -power
def normalize(num, den, mant_bits):
"""Scale the positive ratio NUM/DEN into [2**(mant_bits-1), 2**mant_bits).
Return the scaled pair along with the exponent EXP that was taken out,
so that the original value is num/den * 2**exp."""
exp = num.bit_length() - den.bit_length() - mant_bits
if exp >= 0:
den <<= exp
else:
num <<= -exp
while num >= den << mant_bits:
den <<= 1
exp += 1
while num < den << (mant_bits - 1):
num <<= 1
exp -= 1
return num, den, exp
def decimal_to_binary(text, mant_bits):
"""Convert the decimal string TEXT to the nearest value having MANT_BITS
of significand, returned as an exact Fraction.
The generator prints reference values with enough digits to reproduce
the original, so this recovers the exact value converted."""
fr = Fraction(text)
if fr == 0:
return fr
neg = fr < 0
num, den = (-fr.numerator if neg else fr.numerator), fr.denominator
num, den, exp = normalize(num, den, mant_bits)
mant = round_ratio(num, den)
if mant >> mant_bits:
mant >>= 1
exp += 1
fr = Fraction(mant << exp) if exp >= 0 else Fraction(mant, 1 << -exp)
return -fr if neg else fr
def decimal_digits(fr, ndigits):
"""Round the positive Fraction FR to NDIGITS significant decimal digits.
Return the digit string and the decimal exponent X such that the value
is D[0].D[1:] * 10**X, i.e. X is the exponent an 'e' conversion prints."""
num, den = fr.numerator, fr.denominator
# Seed from the binary magnitude; the estimate is within one decade.
x = int((num.bit_length() - den.bit_length()) * LOG10_2)
while True:
hi_num, hi_den = scale_ratio(num, den, -(x + 1))
if hi_num < hi_den:
break
x += 1
while True:
lo_num, lo_den = scale_ratio(num, den, -x)
if lo_num >= lo_den:
break
x -= 1
digits = round_ratio(*scale_ratio(num, den, ndigits - 1 - x))
if digits >= 10 ** ndigits:
# Rounding carried into the next decade, e.g. 9.99 -> 10.0.
digits //= 10
x += 1
return str(digits).zfill(ndigits), x
class Spec:
"""A parsed conversion specification."""
__slots__ = ("minus", "plus", "space", "alt", "zero", "width", "prec",
"conv", "neg_zero")
def __init__(self, fmt, args):
"""Parse FMT, taking values for any '*' from ARGS in order."""
self.minus = self.plus = self.space = self.alt = self.zero = False
self.neg_zero = False
i = 1 # skip '%'
while i < len(fmt) and fmt[i] in FLAG_CHARS:
char = fmt[i]
self.minus |= char == "-"
self.plus |= char == "+"
self.space |= char == " "
self.alt |= char == "#"
self.zero |= char == "0"
i += 1
self.width = 0
if i < len(fmt) and fmt[i] == "*":
self.width = int(args.pop(0))
i += 1
# A negative field width is a '-' flag with a positive width.
if self.width < 0:
self.minus = True
self.width = -self.width
else:
start = i
while i < len(fmt) and fmt[i].isdigit():
i += 1
if i > start:
self.width = int(fmt[start:i])
self.prec = None
if i < len(fmt) and fmt[i] == ".":
i += 1
if i < len(fmt) and fmt[i] == "*":
self.prec = int(args.pop(0))
i += 1
# A negative precision is taken as if it were omitted.
if self.prec < 0:
self.prec = None
else:
start = i
while i < len(fmt) and fmt[i].isdigit():
i += 1
self.prec = int(fmt[start:i]) if i > start else 0
# Length modifiers only select the C type of the argument, which the
# generator has already applied; they do not affect the output.
while i < len(fmt) and fmt[i] in LENGTH_CHARS:
i += 1
self.conv = fmt[i]
def pad(self, body, zero_ok=True, sign=""):
"""Apply the field width to SIGN followed by BODY."""
count = self.width - len(sign) - len(body)
if count <= 0:
return sign + body
if self.minus:
return sign + body + " " * count
if self.zero and zero_ok:
return sign + "0" * count + body
return " " * count + sign + body
def sign_of(self, neg):
"""The character introducing a signed conversion of a value whose
sign is NEG, honoring the '+' and space flags."""
if neg:
return "-"
if self.plus:
return "+"
if self.space:
return " "
return ""
def convert_int(value, spec):
conv = spec.conv
if conv in "di":
neg = value < 0
digits = str(-value if neg else value)
sign = spec.sign_of(neg)
else:
digits = format(value, INT_FORMATS[conv])
sign = ""
if spec.prec is not None:
# A zero value converted with a precision of zero produces no
# characters at all.
if value == 0 and spec.prec == 0:
digits = ""
digits = digits.rjust(spec.prec, "0")
if spec.alt:
if conv == "o" and not digits.startswith("0"):
digits = "0" + digits
elif conv in "bBxX" and value != 0:
# The base prefix precedes any '0' flag padding, so it pads
# along with the sign rather than with the digits.
sign = "0" + conv
# An explicit precision defeats the '0' flag.
return spec.pad(digits, zero_ok=spec.prec is None, sign=sign)
def convert_string(value, spec):
if spec.prec is not None:
value = value[:spec.prec]
return spec.pad(value, zero_ok=False)
def convert_char(value, spec):
return spec.pad(value[:1], zero_ok=False)
def convert_special(kind, neg, spec):
"""Convert an infinity or a NaN. The field is padded with spaces
whether or not the '0' flag was given, and the alternative form has no
effect."""
body = kind.upper() if spec.conv in UPPER_CONVS else kind
return spec.pad(body, zero_ok=False, sign=spec.sign_of(neg))
def render_f(fr, prec, alt, strip):
"""Render the non-negative Fraction FR in the style of 'f'."""
scaled = round_ratio(*scale_ratio(fr.numerator, fr.denominator, prec))
text = str(scaled).zfill(prec + 1)
if prec:
whole, frac = text[:-prec], text[-prec:]
else:
whole, frac = text, ""
if strip:
frac = frac.rstrip("0")
if frac or alt:
return whole + "." + frac
return whole
def to_significand(fr, mant_bits, min_exp):
"""Split the positive Fraction FR into an integer significand and a
power of two, returned as (significand, exponent).
The significand has exactly MANT_BITS bits for a normal value. Below
the smallest normal the exponent cannot go any lower, so it stops at
MIN_EXP and the significand loses bits from the top instead, which is
what makes the leading hexadecimal digit of a subnormal come out zero.
MIN_EXP is None where the format has no subnormals. FR comes from a
value of this format, so the split is exact either way."""
num, den, exp = normalize(fr.numerator, fr.denominator, mant_bits)
if min_exp is not None and exp < min_exp:
# Subnormal: scale back up to the smallest exponent the format has,
# which drops the significand below its full width.
den <<= min_exp - exp
exp = min_exp
return num // den, exp
def render_a(fr, mant_bits, min_exp, prec, alt, upper):
"""Render the non-negative Fraction FR in the style of 'a'.
The number of bits the leading hexadecimal digit holds is whatever is
left over once the remaining significand bits are grouped into whole
digits, which is how the significand ends up written out without any
shifting. For a 53 bit significand that leaves one bit, so the leading
digit is 1; for a 64 bit one it leaves four, so the leading digit runs
from 8 to f. A subnormal has no bit to put there and so leads with a
zero, its exponent being the smallest the format provides."""
lead_bits = ((mant_bits - 1) % 4) + 1
nfrac = (mant_bits - lead_bits) // 4
if fr == 0:
digits, exp = "0" * (1 + nfrac), 0
else:
mant, exp = to_significand(fr, mant_bits, min_exp)
digits = ("%x" % mant).zfill(1 + nfrac)
exp += 4 * nfrac
if prec is None:
whole, frac = digits[0], digits[1:].rstrip("0")
elif prec < nfrac:
value = round_ratio(int(digits, 16), 16 ** (nfrac - prec))
if value >= 16 ** (1 + prec):
# Rounding carried out of the leading digit; re-express with
# one digit fewer rather than widening the integer part.
value //= 16
exp += 4
text = ("%x" % value).zfill(1 + prec)
whole, frac = text[0], text[1:]
else:
whole, frac = digits[0], digits[1:].ljust(prec, "0")
# The "0x" prefix is not included: like the one the '#' flag produces
# for the integer hexadecimal conversions it precedes any '0' flag
# padding, so the caller pads it along with the sign.
body = whole + "." + frac if frac or alt else whole
text = "%sp%s%d" % (body, "-" if exp < 0 else "+", abs(exp))
return text.upper() if upper else text
def render_e(fr, prec, alt, upper, strip):
"""Render the non-negative Fraction FR in the style of 'e'."""
if fr == 0:
digits, exp = "0" * (prec + 1), 0
else:
digits, exp = decimal_digits(fr, prec + 1)
whole, frac = digits[0], digits[1:]
if strip:
frac = frac.rstrip("0")
mant = whole + "." + frac if frac or alt else whole
return "%s%s%s%02d" % (mant, "E" if upper else "e",
"-" if exp < 0 else "+", abs(exp))
def convert_float(value, spec, mant_bits, min_exp, cache):
"""Convert VALUE, an exact Fraction. CACHE memoizes rendered digits for
the value currently being converted."""
conv = spec.conv
neg = value < 0 or (value == 0 and spec.neg_zero)
fr = -value if value < 0 else value
prec = 6 if spec.prec is None else spec.prec
upper = conv in UPPER_CONVS
# The generator iterates a large number of flag and field width
# combinations over each value, so the same digits are called for over
# and over. They depend only on the magnitude, the precision and the
# alternative form, which is what keeps the wider types inexpensive
# despite the arithmetic being exact.
key = (conv, spec.prec, spec.alt)
body = cache.get(key)
if body is None:
if conv in "aA":
body = render_a(fr, mant_bits, min_exp, spec.prec, spec.alt,
upper)
elif conv in "gG":
sig = 1 if prec == 0 else prec
_, exp = decimal_digits(fr, sig) if fr != 0 else ("", 0)
if -4 <= exp < sig:
body = render_f(fr, sig - 1 - exp, spec.alt,
strip=not spec.alt)
else:
body = render_e(fr, sig - 1, spec.alt, upper,
strip=not spec.alt)
elif conv in "eE":
body = render_e(fr, prec, spec.alt, upper, strip=False)
else:
body = render_f(fr, prec, spec.alt, strip=False)
cache[key] = body
sign = spec.sign_of(neg)
if conv in "aA":
sign += "0X" if upper else "0x"
return spec.pad(body, zero_ok=True, sign=sign)
class Block:
"""The state a run of records shares: the C type they exercise and the
single value they all convert."""
__slots__ = ("mant_bits", "type_min_exp", "min_exp", "val_text",
"value", "special", "neg_zero", "cache")
def __init__(self):
self.mant_bits = 0
self.type_min_exp = None
self.min_exp = None
self.set_value("")
def set_value(self, text):
"""Begin a run of records converting the value written as TEXT."""
self.val_text = text
self.value = None
self.special = None
self.neg_zero = False
self.cache = {} # digits memoized by convert_float
def interpret(self, conv):
"""Interpret the value text as the C type conversion CONV takes."""
if conv in FLOAT_CONVS:
# *_MIN_EXP is the exponent of the smallest normal value with
# the radix point before the significand; shift it to sit after
# the significand, as to_significand expects. Where it was not
# reported the type has no subnormals.
self.min_exp = (None if self.type_min_exp is None
else self.type_min_exp - self.mant_bits)
lowered = self.val_text.lower()
if "inf" in lowered or "nan" in lowered:
self.special = "nan" if "nan" in lowered else "inf"
else:
self.value = decimal_to_binary(self.val_text, self.mant_bits)
self.neg_zero = self.val_text.lstrip().startswith("-")
elif conv in INT_CONVS:
self.value = int(self.val_text)
else:
self.value = self.val_text
def expect(self, spec):
"""The output SPEC is required to produce for this block's value."""
conv = spec.conv
# Which C type the value has follows from the conversion specifier,
# which is not known until the first record of the block, so the
# text is interpreted here rather than where it was read.
if self.value is None and self.special is None:
self.interpret(conv)
spec.neg_zero = self.neg_zero
if conv in FLOAT_CONVS:
if self.special is not None:
return convert_special(self.special, self.neg_zero, spec)
return convert_float(self.value, spec, self.mant_bits,
self.min_exp, self.cache)
if conv in INT_CONVS:
return convert_int(self.value, spec)
if conv == "c":
return convert_char(self.value, spec)
return convert_string(self.value, spec)
def check(raw, block):
"""Check one record against the value BLOCK holds, reporting a mismatch
on standard error. Return whether the record was in order."""
# Records are colon separated and end with an empty field, included to
# make the data easier to read. What precedes the output are the format
# and then any width and precision supplied as arguments.
fields = raw.split(":")
fields.pop()
fmt, actual, args = fields[0], fields[-1], fields[1:-1]
expect = block.expect(Spec(fmt, list(args)))
if expect == actual:
return True
diagnose('("%s"%s, %s) => "%s", expected "%s"'
% (fmt, "".join(", " + arg for arg in args), block.val_text,
actual, expect))
return False
def main():
# The wider types at the precisions exercised produce integers well past
# the default limit on conversion to a string. Versions that predate
# the limit have no such call and need no lifting.
if hasattr(sys, "set_int_max_str_digits"):
sys.set_int_max_str_digits(0)
# Records hold arbitrary bytes, so read them as Latin-1 rather than
# through whatever encoding the locale would otherwise select.
stdin = io.TextIOWrapper(sys.stdin.buffer, encoding="latin-1",
newline="\n")
block = Block()
status = 0
for raw in stdin:
raw = raw.rstrip("\n")
if raw.startswith("prec:"):
block.mant_bits = int(raw[5:])
elif raw.startswith("minexp:"):
block.type_min_exp = int(raw[7:])
elif raw.startswith("val:"):
block.set_value(raw[4:])
elif raw.startswith("%"):
if not check(raw, block):
status = 1
else:
diagnose('unrecognized input: "%s"' % raw)
status = 1
return status
if __name__ == "__main__":
try:
sys.exit(main())
except BrokenPipeError:
# The wrappers pipe into 'head', which stops reading once it has
# the first diagnostic. Point what is left of standard error at
# the null device, so that flushing at exit does not run into the
# same failure and report it a second time.
os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stderr.fileno())
sys.exit(1)