Skip to content
Merged
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
4 changes: 2 additions & 2 deletions THIRD-PARTY-LICENSES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

------
** pydantic; version 2.13.4 -- https://pypi.org/project/pydantic/
** pydantic; version 2.13.5 -- https://pypi.org/project/pydantic/
The MIT License (MIT)

Copyright (c) 2017 to present Pydantic Services Inc. and individual contributors.
Expand All @@ -48,7 +48,7 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

------
** pydantic_core; version 2.46.4 -- https://pypi.org/project/pydantic_core/
** pydantic_core; version 2.46.5 -- https://pypi.org/project/pydantic_core/
The MIT License (MIT)

Copyright (c) 2022 Samuel Colvin
Expand Down
9 changes: 8 additions & 1 deletion src/openjd/model/_step_param_space_iter.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,14 @@ def _create_expr_tree(
name=name,
type=ParameterValueType(parameter.type),
range=parameter.range,
range_set=set(parameter.range),
# Non-CHUNK containment compares against ParameterValue.value,
# which is the rendered form of a range element (see
# RangeListIdentifierNode.__getitem__), so the set has to hold
# rendered elements too. A set of the raw elements only matched
# when a range happened to be written as strings — an INT range
# written as `[1, 2, 3]` reported every one of its own values as
# not contained.
range_set={str(v) for v in parameter.range},
)
else:
return RangeExpressionIdentifierNode(
Expand Down
107 changes: 106 additions & 1 deletion src/openjd/model/v2023_09/_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -1208,7 +1208,10 @@ def __new__(cls, value: str, *, context: ModelParsingContextInterface = ModelPar
list[Union[Decimal, TaskParameterStringValue]], Field(min_length=1, max_length=1024)
]
StringRangeList = Annotated[list[TaskParameterStringValue], Field(min_length=1, max_length=1024)]
TaskParameterStringValueAsJob = Annotated[str, StringConstraints(min_length=0, max_length=1024)]
_MAX_TASK_PARAM_VALUE_LEN = 1024
TaskParameterStringValueAsJob = Annotated[
str, StringConstraints(min_length=0, max_length=_MAX_TASK_PARAM_VALUE_LEN)
]

TaskRangeList = list[Union[TaskParameterStringValueAsJob, int, float, Decimal]]

Expand Down Expand Up @@ -1242,6 +1245,76 @@ def _validate_target_runtime_seconds(cls, value: Any, info: ValidationInfo) -> A
return validate_int_fmtstring_field(value, ge=0, context=context)


def _normalized_range_element(elem: str, to_int: bool) -> Any:
"""The number an ``<intstring>``/``<floatstring>`` range element denotes -- an
``int`` for ``<intstring>``, and for ``<floatstring>`` 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."""
try:
if to_int:
return int(elem)
Comment thread
leongdl marked this conversation as resolved.
value = Decimal(elem)
if not value.is_finite():
# Decimal accepts 'nan'/'inf', which are not <floatstring>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 '[-]<integer>.<fraction>' 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")
Comment thread
leongdl marked this conversation as resolved.
negative = text.startswith("-")
integer, _, fraction = text.lstrip("-").partition(".")
# A <floatstring> 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}"
Comment thread
leongdl marked this conversation as resolved.
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 elem


# Target model for task parameters when instantiating a job.
class RangeListTaskParameterDefinition(OpenJDModel_v2023_09):
# element type of items in the range
Expand All @@ -1251,6 +1324,38 @@ class RangeListTaskParameterDefinition(OpenJDModel_v2023_09):
# has a value when type is CHUNK[INT], which is only possible from the TASK_CHUNKING extension
chunks: Optional[TaskChunksDefinition] = None

@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 <IntRangeList> element is `<integer> |
# <intstring>` and a <FloatRangeList> element is `<float> |
# <floatstring>`, where an <intstring>/<floatstring> 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.
#
# 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.
Comment thread
leongdl marked this conversation as resolved.
param_type = info.data.get("type")
if not isinstance(value, list) or param_type not in (
TaskParameterType.INT,
TaskParameterType.CHUNK_INT,
TaskParameterType.FLOAT,
):
return value
to_int = param_type != TaskParameterType.FLOAT
return [
_normalized_range_element(elem, to_int) if isinstance(elem, str) else elem
Comment thread
leongdl marked this conversation as resolved.
for elem in value
]

@field_validator("range")
@classmethod
def _validate_range_len(cls, value: Any) -> Any:
Expand Down
99 changes: 99 additions & 0 deletions test/openjd/model_v0/test_step_param_space_iter.py
Original file line number Diff line number Diff line change
Expand Up @@ -567,3 +567,102 @@ def test_nested_expr_iteration(self) -> None:
"Param3": ParameterValue(type=ParameterValueType.STRING, value="11"),
"Param4": ParameterValue(type=ParameterValueType.INT, value="20"),
} not in it


