Skip to content

Commit 5d3c9bb

Browse files
authored
feat: add validation that associative op's args are equal length (#96)
Problem: The Open Job Description specification says "Every comma-separated expression within an associative operator must have the exact same number of values defined in their range." The current model validators to not ensure that this is the case. Reference: https://github.com/OpenJobDescription/openjd-specifications/wiki/2023-09-Template-Schemas#343-combinationexpr Solution: I've implemented validate_step_parameter_space_dimensions() to validate this constraint and added a validator to the StepParameterSpace to use it. My first inclination was to implement this validation as part of constructing the StepParameterSpaceIterator since it already has a recursive expression parse, but that is a chicken & egg problem; we need a StepParameterSpace to construct a StepParameterSpaceIterator, but we don't have one yet when doing validation for the construction of a StepParameterSpace. Signed-off-by: Daniel Neilson <[email protected]>
1 parent 66ea664 commit 5d3c9bb

File tree

5 files changed

+214
-0
lines changed

5 files changed

+214
-0
lines changed

src/openjd/model/_internal/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@
88
from ._combination_expr import Parser as CombinationExpressionParser
99
from ._combination_expr import ProductNode as CombinationExpressionProductNode
1010
from ._create_job import instantiate_model
11+
from ._param_space_dim_validation import validate_step_parameter_space_dimensions
1112
from ._variable_reference_validation import prevalidate_model_template_variable_references
1213

