diff --git a/src/openjd/model/v2023_09/_model.py b/src/openjd/model/v2023_09/_model.py index 56caa30..423ea14 100644 --- a/src/openjd/model/v2023_09/_model.py +++ b/src/openjd/model/v2023_09/_model.py @@ -1245,74 +1245,44 @@ def _validate_target_runtime_seconds(cls, value: Any, info: ValidationInfo) -> A return validate_int_fmtstring_field(value, ge=0, context=context) +# '02.50' -> '2.50', '007' -> '7', '000' -> '0'. The lookahead leaves the last +# digit, so '0.50' keeps the zero that is its integer part. +_REDUNDANT_LEADING_ZEROS = re.compile(r"^([+-]?)0+(?=[0-9])") + +# An all-zero mantissa, whatever the exponent: '0.00', '-0.0' and '0e5' spell zero, +# '1e-400' does not. Mirrors openjd_expr::value::text_spells_zero. +_SPELLS_ZERO = re.compile(r"^[+-]?[0.]*[0][0.]*(?:[eE][+-]?[0-9]+)?$") + + +def _spells_zero(text: str) -> bool: + return _SPELLS_ZERO.match(text) is not None + + def _normalized_range_element(elem: str, to_int: bool) -> Any: - """The number an ````/```` range element denotes -- an - ``int`` for ````, and for ```` the plain-notation text - of the value, since ``str(Decimal)`` cannot render every magnitude without an - exponent. Returns the element unchanged when it does not denote a number, or - when its plain-notation form would not fit this field's character cap.""" + """The value an ````/```` range element denotes. + + An ```` becomes an ``int``. A ```` keeps its text less + redundant leading zeros, so the decimal places it was written with survive + (§7.5). Returns the element unchanged when it does not denote a number. + """ try: if to_int: - return int(elem) - value = Decimal(elem) - if not value.is_finite(): - # Decimal accepts 'nan'/'inf', which are not s; leave - # them as text rather than changing how they render. - return elem - # Bound the expansion from the exponent before performing it. Decimal - # construction from a string is unbounded -- the context's Emax/Emin - # constrain arithmetic results, not construction, so Decimal('1e999999999') - # is finite and the guard above does not stop it -- and the plain-notation - # length is linear in the exponent, so 11 characters of template text - # would otherwise materialize ~10**9 characters. - # - # The bound is the character cap this field already enforces, because an - # expansion past it cannot be carried here as text at all: the value is - # stored in a TaskRangeList element, whose str member is - # TaskParameterStringValueAsJob, and pydantic's union falls through to - # the numeric members instead, rendering the element as an integer, or as - # inf, or as 0.0. Such an element keeps its source text -- what this - # function already does for anything it cannot normalize, and how it - # rendered before normalization was introduced. - exponent = cast(int, value.as_tuple().exponent) # finite: never 'n'/'N'/'F' - # Length of the '[-].' text returned below. Stripping - # redundant zeros can only shorten it, so this is an upper bound. - expansion_len = ( - (1 if value.is_signed() else 0) - + max(value.adjusted() + 1, 1) # integer digits, at least one - + 1 # the decimal point - + max(-exponent, 1) # fraction digits, at least one - ) - if expansion_len > _MAX_TASK_PARAM_VALUE_LEN: - return elem - # format(value, 'f') is exact and plain at every magnitude: with no - # precision in the format spec it neither rounds to getcontext().prec - # nor switches to exponent notation, so an embedding app that sets a - # different context cannot change what this library renders. Neither - # holds of the obvious alternatives -- normalize() rounds to the - # ambient precision, str() switches to exponent notation below 1e-6, - # and quantize() raises InvalidOperation once the result needs more - # digits than the ambient precision allows. - text = format(value, "f") - negative = text.startswith("-") - integer, _, fraction = text.lstrip("-").partition(".") - # A denotes a number, so redundant zeros in its source - # text are not part of the value: '02.50' is 2.5. One fractional digit - # is always kept, matching openjd-rs, which renders the float 1.0 as - # `1.0` and never as `1`. - integer = integer.lstrip("0") or "0" - fraction = fraction.rstrip("0") or "0" - if negative and integer == "0" and fraction == "0": - # The number denoted by '-0.0' is zero, which has no sign; openjd-rs - # renders it `0.0`. - negative = False - return f"{'-' if negative else ''}{integer}.{fraction}" - except (ValueError, ArithmeticError): - # Not a number at all — e.g. a format string that resolved to - # non-numeric text. A literal range is already checked against its - # element type at template parse time, so leave such a value to the - # existing behaviour rather than adding a rejection path here. + return int(elem) # int() already drops leading zeros + # Parsed only to check it is a number; the text is what renders. Not a + # Decimal -- re-rendering one is context-sensitive and unbounded (§7.5). + float(elem) + except ValueError: + # A resolved format string can be non-numeric. Literals are checked at + # template parse time, so carry it through rather than rejecting here. return elem + # Trimmed because the text reaches a command line and float() ignores + # surrounding whitespace; openjd-rs trims here too. + text = _REDUNDANT_LEADING_ZEROS.sub(r"\1", elem.strip()) + # Zero has no sign, so drop one without dropping the decimal places: '-0.00' + # renders `0.00`. Decided from the text, not from `value` -- the parse + # underflows to 0.0 below ~5e-324, and a tiny value's digits are exactly what + # a is for. + return text.lstrip("+-") if _spells_zero(text) else text # Target model for task parameters when instantiating a job. @@ -1327,22 +1297,14 @@ class RangeListTaskParameterDefinition(OpenJDModel_v2023_09): @field_validator("range", mode="before") @classmethod def _normalize_numeric_range_elements(cls, value: Any, info: ValidationInfo) -> Any: - # §3.4.1.1/§3.4.1.2: an element is ` | - # ` and a element is ` | - # `, where an / is "a string whose - # value is the string representation of" a number (§2.3, §2.4). Such an - # element therefore denotes that number, not its source text, so '02' - # is the task value 2 and '02.50' is 2.5. Python was keeping the source - # text, which reaches a task command line as `--frame 02`. - # - # Applied on the instantiation target so every inbound range path is - # covered: a literal list, a range-expression expansion, and an RFC 0006 - # typed whole-field resolution all funnel through this model. + # §7.5: a string-form range element loses redundant leading zeros and + # keeps its decimal places, so '02' is the value 2 and '02.50' renders + # `2.50`. Numeric literals carry no such request and are left as parsed; + # STRING and PATH ranges have no numeric form at all. # - # Numeric elements are left exactly as parsed. A FLOAT range of [1.0] - # renders 1.0, which openjd-rs also does and the conformance suite pins - # (base/jobs/3.4--float-parameter). STRING and PATH ranges are text by - # definition and are never touched. + # On the instantiation target so all three inbound paths are covered: a + # literal list, a range-expression expansion, and RFC 0006 whole-field + # resolution. param_type = info.data.get("type") if not isinstance(value, list) or param_type not in ( TaskParameterType.INT, diff --git a/test/openjd/model_v0/test_step_param_space_iter.py b/test/openjd/model_v0/test_step_param_space_iter.py index d11e277..5cfc2ce 100644 --- a/test/openjd/model_v0/test_step_param_space_iter.py +++ b/test/openjd/model_v0/test_step_param_space_iter.py @@ -613,8 +613,8 @@ def test_int_range_intstring_elements(self) -> None: def test_float_range_floatstring_elements(self) -> None: # WHEN a FLOAT range is written with elements - # THEN each task gets the number the element denotes - assert self._task_values("FLOAT", ["1.5", "02.50"]) == ["1.5", "2.5"] + # THEN each task gets the element's text, less redundant leading zeros + assert self._task_values("FLOAT", ["1.5", "02.50", "3.500"]) == ["1.5", "2.50", "3.500"] def test_float_range_numeric_literals_keep_their_scale(self) -> None: # A literal is not a string representation, so it keeps the scale diff --git a/test/openjd/model_v0/v2023_09/test_parameter_space.py b/test/openjd/model_v0/v2023_09/test_parameter_space.py index 25ae76a..442ba36 100644 --- a/test/openjd/model_v0/v2023_09/test_parameter_space.py +++ b/test/openjd/model_v0/v2023_09/test_parameter_space.py @@ -441,13 +441,12 @@ def test_list_form_range_is_still_capped(self, model: Any, obj: dict[str, Any]) class TestRangeListElementNormalization: - """§3.4.1.1/§3.4.1.2: an `` element is ` | ` - and a `` element is ` | `. An - ``/`` is "a string whose value is the string - representation of" a number (§2.3, §2.4), so it denotes that number and not - its source text — a range of `['1', '02', '003']` is the task values 1, 2 and - 3. These values reach a task command line, so keeping the text renders - `--frame 02`. + """§7.5 on a range element: leading zeros go, decimal places stay. + + `['1', '02', '003']` is the task values 1, 2 and 3; keeping the text renders + `--frame 02`. But `'2.50'` renders `2.50`, because the string form is how a + template asks for a fixed number of decimal places — + `EXPR/jobs/expr1.3.4--float-passthrough` pins that for a FLOAT default. """ @pytest.mark.parametrize( @@ -471,96 +470,86 @@ def test_intstring_elements_carry_their_value(self, param_type: str) -> None: @pytest.mark.parametrize( "element,expected", ( - pytest.param("1.5", "1.5", id="no normalization needed"), - pytest.param("02.50", "2.5", id="leading and trailing zeros"), - pytest.param("3.500", "3.5", id="trailing zeros"), - pytest.param("-02.50", "-2.5", id="negative"), - # An integral float keeps one fractional digit. openjd-rs renders - # the '1.0' as `1.0` and '007' as `7.0`, so dropping - # the fraction would make the same number render two ways depending - # on whether it was written as a or a . - pytest.param("1.0", "1.0", id="integral float"), + pytest.param("1.5", "1.5", id="nothing to strip"), + # The rule, in both directions at once. + pytest.param("02.50", "2.50", id="leading zero goes, trailing zero stays"), + pytest.param("3.500", "3.500", id="trailing zeros stay"), + pytest.param("-02.50", "-2.50", id="negative"), + pytest.param("+02.50", "+2.50", id="explicit plus sign is kept"), + pytest.param("007", "7", id="leading zeros on a whole number"), + pytest.param("100", "100", id="a zero that is a significant digit"), + # Neither the zero before the point nor the last of an all-zero + # integer part is redundant. + pytest.param("0.50", "0.50", id="the necessary leading zero stays"), + pytest.param("000", "0", id="all zeros keeps one"), + pytest.param("0", "0", id="bare zero"), pytest.param("0.0", "0.0", id="zero"), + pytest.param("0.00", "0.00", id="zero keeps its trailing zeros too"), + # Zero has no sign, which openjd-rs and mainline both render away. + # Dropping it must not drop the decimal places with it. pytest.param("-0.0", "0.0", id="negative zero has no sign"), - pytest.param("007", "7.0", id="leading zeros on a whole number"), - pytest.param("100", "100.0", id="whole number"), - # Exponent notation must never reach a task command line, at any - # magnitude and in either direction. - pytest.param("1E+2", "100.0", id="exponent notation in the source"), - pytest.param( - "0.0000001", - "0.0000001", - id="below 1e-6, where Decimal.__str__ would give 1E-7", - ), - pytest.param( - "1E+30", - "1" + "0" * 30 + ".0", - id="more digits than the default decimal precision", - ), + pytest.param("-0.00", "0.00", id="unsigned, but still two places"), + # An all-zero mantissa spells zero whatever the exponent. + pytest.param("0E+2", "0E+2", id="exponent form of zero"), + pytest.param("-0e5", "0e5", id="signed exponent form of zero"), + # Underflow is not zero being spelled: the digits are the request. + pytest.param("1e-400", "1e-400", id="underflow keeps its digits"), + pytest.param("0." + "0" * 400 + "1", "0." + "0" * 400 + "1", id="tiny plain decimal"), + # float() ignores surrounding whitespace and this text reaches a + # command line, so it is trimmed rather than forwarded. + pytest.param(" 1.5 ", "1.5", id="surrounding whitespace"), + pytest.param("1.0", "1.0", id="integral float keeps its point"), + # Exponent notation is the author's own text and is forwarded. The + # FLOAT parameter default path does the same for `default: "1E+2"`. + pytest.param("1E+2", "1E+2", id="exponent notation"), + pytest.param("01E+2", "1E+2", id="leading zero on an exponent form"), + pytest.param("0.0000001", "0.0000001", id="below 1e-6"), pytest.param( "1.2345678901234567890123456789012345678901", "1.2345678901234567890123456789012345678901", - id="more significant digits than the default decimal precision", + id="more significant digits than a float can hold", ), ), ) - def test_floatstring_elements_carry_their_value(self, element: str, expected: str) -> None: + def test_floatstring_elements_keep_their_scale(self, element: str, expected: str) -> None: # WHEN the instantiation target parses a float range holding a model = _parse_model( model=RangeListTaskParameterDefinition, obj={"type": "FLOAT", "range": [element]} ) - # THEN the element renders as the number it denotes + # THEN it renders as written, less any redundant leading zeros assert [str(v) for v in model.range] == [expected] - @pytest.mark.parametrize( - "element", - ( - # Decimal's context bounds arithmetic results, not construction from - # a string, so these are finite: 11 characters of template text - # whose plain-notation expansion is ~10**9 characters. - pytest.param("1e999999999", id="huge positive exponent"), - pytest.param("1e-999999999", id="huge negative exponent"), - # One character past the cap in each direction: both expand to 1025. - pytest.param("1E+1022", id="one past the cap, positive exponent"), - pytest.param("1E-1023", id="one past the cap, negative exponent"), - ), - ) - def test_element_too_long_to_expand_keeps_its_source_text(self, element: str) -> None: - # WHEN the instantiation target parses a whose plain-notation - # form would not fit TaskParameterStringValueAsJob's 1024-character cap - model = _parse_model( - model=RangeListTaskParameterDefinition, obj={"type": "FLOAT", "range": [element]} - ) - - # THEN it keeps its source text rather than being expanded. Expanding it - # would allocate the whole expansion, and the result could not be carried - # as text here anyway: it fails the str member of TaskRangeList's union, - # and pydantic then falls through to the numeric members, rendering the - # element as an integer, or as inf, or as 0.0. - assert [str(v) for v in model.range] == [element] - @pytest.mark.parametrize( "element,expected", ( - pytest.param("1E+1021", "1" + "0" * 1021 + ".0", id="positive exponent"), - pytest.param("1E-1022", "0." + "0" * 1021 + "1", id="negative exponent"), + # An exponent too large for a float still costs only its own + # characters, which is the whole point: nothing expands it. + pytest.param("1e999999999", "1e999999999", id="huge positive exponent"), + pytest.param("1E+1022", "1E+1022", id="positive exponent past the field cap"), + # Too small for a float either way, and the digits still stand: + # underflowing to 0.0 is a parse limit, not the author writing zero. + pytest.param("1e-999999999", "1e-999999999", id="huge negative exponent"), + pytest.param("1E-1023", "1E-1023", id="negative exponent past the field cap"), ), ) - def test_element_at_the_cap_is_still_normalized(self, element: str, expected: str) -> None: - # GIVEN a whose plain-notation form is exactly 1024 - # characters — at the cap, not past it - assert len(expected) == 1024 - - # WHEN the instantiation target parses it + def test_a_huge_exponent_costs_only_its_own_characters( + self, element: str, expected: str + ) -> None: + # WHEN 11 characters of template text denote a number needing ~10**9 + # digits in plain notation model = _parse_model( model=RangeListTaskParameterDefinition, obj={"type": "FLOAT", "range": [element]} ) - # THEN the length bound has not cost it its normalization + # THEN nothing expands it, so no bound on the exponent is needed to stop a + # template allocating ~10**9 characters. Long *literal* text can still + # exceed TaskParameterStringValueAsJob's cap and fall through to a numeric + # member of the union, as it does on mainline and in 0.11.6; only the + # expansion is gone. assert [str(v) for v in model.range] == [expected] - def test_floatstring_normalization_ignores_the_decimal_context(self) -> None: + def test_floatstring_rendering_ignores_the_decimal_context(self) -> None: # GIVEN an embedding application that has narrowed the process-wide # decimal context, and a with more significant digits # than that precision allows @@ -573,8 +562,8 @@ def test_floatstring_normalization_ignores_the_decimal_context(self) -> None: model=RangeListTaskParameterDefinition, obj={"type": "FLOAT", "range": [element]} ) - # THEN the value is unrounded: what this library renders is a property of - # the template, not of the host application's decimal context + # THEN it is unrounded: the rendering is a property of the template, not + # of the host application's decimal context assert [str(v) for v in model.range] == [element] @pytest.mark.parametrize(