class TestRangeListElementValues:
"""The values a task command line actually receives, end to end from a
template through `create_job`.

§3.4.1.1/§3.4.1.2 allow a range element to be written in the `<intstring>` /
`<floatstring>` form, which §2.3/§2.4 define as "a string whose value is the
string representation of" a number. Such an element denotes the number, so
`['1', '02', '003']` on an INT parameter is the task values 1, 2 and 3 — not
the source text, which would reach a renderer as `--frame 02`.
"""

@staticmethod
def _task_values(param_type: str, range_list: list) -> list[str]:
job_template = parse_model(
model=JobTemplate_2023_09,
obj={
"specificationVersion": "jobtemplate-2023-09",
"name": "Job",
"steps": [
{
"name": "step",
"parameterSpace": {
"taskParameterDefinitions": [
{"name": "P", "type": param_type, "range": range_list}
]
},
"script": {"actions": {"onRun": {"command": "do thing"}}},
}
],
},
)
job = create_job(job_template=job_template, job_parameter_values=dict())
return [
params["P"].value
for params in StepParameterSpaceIterator(space=job.steps[0].parameterSpace)
]

def test_int_range_intstring_elements(self) -> None:
# WHEN an INT range is written with <intstring> elements
# THEN each task gets the integer the element denotes
assert self._task_values("INT", ["1", "02", "003"]) == ["1", "2", "3"]

def test_float_range_floatstring_elements(self) -> None:
# WHEN a FLOAT range is written with <floatstring> elements
# THEN each task gets the number the element denotes
assert self._task_values("FLOAT", ["1.5", "02.50"]) == ["1.5", "2.5"]

def test_float_range_numeric_literals_keep_their_scale(self) -> None:
# A <float> literal is not a string representation, so it keeps the scale
# it was written with. openjd-rs renders an integral float as `1.0` too,
# and the conformance suite pins this
# (2023-09/base/jobs/3.4--float-parameter).
assert self._task_values("FLOAT", [0.5, 1.0, 1.5]) == ["0.5", "1.0", "1.5"]

@pytest.mark.parametrize(
"param_type,range_list",
(
pytest.param("STRING", ["02", "1.50"], id="STRING"),
pytest.param("PATH", ["/frames/02", "/frames/003"], id="PATH"),
),
)
def test_string_and_path_ranges_are_text(self, param_type: str, range_list: list) -> None:
# §3.4.1.3/§3.4.1.4 give STRING and PATH no numeric element form, so
# nothing about their elements is normalizable.
assert self._task_values(param_type, range_list) == range_list

@pytest.mark.parametrize(
"range_list",
(
pytest.param([10, 11, 12], id="written as integers"),
pytest.param(["10", "11", "12"], id="written as intstrings"),
),
)
def test_containment_matches_the_rendered_values(self, range_list: list) -> None:
# Containment compares a ParameterValue against the parameter space, and
# ParameterValue.value is the *rendered* form of a range element. The
# containment set was built from the raw elements, so an INT range
# written as `[10, 11, 12]` reported its own values as not contained.

# GIVEN a parameter space over an int range
space = StepParameterSpace_2023_09(
taskParameterDefinitions={
"Param1": RangeListTaskParameterDefinition_2023_09(
type=ParameterValueType.INT, range=range_list
),
},
)

# WHEN it is iterated
it = StepParameterSpaceIterator(space=space)

# THEN every value it yields is contained in it, however the range was written
values = list(it)
assert [v["Param1"].value for v in values] == ["10", "11", "12"]
for value in values:
assert value in it
assert {"Param1": ParameterValue(type=ParameterValueType.INT, value="13")} not in it
Loading
Loading