1314
__all__ = (
1415
"instantiate_model",
1516
"prevalidate_model_template_variable_references",
17+
"validate_step_parameter_space_dimensions",
1618
"validate_unique_elements",
1719
"CombinationExpressionAssociationNode",
1820
"CombinationExpressionIdentifierNode",
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
3+
from functools import reduce
4+
from operator import mul
5+
from ._combination_expr import AssociationNode, IdentifierNode, Node, Parser, ProductNode
6+
from .._errors import ExpressionError
7+
8+
9+
def validate_step_parameter_space_dimensions(
10+
parameter_range_lengths: dict[str, int], combination: str
11+
) -> None:
12+
"""This validates that a CombinationExpr satisfies constraints placed
13+
on the ranges of the elements of the expression.
14+
Specifically that the arguments to an Associative operator all have
15+
the exact same number of elements.
16+
17+
Args:
18+
- parameter_range_lengths: dict[str,int] -- A map from identifier name
19+
to the number of elements in that parameter's range.
20+
- combination: str -- The combination expression.
21+
22+
Raises:
23+
ExpressionError if the combination expression violates constraints.
24+
"""
25+
parse_tree = Parser().parse(combination)
26+
_validate_expr_tree(parse_tree, parameter_range_lengths)
27+
28+
29+
def _validate_expr_tree(root: Node, parameter_range_lengths: dict[str, int]) -> int:
30+
# Returns the length of the subtree while recursively validating it.
31+
if isinstance(root, IdentifierNode):
32+
name = root.parameter
33+
return parameter_range_lengths[name]
34+
elif isinstance(root, AssociationNode):
35+
# Association requires that all arguments are the exact same length.
36+
# Ensure that is the case
37+
arg_lengths = tuple(
38+
_validate_expr_tree(child, parameter_range_lengths) for child in root.children
39+
)
40+
if len(set(arg_lengths)) > 1:
41+
raise ExpressionError(
42+
(
43+
"Associative expressions must have arguments with identical ranges. "
44+
"Expression %s has argument lengths %s." % (str(root), arg_lengths)
45+
)
46+
)
47+
return arg_lengths[0]
48+
else:
49+
# For type hinting
50+
assert isinstance(root, ProductNode)
51+
return reduce(
52+
mul, (_validate_expr_tree(child, parameter_range_lengths) for child in root.children), 1
53+
)

src/openjd/model/v2023_09/_model.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
)
3333
from .._internal import (
3434
CombinationExpressionParser,
35+
validate_step_parameter_space_dimensions,
3536
validate_unique_elements,
3637
)
3738
from .._internal._variable_reference_validation import (
@@ -729,6 +730,25 @@ class StepParameterSpace(OpenJDModel_v2023_09):
729730
taskParameterDefinitions: dict[Identifier, TaskRangeParameter]
730731
combination: Optional[CombinationExpr] = None
731732

733+
@validator("combination")
734+
def _validate_parameter_space(cls, v: str, values: dict[str, Any]) -> str:
735+
if v is None:
736+
return v
737+
param_defs = cast(dict[Identifier, TaskRangeParameter], values["taskParameterDefinitions"])
738+
parameter_range_lengths = {
739+
id: (
740+
len(param.range)
741+
if isinstance(param.range, list)
742+
else len(IntRangeExpr.from_str(param.range))
743+
)
744+
for id, param in param_defs.items()
745+
}
746+
try:
747+
validate_step_parameter_space_dimensions(parameter_range_lengths, v)
748+
except ExpressionError as e:
749+
raise ValueError(str(e)) from None
750+
return v
751+
732752

733753
class StepParameterSpaceDefinition(OpenJDModel_v2023_09):
734754
"""Definition of a Step's parameter space. The parameter space is the multidimensional
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
2+
3+
import pytest
4+
import re
5+
6+
from openjd.model._internal import validate_step_parameter_space_dimensions
7+
from openjd.model import ExpressionError
8+
9+
10+
@pytest.mark.parametrize(
11+
"param_ranges, combination",
12+
[
13+
pytest.param({"A": 5}, "A", id="Identifier"),
14+
pytest.param({"A": 5, "B": 10}, "A * B", id="Product"),
15+
pytest.param({"A": 5, "B": 5}, "(A,B)", id="Association"),
16+
pytest.param({"A": 5, "B": 5, "C": 10}, "(A,B)*C", id="Association-Product"),
17+
pytest.param({"A": 5, "B": 5, "C": 10}, "C*(A,B)", id="Product-Association"),
18+
pytest.param({"A": 5, "B": 5, "C": 1}, "(A*C,B)", id="Nested product 2"),
19+
pytest.param({"A": 5, "B": 5, "C": 1}, "(A,B*C)", id="Nested product 2"),
20+
pytest.param({"A": 5, "B": 5, "C": 5}, "((A,C),B)", id="Nested association 1"),
21+
pytest.param({"A": 5, "B": 5, "C": 5}, "(A,(B,C))", id="Nested association 2"),
22+
],
23+
)
24+
def test_allowable(param_ranges: dict[str, int], combination: str) -> None:
25+
# Test that perfectly valid parameter spaces do not raise exceptions
26+
27+
# THEN
28+
validate_step_parameter_space_dimensions(param_ranges, combination)
29+
30+
31+
@pytest.mark.parametrize(
32+
"param_ranges, combination, expected_exception",
33+
[
34+
pytest.param(
35+
{"A": 5, "B": 10},
36+
"(A,B)",
37+
"Associative expressions must have arguments with identical ranges. Expression (A, B) has argument lengths (5, 10).",
38+
id="2-arg",
39+
),
40+
pytest.param(
41+
{"A": 5, "B": 10, "C": 10},
42+
"(A,B,C)",
43+
"Associative expressions must have arguments with identical ranges. Expression (A, B, C) has argument lengths (5, 10, 10).",
44+
id="3-arg; A",
45+
),
46+
pytest.param(
47+
{"A": 10, "B": 5, "C": 10},
48+
"(A,B,C)",
49+
"Associative expressions must have arguments with identical ranges. Expression (A, B, C) has argument lengths (10, 5, 10).",
50+
id="3-arg; B",
51+
),
52+
pytest.param(
53+
{"A": 10, "B": 10, "C": 5},
54+
"(A,B,C)",
55+
"Associative expressions must have arguments with identical ranges. Expression (A, B, C) has argument lengths (10, 10, 5).",
56+
id="3-arg; C",
57+
),
58+
pytest.param(
59+
{"A": 5, "B": 5, "C": 5},
60+
"(A,B*C)",
61+
"Associative expressions must have arguments with identical ranges. Expression (A, B * C) has argument lengths (5, 25).",
62+
id="Nested product",
63+
),
64+
pytest.param(
65+
{"A": 5, "B": 10, "C": 10},
66+
"(A,(B,C))",
67+
"Associative expressions must have arguments with identical ranges. Expression (A, (B, C)) has argument lengths (5, 10).",
68+
id="Nested Association",
69+
),
70+
pytest.param(
71+
{"A": 5, "B": 10, "C": 10},
72+
"C * (A,B)",
73+
"Associative expressions must have arguments with identical ranges. Expression (A, B) has argument lengths (5, 10).",
74+
id="Recurse to association 1",
75+
),
76+
pytest.param(
77+
{"A": 5, "B": 10, "C": 10},
78+
"(A,B) * C",
79+
"Associative expressions must have arguments with identical ranges. Expression (A, B) has argument lengths (5, 10).",
80+
id="Recurse to association 2",
81+
),
82+
],
83+
)
84+
def test_mismatched_association(
85+
param_ranges: dict[str, int], combination: str, expected_exception: str
86+
) -> None:
87+
# Test that expressions that contain associations with mismatched argument lengths
88+
# all raise the expected exception.
89+
90+
# THEN
91+
with pytest.raises(ExpressionError, match=re.escape(expected_exception)):
92+
validate_step_parameter_space_dimensions(param_ranges, combination)

test/openjd/model/test_create_job.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import tempfile
55
import pytest
66
from pathlib import Path
7+
from typing import Any
78

89
from openjd.model import (
910
DecodeValidationError,
@@ -737,3 +738,49 @@ def test_fails_to_instantiate(self) -> None:
737738
"1 validation errors for JobTemplate\nname:\n\tensure this value has at most 128 characters"
738739
in str(excinfo.value)
739740
)
741+
742+
def test_uneven_parameter_space_association(self) -> None:
743+
# Test that when the arguments to an Association operator in a
744+
# parameter space combination expression have differing lengths then
745+
# we raise an appropriate exception.
746+
#
747+
# Note: This validation is run in the create job flow because we need
748+
# to have a fully instantiated the step parameter space's task parameter
749+
# definitions to know how large each parameter range is.
750+
751+
# GIVEN
752+
job_template = _parse_model(
753+
model=JobTemplate_2023_09,
754+
obj={
755+
"specificationVersion": "jobtemplate-2023-09",
756+
"name": "Job",
757+
"steps": [
758+
{
759+
"name": "Step",
760+
"parameterSpace": {
761+
"taskParameterDefinitions": [
762+
{"name": "A", "type": "INT", "range": "1-10"},
763+
{"name": "B", "type": "INT", "range": [1, 2]},
764+
],
765+
"combination": "(A,B)",
766+
},
767+
"script": {"actions": {"onRun": {"command": "do something"}}},
768+
}
769+
],
770+
},
771+
)
772+
parameter_values = dict[str, Any]()
773+
774+
# WHEN
775+
with pytest.raises(DecodeValidationError) as excinfo:
776+
# This'll have an error when instantiating the Job due to the Job's name being too long.
777+
create_job(
778+
job_template=job_template,
779+
job_parameter_values=parameter_values,
780+
)
781+
782+
# THEN
783+
assert (
784+
"1 validation errors for JobTemplate\nsteps[0] -> steps[0] -> parameterSpace -> combination:\n\tAssociative expressions must have arguments with identical ranges. Expression (A, B) has argument lengths (10, 2)."
785+
in str(excinfo.value)
786+
)

0 commit comments

Comments
 (0)