diff --git a/conformance-tests/2023-09/EXPR/job_templates/2--type-case-insensitive.yaml b/conformance-tests/2023-09/EXPR/job_templates/2--type-case-insensitive.yaml new file mode 100644 index 0000000..b07ca03 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2--type-case-insensitive.yaml @@ -0,0 +1,46 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: MyInt + type: int +- name: MyFloat + type: Float +- name: MyString + type: STRING +- name: MyPath + type: pAtH +- name: MyBool + type: Bool + default: true +- name: MyRangeExpr + type: range_expr + default: "1-10" +- name: MyListString + type: "list[string]" + default: ["a", "b"] +- name: MyListInt + type: "List[Int]" + default: [1, 2] +- name: MyListFloat + type: "LIST[float]" + default: [1.0, 2.0] +- name: MyListPath + type: "list[PATH]" + default: ["/tmp/a", "/tmp/b"] +- name: MyListBool + type: "List[Bool]" + default: [true, false] +- name: MyListListInt + type: "list[list[int]]" + default: [[1, 2], [3]] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.10--range-expr-param-invalid-default.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.10--range-expr-param-invalid-default.invalid.yaml new file mode 100644 index 0000000..b65ff55 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.10--range-expr-param-invalid-default.invalid.yaml @@ -0,0 +1,17 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Frames + type: RANGE_EXPR + default: "not-a-range" +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.10--range-expr-param.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.10--range-expr-param.yaml new file mode 100644 index 0000000..04f9220 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.10--range-expr-param.yaml @@ -0,0 +1,86 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +# Single values +- name: SinglePositive + type: RANGE_EXPR + default: "42" +- name: SingleZero + type: RANGE_EXPR + default: "0" +- name: SingleNegative + type: RANGE_EXPR + default: "-100" +# Simple ranges +- name: SimpleRange + type: RANGE_EXPR + default: "1-100" +- name: NegativeRange + type: RANGE_EXPR + default: "-10-10" +# Ranges with step +- name: RangeWithStep + type: RANGE_EXPR + default: "0-100:10" +- name: RangeStepNotEvenlyDivide + type: RANGE_EXPR + default: "1-9:3" +- name: NegativeStep + type: RANGE_EXPR + default: "10-1:-1" +- name: NegativeRangeNegativeStep + type: RANGE_EXPR + default: "-1--10:-1" +# Comma-separated values +- name: CommaSeparated + type: RANGE_EXPR + default: "1,3,5,7" +# Mixed ranges and values +- name: MixedRangesAndValues + type: RANGE_EXPR + default: "1-10,20-30:2,42" +# Multiple ranges +- name: MultipleRanges + type: RANGE_EXPR + default: "0-3:3,5-10:5,12,13,14,15" +# Ranges out of order (parser normalizes) +- name: OutOfOrder + type: RANGE_EXPR + default: "20-29,0-9,10-19" +# Whitespace (parser ignores) +- name: WithWhitespace + type: RANGE_EXPR + default: " 0 - 1 : 1, 2 - 100 : 1" +# Large values +- name: LargeValues + type: RANGE_EXPR + default: "9999999" +# UI controls +- name: WithLineEdit + type: RANGE_EXPR + default: "1-10" + userInterface: + control: LINE_EDIT + label: Frame Range +- name: WithHidden + type: RANGE_EXPR + default: "1-10" + userInterface: + control: HIDDEN +# Length constraints +- name: WithLengthConstraints + type: RANGE_EXPR + default: "1-100" + minLength: 1 + maxLength: 1024 +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.11--list-string-item-not-in-allowed.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.11--list-string-item-not-in-allowed.invalid.yaml new file mode 100644 index 0000000..8cf2cb4 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.11--list-string-item-not-in-allowed.invalid.yaml @@ -0,0 +1,19 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Items + type: LIST[STRING] + default: ["x"] + item: + allowedValues: ["a", "b", "c"] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.11--list-string-item-too-long.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.11--list-string-item-too-long.invalid.yaml new file mode 100644 index 0000000..52f6baa --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.11--list-string-item-too-long.invalid.yaml @@ -0,0 +1,19 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Items + type: LIST[STRING] + default: ["abcdef"] + item: + maxLength: 3 +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.11--list-string-item-too-short.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.11--list-string-item-too-short.invalid.yaml new file mode 100644 index 0000000..313ae50 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.11--list-string-item-too-short.invalid.yaml @@ -0,0 +1,19 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Items + type: LIST[STRING] + default: [""] + item: + minLength: 1 +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.11--list-string-param.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.11--list-string-param.yaml new file mode 100644 index 0000000..8dd24ee --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.11--list-string-param.yaml @@ -0,0 +1,90 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +# Minimal +- name: Minimal + type: LIST[STRING] +# With default +- name: WithDefault + type: LIST[STRING] + default: ["a", "b", "c"] +# Empty default +- name: EmptyDefault + type: LIST[STRING] + default: [] +# Single element +- name: SingleElement + type: LIST[STRING] + default: ["only"] +# Long list +- name: LongList + type: LIST[STRING] + default: ["a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t"] +# Length constraints +- name: WithMinMax + type: LIST[STRING] + default: ["a", "b"] + minLength: 1 + maxLength: 50 +# Min only +- name: MinOnly + type: LIST[STRING] + default: ["a"] + minLength: 0 +# Max only +- name: MaxOnly + type: LIST[STRING] + default: ["a", "b", "c"] + maxLength: 100 +# Item constraints - allowedValues +- name: ItemAllowed + type: LIST[STRING] + default: ["alpha", "beta"] + item: + allowedValues: ["alpha", "beta", "gamma"] +# Item constraints - string length +- name: ItemLength + type: LIST[STRING] + default: ["hi", "bye"] + item: + minLength: 1 + maxLength: 10 +# Both list and item constraints +- name: FullyConstrained + type: LIST[STRING] + default: ["hello", "world"] + minLength: 1 + maxLength: 5 + item: + allowedValues: ["hello", "world", "foo"] + minLength: 3 + maxLength: 5 +# Description +- name: WithDescription + type: LIST[STRING] + default: ["x"] + description: A list of string values +# UI controls +- name: WithLineEditList + type: LIST[STRING] + default: ["a"] + userInterface: + control: LINE_EDIT_LIST + label: Items + groupLabel: Options +- name: WithHidden + type: LIST[STRING] + default: ["a"] + userInterface: + control: HIDDEN +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.11--list-string-scalar-not-list.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.11--list-string-scalar-not-list.invalid.yaml new file mode 100644 index 0000000..c1c5f0f --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.11--list-string-scalar-not-list.invalid.yaml @@ -0,0 +1,17 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Items + type: LIST[STRING] + default: notalist +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.11--list-string-too-long.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.11--list-string-too-long.invalid.yaml new file mode 100644 index 0000000..d83227a --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.11--list-string-too-long.invalid.yaml @@ -0,0 +1,18 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Items + type: LIST[STRING] + default: ["a", "b", "c"] + maxLength: 2 +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.11--list-string-too-short.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.11--list-string-too-short.invalid.yaml new file mode 100644 index 0000000..2ffdbea --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.11--list-string-too-short.invalid.yaml @@ -0,0 +1,18 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Items + type: LIST[STRING] + default: [] + minLength: 1 +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.12--list-path-item-too-short.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.12--list-path-item-too-short.invalid.yaml new file mode 100644 index 0000000..d86be1c --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.12--list-path-item-too-short.invalid.yaml @@ -0,0 +1,19 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Files + type: LIST[PATH] + default: [""] + item: + minLength: 1 +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.12--list-path-param.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.12--list-path-param.yaml new file mode 100644 index 0000000..a961fe1 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.12--list-path-param.yaml @@ -0,0 +1,104 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +# Minimal +- name: Minimal + type: LIST[PATH] +# With default +- name: WithDefault + type: LIST[PATH] + default: ["/tmp/a.exr", "/tmp/b.exr"] +# Empty default +- name: EmptyDefault + type: LIST[PATH] + default: [] +# Single element +- name: SingleElement + type: LIST[PATH] + default: ["/tmp/only.exr"] +# objectType and dataFlow +- name: FileIn + type: LIST[PATH] + objectType: FILE + dataFlow: IN + default: ["/input/a.exr"] +- name: FileOut + type: LIST[PATH] + objectType: FILE + dataFlow: OUT + default: ["/output/a.exr"] +- name: FileInOut + type: LIST[PATH] + objectType: FILE + dataFlow: INOUT + default: ["/data/a.exr"] +- name: DirNone + type: LIST[PATH] + objectType: DIRECTORY + dataFlow: NONE + default: ["/mnt/share"] +# Length constraints +- name: WithMinMax + type: LIST[PATH] + default: ["/a", "/b"] + minLength: 1 + maxLength: 50 +# Item constraints +- name: ItemLength + type: LIST[PATH] + default: ["/tmp/file"] + item: + minLength: 1 + maxLength: 200 +# Both constraints +- name: FullyConstrained + type: LIST[PATH] + default: ["/opt/a", "/opt/b"] + minLength: 1 + maxLength: 10 + item: + minLength: 1 + maxLength: 100 +# Description +- name: WithDescription + type: LIST[PATH] + default: ["/tmp"] + description: A list of paths +# UI controls +- name: WithInputFileList + type: LIST[PATH] + objectType: FILE + dataFlow: IN + default: ["/tmp/a"] + userInterface: + control: CHOOSE_INPUT_FILE_LIST + label: Input Files +- name: WithOutputFileList + type: LIST[PATH] + objectType: FILE + dataFlow: OUT + default: ["/tmp/a"] + userInterface: + control: CHOOSE_OUTPUT_FILE_LIST +- name: WithDirList + type: LIST[PATH] + objectType: DIRECTORY + default: ["/tmp"] + userInterface: + control: CHOOSE_DIRECTORY_LIST +- name: WithHidden + type: LIST[PATH] + default: ["/tmp"] + userInterface: + control: HIDDEN +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.12--list-path-scalar-not-list.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.12--list-path-scalar-not-list.invalid.yaml new file mode 100644 index 0000000..38b45d5 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.12--list-path-scalar-not-list.invalid.yaml @@ -0,0 +1,17 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Files + type: LIST[PATH] + default: /tmp/file +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.12--list-path-too-long.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.12--list-path-too-long.invalid.yaml new file mode 100644 index 0000000..a312174 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.12--list-path-too-long.invalid.yaml @@ -0,0 +1,18 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Files + type: LIST[PATH] + default: ["/a", "/b", "/c"] + maxLength: 2 +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.12--list-path-too-short.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.12--list-path-too-short.invalid.yaml new file mode 100644 index 0000000..36a16a2 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.12--list-path-too-short.invalid.yaml @@ -0,0 +1,18 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Files + type: LIST[PATH] + default: [] + minLength: 1 +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.12--list-path-wrong-item-type.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.12--list-path-wrong-item-type.invalid.yaml new file mode 100644 index 0000000..f7e7699 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.12--list-path-wrong-item-type.invalid.yaml @@ -0,0 +1,17 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Files + type: LIST[PATH] + default: [123, 456] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.13--list-int-item-below-min.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.13--list-int-item-below-min.invalid.yaml new file mode 100644 index 0000000..11a62a9 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.13--list-int-item-below-min.invalid.yaml @@ -0,0 +1,19 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Values + type: LIST[INT] + default: [-5] + item: + minValue: 0 +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.13--list-int-item-not-in-allowed.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.13--list-int-item-not-in-allowed.invalid.yaml new file mode 100644 index 0000000..c242901 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.13--list-int-item-not-in-allowed.invalid.yaml @@ -0,0 +1,19 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Values + type: LIST[INT] + default: [99] + item: + allowedValues: [1, 2, 3] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.13--list-int-param.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.13--list-int-param.yaml new file mode 100644 index 0000000..83dd64a --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.13--list-int-param.yaml @@ -0,0 +1,84 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +# Minimal +- name: Minimal + type: LIST[INT] +# With default +- name: WithDefault + type: LIST[INT] + default: [1, 2, 3] +# Empty default +- name: EmptyDefault + type: LIST[INT] + default: [] +# Single element +- name: SingleElement + type: LIST[INT] + default: [42] +# Negative values +- name: Negatives + type: LIST[INT] + default: [-10, -1, 0, 1, 10] +# Long list +- name: LongList + type: LIST[INT] + default: [0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19] +# Length constraints +- name: WithMinMax + type: LIST[INT] + default: [1, 2] + minLength: 1 + maxLength: 50 +# Item constraints - allowedValues +- name: ItemAllowed + type: LIST[INT] + default: [1, 3] + item: + allowedValues: [1, 2, 3, 4, 5] +# Item constraints - value range +- name: ItemRange + type: LIST[INT] + default: [5, 10] + item: + minValue: 0 + maxValue: 100 +# Both constraints +- name: FullyConstrained + type: LIST[INT] + default: [10, 20] + minLength: 1 + maxLength: 10 + item: + minValue: 0 + maxValue: 100 +# Description +- name: WithDescription + type: LIST[INT] + default: [1] + description: A list of integers +# UI controls +- name: WithSpinBoxList + type: LIST[INT] + default: [1] + userInterface: + control: SPIN_BOX_LIST + label: Values + groupLabel: Numbers + singleStepDelta: 5 +- name: WithHidden + type: LIST[INT] + default: [1] + userInterface: + control: HIDDEN +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.13--list-int-scalar-not-list.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.13--list-int-scalar-not-list.invalid.yaml new file mode 100644 index 0000000..b68133e --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.13--list-int-scalar-not-list.invalid.yaml @@ -0,0 +1,17 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Values + type: LIST[INT] + default: 42 +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.13--list-int-too-long.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.13--list-int-too-long.invalid.yaml new file mode 100644 index 0000000..b6809f7 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.13--list-int-too-long.invalid.yaml @@ -0,0 +1,18 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Values + type: LIST[INT] + default: [1, 2, 3] + maxLength: 2 +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.13--list-int-too-short.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.13--list-int-too-short.invalid.yaml new file mode 100644 index 0000000..319875a --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.13--list-int-too-short.invalid.yaml @@ -0,0 +1,18 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Values + type: LIST[INT] + default: [] + minLength: 1 +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.13--list-int-wrong-item-type.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.13--list-int-wrong-item-type.invalid.yaml new file mode 100644 index 0000000..e312c82 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.13--list-int-wrong-item-type.invalid.yaml @@ -0,0 +1,17 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Values + type: LIST[INT] + default: ["not", "ints"] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.14--list-float-item-below-min.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.14--list-float-item-below-min.invalid.yaml new file mode 100644 index 0000000..4ad1020 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.14--list-float-item-below-min.invalid.yaml @@ -0,0 +1,19 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Weights + type: LIST[FLOAT] + default: [-1.0] + item: + minValue: 0.0 +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.14--list-float-param.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.14--list-float-param.yaml new file mode 100644 index 0000000..984ffe7 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.14--list-float-param.yaml @@ -0,0 +1,88 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +# Minimal +- name: Minimal + type: LIST[FLOAT] +# With default +- name: WithDefault + type: LIST[FLOAT] + default: [0.5, 1.0, 1.5] +# Empty default +- name: EmptyDefault + type: LIST[FLOAT] + default: [] +# Single element +- name: SingleElement + type: LIST[FLOAT] + default: [3.14] +# Integer values (coerced to float) +- name: IntValues + type: LIST[FLOAT] + default: [1, 2, 3] +# Negative values +- name: Negatives + type: LIST[FLOAT] + default: [-1.5, 0.0, 1.5] +# High precision +- name: HighPrecision + type: LIST[FLOAT] + default: [0.123456789, 9.87654321] +# Length constraints +- name: WithMinMax + type: LIST[FLOAT] + default: [1.0, 2.0] + minLength: 1 + maxLength: 50 +# Item constraints - allowedValues +- name: ItemAllowed + type: LIST[FLOAT] + default: [0.25, 0.75] + item: + allowedValues: [0.25, 0.5, 0.75, 1.0] +# Item constraints - value range +- name: ItemRange + type: LIST[FLOAT] + default: [0.5, 5.0] + item: + minValue: 0.0 + maxValue: 10.0 +# Both constraints +- name: FullyConstrained + type: LIST[FLOAT] + default: [1.0, 2.0] + minLength: 1 + maxLength: 10 + item: + minValue: 0.0 + maxValue: 100.0 +# Description +- name: WithDescription + type: LIST[FLOAT] + default: [1.0] + description: A list of floats +# UI controls +- name: WithSpinBoxList + type: LIST[FLOAT] + default: [1.0] + userInterface: + control: SPIN_BOX_LIST + label: Weights + decimals: 2 + singleStepDelta: 0.1 +- name: WithHidden + type: LIST[FLOAT] + default: [1.0] + userInterface: + control: HIDDEN +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.14--list-float-scalar-not-list.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.14--list-float-scalar-not-list.invalid.yaml new file mode 100644 index 0000000..ed8bad1 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.14--list-float-scalar-not-list.invalid.yaml @@ -0,0 +1,17 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Weights + type: LIST[FLOAT] + default: 3.14 +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.14--list-float-too-long.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.14--list-float-too-long.invalid.yaml new file mode 100644 index 0000000..04df0d6 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.14--list-float-too-long.invalid.yaml @@ -0,0 +1,18 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Weights + type: LIST[FLOAT] + default: [1.0, 2.0, 3.0] + maxLength: 2 +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.14--list-float-too-short.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.14--list-float-too-short.invalid.yaml new file mode 100644 index 0000000..ccde97c --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.14--list-float-too-short.invalid.yaml @@ -0,0 +1,18 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Weights + type: LIST[FLOAT] + default: [] + minLength: 1 +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.14--list-float-wrong-item-type.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.14--list-float-wrong-item-type.invalid.yaml new file mode 100644 index 0000000..9f4b9b5 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.14--list-float-wrong-item-type.invalid.yaml @@ -0,0 +1,17 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Weights + type: LIST[FLOAT] + default: ["not", "floats"] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.15--list-bool-param.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.15--list-bool-param.yaml new file mode 100644 index 0000000..da8f625 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.15--list-bool-param.yaml @@ -0,0 +1,86 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +# Minimal +- name: Minimal + type: LIST[BOOL] +# With default +- name: WithDefault + type: LIST[BOOL] + default: [true, false, true] +# Empty default +- name: EmptyDefault + type: LIST[BOOL] + default: [] +# Single element +- name: SingleElement + type: LIST[BOOL] + default: [true] +# Int coercion +- name: IntValues + type: LIST[BOOL] + default: [1, 0, 1, 0] +# Float coercion +- name: FloatValues + type: LIST[BOOL] + default: [1.0, 0.0] +# String coercion +- name: StringTrueFalse + type: LIST[BOOL] + default: ["true", "false"] +- name: StringYesNo + type: LIST[BOOL] + default: ["yes", "no"] +- name: StringOnOff + type: LIST[BOOL] + default: ["on", "off"] +- name: String10 + type: LIST[BOOL] + default: ["1", "0"] +# Mixed case strings +- name: MixedCaseStrings + type: LIST[BOOL] + default: ["TRUE", "False", "YES", "no", "On", "OFF"] +# Mixed types +- name: MixedTypes + type: LIST[BOOL] + default: [true, 1, "yes", 0.0, "off"] +# Length constraints +- name: WithMinMax + type: LIST[BOOL] + default: [true, false] + minLength: 1 + maxLength: 50 +# Long list +- name: LongList + type: LIST[BOOL] + default: [true,false,true,false,true,false,true,false,true,false,true,false,true,false,true,false,true,false,true,false] +# Description +- name: WithDescription + type: LIST[BOOL] + default: [true] + description: A list of booleans +# UI controls +- name: WithCheckBoxList + type: LIST[BOOL] + default: [true] + userInterface: + control: CHECK_BOX_LIST + label: Flags + groupLabel: Options +- name: WithHidden + type: LIST[BOOL] + default: [true] + userInterface: + control: HIDDEN +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.15--list-bool-scalar-not-list.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.15--list-bool-scalar-not-list.invalid.yaml new file mode 100644 index 0000000..4d3cb98 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.15--list-bool-scalar-not-list.invalid.yaml @@ -0,0 +1,17 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Flags + type: LIST[BOOL] + default: true +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.15--list-bool-too-long.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.15--list-bool-too-long.invalid.yaml new file mode 100644 index 0000000..ca5831b --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.15--list-bool-too-long.invalid.yaml @@ -0,0 +1,18 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Flags + type: LIST[BOOL] + default: [true, false, true] + maxLength: 2 +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.15--list-bool-too-short.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.15--list-bool-too-short.invalid.yaml new file mode 100644 index 0000000..59fdfbc --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.15--list-bool-too-short.invalid.yaml @@ -0,0 +1,18 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Flags + type: LIST[BOOL] + default: [] + minLength: 1 +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.15--list-bool-wrong-item-type.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.15--list-bool-wrong-item-type.invalid.yaml new file mode 100644 index 0000000..963dc6c --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.15--list-bool-wrong-item-type.invalid.yaml @@ -0,0 +1,17 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Flags + type: LIST[BOOL] + default: ["maybe", "perhaps"] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-inner-item-above-max.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-inner-item-above-max.invalid.yaml new file mode 100644 index 0000000..bf79469 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-inner-item-above-max.invalid.yaml @@ -0,0 +1,20 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: AdjList + type: LIST[LIST[INT]] + default: [[999]] + item: + item: + maxValue: 100 +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-inner-item-below-min.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-inner-item-below-min.invalid.yaml new file mode 100644 index 0000000..3bbdb76 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-inner-item-below-min.invalid.yaml @@ -0,0 +1,20 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: AdjList + type: LIST[LIST[INT]] + default: [[-5]] + item: + item: + minValue: 0 +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-inner-item-not-in-allowed.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-inner-item-not-in-allowed.invalid.yaml new file mode 100644 index 0000000..0175b35 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-inner-item-not-in-allowed.invalid.yaml @@ -0,0 +1,20 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: AdjList + type: LIST[LIST[INT]] + default: [[99]] + item: + item: + allowedValues: [1, 2, 3] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-inner-too-long.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-inner-too-long.invalid.yaml new file mode 100644 index 0000000..bd53351 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-inner-too-long.invalid.yaml @@ -0,0 +1,19 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: AdjList + type: LIST[LIST[INT]] + default: [[1, 2, 3, 4, 5]] + item: + maxLength: 3 +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-inner-too-short.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-inner-too-short.invalid.yaml new file mode 100644 index 0000000..e167d84 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-inner-too-short.invalid.yaml @@ -0,0 +1,19 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: AdjList + type: LIST[LIST[INT]] + default: [[]] + item: + minLength: 1 +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-param.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-param.yaml new file mode 100644 index 0000000..fe3df9e --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-param.yaml @@ -0,0 +1,100 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +# Minimal +- name: Minimal + type: LIST[LIST[INT]] +# With default +- name: WithDefault + type: LIST[LIST[INT]] + default: [[1, 2], [3], [0, 1]] +# Empty outer list +- name: EmptyOuter + type: LIST[LIST[INT]] + default: [] +# Empty inner lists +- name: EmptyInner + type: LIST[LIST[INT]] + default: [[], [], []] +# Single inner list +- name: SingleInner + type: LIST[LIST[INT]] + default: [[1, 2, 3]] +# Single element inner lists +- name: SingleElements + type: LIST[LIST[INT]] + default: [[1], [2], [3]] +# Negative values +- name: Negatives + type: LIST[LIST[INT]] + default: [[-10, -1], [0], [1, 10]] +# Long outer list +- name: LongOuter + type: LIST[LIST[INT]] + default: [[1],[2],[3],[4],[5],[6],[7],[8],[9],[10],[11],[12],[13],[14],[15]] +# Long inner list +- name: LongInner + type: LIST[LIST[INT]] + default: [[0,1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19]] +# Outer length constraints +- name: OuterMinMax + type: LIST[LIST[INT]] + default: [[1], [2]] + minLength: 1 + maxLength: 50 +# Inner length constraints +- name: InnerMinMax + type: LIST[LIST[INT]] + default: [[1, 2, 3]] + item: + minLength: 1 + maxLength: 20 +# Inner item value constraints +- name: InnerItemRange + type: LIST[LIST[INT]] + default: [[5, 10]] + item: + item: + minValue: 0 + maxValue: 100 +# Inner item allowedValues +- name: InnerItemAllowed + type: LIST[LIST[INT]] + default: [[1, 3]] + item: + item: + allowedValues: [1, 2, 3, 4, 5] +# All constraints +- name: FullyConstrained + type: LIST[LIST[INT]] + default: [[1, 2], [3, 4]] + minLength: 1 + maxLength: 10 + item: + minLength: 1 + maxLength: 5 + item: + minValue: 0 + maxValue: 100 +# Description +- name: WithDescription + type: LIST[LIST[INT]] + default: [[1]] + description: Adjacency list for task dependencies +# UI control (only HIDDEN supported) +- name: WithHidden + type: LIST[LIST[INT]] + default: [[1]] + userInterface: + control: HIDDEN +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-ragged-scalar-in-outer.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-ragged-scalar-in-outer.invalid.yaml new file mode 100644 index 0000000..c40ec98 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-ragged-scalar-in-outer.invalid.yaml @@ -0,0 +1,17 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: AdjList + type: LIST[LIST[INT]] + default: [[1, 2, 3], 3] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-scalar-not-list.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-scalar-not-list.invalid.yaml new file mode 100644 index 0000000..119d606 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-scalar-not-list.invalid.yaml @@ -0,0 +1,17 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: AdjList + type: LIST[LIST[INT]] + default: 42 +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-string-in-inner.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-string-in-inner.invalid.yaml new file mode 100644 index 0000000..b3d2787 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-string-in-inner.invalid.yaml @@ -0,0 +1,17 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: AdjList + type: LIST[LIST[INT]] + default: [[1, "a"]] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-too-long.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-too-long.invalid.yaml new file mode 100644 index 0000000..b342053 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-too-long.invalid.yaml @@ -0,0 +1,18 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: AdjList + type: LIST[LIST[INT]] + default: [[1], [2], [3]] + maxLength: 2 +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-too-short.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-too-short.invalid.yaml new file mode 100644 index 0000000..62c8bd3 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.16--list-list-int-too-short.invalid.yaml @@ -0,0 +1,18 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: AdjList + type: LIST[LIST[INT]] + default: [] + minLength: 1 +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.9--bool-param-float-invalid.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.9--bool-param-float-invalid.invalid.yaml new file mode 100644 index 0000000..fa0c3b8 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.9--bool-param-float-invalid.invalid.yaml @@ -0,0 +1,17 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Flag + type: BOOL + default: 0.5 +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.9--bool-param-int-invalid.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.9--bool-param-int-invalid.invalid.yaml new file mode 100644 index 0000000..5df3960 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.9--bool-param-int-invalid.invalid.yaml @@ -0,0 +1,17 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Flag + type: BOOL + default: 2 +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.9--bool-param-string-invalid.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.9--bool-param-string-invalid.invalid.yaml new file mode 100644 index 0000000..1d0d94b --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.9--bool-param-string-invalid.invalid.yaml @@ -0,0 +1,17 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Flag + type: BOOL + default: "maybe" +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/2.9--bool-param.yaml b/conformance-tests/2023-09/EXPR/job_templates/2.9--bool-param.yaml new file mode 100644 index 0000000..7e4b3f2 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/2.9--bool-param.yaml @@ -0,0 +1,111 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +# Bool literals +- name: BoolTrue + type: BOOL + default: true +- name: BoolFalse + type: BOOL + default: false +# Int values +- name: Int1 + type: BOOL + default: 1 +- name: Int0 + type: BOOL + default: 0 +# Float values +- name: Float1 + type: BOOL + default: 1.0 +- name: Float0 + type: BOOL + default: 0.0 +# String true/false +- name: StrTrue + type: BOOL + default: "true" +- name: StrFalse + type: BOOL + default: "false" +- name: StrTRUE + type: BOOL + default: "TRUE" +- name: StrFALSE + type: BOOL + default: "FALSE" +- name: StrTrueMixed + type: BOOL + default: "True" +- name: StrFalseMixed + type: BOOL + default: "False" +# String yes/no +- name: StrYes + type: BOOL + default: "yes" +- name: StrNo + type: BOOL + default: "no" +- name: StrYES + type: BOOL + default: "YES" +- name: StrNO + type: BOOL + default: "NO" +- name: StrYesMixed + type: BOOL + default: "Yes" +- name: StrNoMixed + type: BOOL + default: "No" +# String on/off +- name: StrOn + type: BOOL + default: "on" +- name: StrOff + type: BOOL + default: "off" +- name: StrON + type: BOOL + default: "ON" +- name: StrOFF + type: BOOL + default: "OFF" +- name: StrOnMixed + type: BOOL + default: "On" +- name: StrOffMixed + type: BOOL + default: "Off" +# String 1/0 +- name: Str1 + type: BOOL + default: "1" +- name: Str0 + type: BOOL + default: "0" +# UI control +- name: WithCheckbox + type: BOOL + default: true + userInterface: + control: CHECK_BOX + label: Enable Feature +- name: WithHidden + type: BOOL + default: false + userInterface: + control: HIDDEN +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/3.6--let-bindings.yaml b/conformance-tests/2023-09/EXPR/job_templates/3.6--let-bindings.yaml new file mode 100644 index 0000000..b842da0 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/3.6--let-bindings.yaml @@ -0,0 +1,81 @@ +# Tests for let bindings at all scoping levels, with chained bindings +# and complex expressions. Covers Template Schemas §3.6. +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +- FEATURE_BUNDLE_1 +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +- name: Name + type: STRING + default: test +- name: Items + type: LIST[INT] + default: [1, 2, 3, 4, 5] + +# --- §3.6.2: StepTemplate.let --- +# Step-level bindings available to parameterSpace, hostRequirements, script +jobEnvironments: +- name: SetupEnv + script: + # --- §3.6.2: EnvironmentScript.let --- + # Env-level bindings available to actions and embeddedFiles + let: + - greeting = Param.Name + - config_line = 'name=' + Param.Name + actions: + onEnter: + command: python + args: + - "-c" + - "print(r'{{greeting}}')" + embeddedFiles: + - name: config + type: TEXT + data: "{{config_line}}" +steps: +- name: StepWithLetAtAllLevels + # --- Step-level let: chained bindings referencing params --- + let: + - a = Param.X + - b = a + 1 + - c = a + b + script: + # --- §3.6.2: StepScript.let --- + # Script-level bindings can reference step-level bindings and Task.Param + let: + - result = c * 2 + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{result}}')" + embeddedFiles: + - name: summary + type: TEXT + data: "step={{c}} script={{result}}" + +- name: StepWithComplexExpressions + # --- §3.6.1: Complex expressions in let bindings --- + let: + - total = sum(Param.Items) + - count = len(Param.Items) + - avg_str = string(total) + '/' + string(count) + script: + actions: + onRun: + command: python + args: + - "-c" + - "print({{repr_py(avg_str)}})" + +- name: StepWithSimpleAction + # --- §3.6.2: SimpleAction.let (requires FEATURE_BUNDLE_1) --- + bash: + let: + - val = Param.X + 1 + script: "echo {{val}}" diff --git a/conformance-tests/2023-09/EXPR/job_templates/3.6--let-comprehension-shadows-env.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/3.6--let-comprehension-shadows-env.invalid.yaml new file mode 100644 index 0000000..444579c --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/3.6--let-comprehension-shadows-env.invalid.yaml @@ -0,0 +1,28 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Items + type: LIST[INT] + default: [1, 2, 3] +jobEnvironments: +- name: Env1 + script: + let: + - x = 10 + actions: + onEnter: + command: python + args: + - "-c" + - "print(r'{{ [x for x in Param.Items] }}')" +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/3.6--let-comprehension-shadows-script-let.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/3.6--let-comprehension-shadows-script-let.invalid.yaml new file mode 100644 index 0000000..4b5103c --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/3.6--let-comprehension-shadows-script-let.invalid.yaml @@ -0,0 +1,19 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + script: + let: + - x = 10 + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ [x for x in Param.Items] }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/3.6--let-comprehension-shadows-simple-action.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/3.6--let-comprehension-shadows-simple-action.invalid.yaml new file mode 100644 index 0000000..b8c0269 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/3.6--let-comprehension-shadows-simple-action.invalid.yaml @@ -0,0 +1,15 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +- FEATURE_BUNDLE_1 +name: TestJob +parameterDefinitions: +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + bash: + let: + - x = 10 + script: "echo {{ [x for x in Param.Items] }}" diff --git a/conformance-tests/2023-09/EXPR/job_templates/3.6--let-comprehension-shadows-step-let.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/3.6--let-comprehension-shadows-step-let.invalid.yaml new file mode 100644 index 0000000..4f6cc8f --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/3.6--let-comprehension-shadows-step-let.invalid.yaml @@ -0,0 +1,19 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + let: + - x = 10 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ [x for x in Param.Items] }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/3.6--let-comprehension-shadows.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/3.6--let-comprehension-shadows.invalid.yaml new file mode 100644 index 0000000..4f6cc8f --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/3.6--let-comprehension-shadows.invalid.yaml @@ -0,0 +1,19 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + let: + - x = 10 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ [x for x in Param.Items] }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/3.6--let-duplicate-name.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/3.6--let-duplicate-name.invalid.yaml new file mode 100644 index 0000000..6f7d4d2 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/3.6--let-duplicate-name.invalid.yaml @@ -0,0 +1,16 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +steps: +- name: Step1 + let: + - x = 1 + - x = 2 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/3.6--let-empty-list.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/3.6--let-empty-list.invalid.yaml new file mode 100644 index 0000000..dcefcc8 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/3.6--let-empty-list.invalid.yaml @@ -0,0 +1,14 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +steps: +- name: Step1 + let: [] + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/3.6--let-requires-expr.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/3.6--let-requires-expr.invalid.yaml new file mode 100644 index 0000000..c3baa39 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/3.6--let-requires-expr.invalid.yaml @@ -0,0 +1,13 @@ +specificationVersion: jobtemplate-2023-09 +name: TestJob +steps: +- name: Step1 + let: + - x = 1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/3.6--let-shadow-enclosing.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/3.6--let-shadow-enclosing.invalid.yaml new file mode 100644 index 0000000..f5151df --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/3.6--let-shadow-enclosing.invalid.yaml @@ -0,0 +1,17 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +steps: +- name: Step1 + let: + - x = 1 + script: + let: + - x = 2 + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/3.6--let-too-many.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/3.6--let-too-many.invalid.yaml new file mode 100644 index 0000000..8a1aa66 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/3.6--let-too-many.invalid.yaml @@ -0,0 +1,65 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +steps: +- name: Step1 + let: + - v0 = 0 + - v1 = 1 + - v2 = 2 + - v3 = 3 + - v4 = 4 + - v5 = 5 + - v6 = 6 + - v7 = 7 + - v8 = 8 + - v9 = 9 + - v10 = 10 + - v11 = 11 + - v12 = 12 + - v13 = 13 + - v14 = 14 + - v15 = 15 + - v16 = 16 + - v17 = 17 + - v18 = 18 + - v19 = 19 + - v20 = 20 + - v21 = 21 + - v22 = 22 + - v23 = 23 + - v24 = 24 + - v25 = 25 + - v26 = 26 + - v27 = 27 + - v28 = 28 + - v29 = 29 + - v30 = 30 + - v31 = 31 + - v32 = 32 + - v33 = 33 + - v34 = 34 + - v35 = 35 + - v36 = 36 + - v37 = 37 + - v38 = 38 + - v39 = 39 + - v40 = 40 + - v41 = 41 + - v42 = 42 + - v43 = 43 + - v44 = 44 + - v45 = 45 + - v46 = 46 + - v47 = 47 + - v48 = 48 + - v49 = 49 + - v50 = 50 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/3.6.1--let-missing-equals.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/3.6.1--let-missing-equals.invalid.yaml new file mode 100644 index 0000000..8a09604 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/3.6.1--let-missing-equals.invalid.yaml @@ -0,0 +1,15 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +steps: +- name: Step1 + let: + - "x" + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/3.6.1--let-self-reference.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/3.6.1--let-self-reference.invalid.yaml new file mode 100644 index 0000000..6cb24a2 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/3.6.1--let-self-reference.invalid.yaml @@ -0,0 +1,15 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +steps: +- name: Step1 + let: + - x = x + 1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/3.6.1--let-uppercase-name.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/3.6.1--let-uppercase-name.invalid.yaml new file mode 100644 index 0000000..71fb0d9 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/3.6.1--let-uppercase-name.invalid.yaml @@ -0,0 +1,15 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +steps: +- name: Step1 + let: + - Foo = 1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/7.3--apply-path-mapping-in-job-name.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/7.3--apply-path-mapping-in-job-name.invalid.yaml new file mode 100644 index 0000000..66c6e0a --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/7.3--apply-path-mapping-in-job-name.invalid.yaml @@ -0,0 +1,14 @@ +# apply_path_mapping() is only available in host context, not in job name (submission-time) +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: "{{ apply_path_mapping('/mnt/share') }}" +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/7.3--session-in-host-requirements.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/7.3--session-in-host-requirements.invalid.yaml new file mode 100644 index 0000000..ad63c14 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/7.3--session-in-host-requirements.invalid.yaml @@ -0,0 +1,19 @@ +# Session.WorkingDirectory is not available in submission-time context (host requirements) +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +steps: +- name: Step1 + hostRequirements: + attributes: + - name: attr.worker.os.family + anyOf: + - "{{ Session.WorkingDirectory }}" + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/7.3--task-param-in-job-name.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/7.3--task-param-in-job-name.invalid.yaml new file mode 100644 index 0000000..946e3f5 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/7.3--task-param-in-job-name.invalid.yaml @@ -0,0 +1,19 @@ +# Task.Param is not available in submission-time context (job name) +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: "Job {{ Task.Param.Frame }}" +steps: +- name: Step1 + parameterSpace: + taskParameterDefinitions: + - name: Frame + type: INT + range: "1-5" + script: + actions: + onRun: + command: python + args: + - "-c" + - "print()" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr-extension-enabled.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr-extension-enabled.yaml new file mode 100644 index 0000000..9a91455 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr-extension-enabled.yaml @@ -0,0 +1,17 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'RESULT:{{ Param.X + 1 }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr-extension-missing.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr-extension-missing.invalid.yaml new file mode 100644 index 0000000..d081d0c --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr-extension-missing.invalid.yaml @@ -0,0 +1,15 @@ +specificationVersion: jobtemplate-2023-09 +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ Param.X + 1 }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr-with-other-extensions.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr-with-other-extensions.yaml new file mode 100644 index 0000000..aaae7bf --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr-with-other-extensions.yaml @@ -0,0 +1,26 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +- TASK_CHUNKING +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +steps: +- name: Step1 + parameterSpace: + taskParameterDefinitions: + - name: Frame + type: CHUNK[INT] + range: "1-10" + chunks: + defaultTaskCount: 5 + rangeConstraint: CONTIGUOUS + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ Param.X + len(Task.Param.Frame) }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--arithmetic-expr.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--arithmetic-expr.yaml new file mode 100644 index 0000000..0f019d8 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--arithmetic-expr.yaml @@ -0,0 +1,44 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 10 +- name: Y + type: FLOAT + default: 2.5 +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - | + # Addition + print(r'{{ Param.X + 3 }}') + # Subtraction + print(r'{{ Param.X - 3 }}') + # Multiplication + print(r'{{ Param.X * 3 }}') + # True division (returns float) + print(r'{{ Param.X / 3 }}') + # Floor division + print(r'{{ Param.X // 3 }}') + # Modulo + print(r'{{ Param.X % 3 }}') + # Exponentiation + print(r'{{ 2 ** 3 }}') + # Unary negation + print(r'{{ -Param.X }}') + # Unary positive + print(r'{{ +Param.X }}') + # Int/float promotion + print(r'{{ Param.X + Param.Y }}') + # Float arithmetic + print(r'{{ Param.Y * 2 }}') + # Parenthesized + print(r'{{ (Param.X + 1) * 2 }}') diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--comparison-expr.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--comparison-expr.yaml new file mode 100644 index 0000000..e697132 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--comparison-expr.yaml @@ -0,0 +1,56 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +- name: Name + type: STRING + default: hello +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - | + # Equal + print(r'{{ Param.X == 5 }}') + # Not equal + print(r'{{ Param.X != 3 }}') + # Less than + print(r'{{ Param.X < 10 }}') + # Less than or equal + print(r'{{ Param.X <= 5 }}') + # Greater than + print(r'{{ Param.X > 0 }}') + # Greater than or equal + print(r'{{ Param.X >= 5 }}') + # Chained comparisons + print(r'{{ 1 < Param.X < 10 }}') + print(r'{{ 0 < Param.X <= 5 }}') + print(r'{{ 1 <= Param.X <= 5 }}') + print(r'{{ 10 > Param.X > 0 }}') + print(r'{{ 5 >= Param.X >= 1 }}') + print(r'{{ 5 >= Param.X > 0 }}') + print(r'{{ 1 < 2 < 3 < 4 < 5 }}') + print(r'{{ 5 > 4 > 3 > 2 > 1 }}') + # String comparison + print(r'{{ Param.Name == "hello" }}') + print(r'{{ "a" < "b" }}') + # Logical and + print(r'{{ Param.X > 0 and Param.X < 10 }}') + # Logical or + print(r'{{ Param.X < 0 or Param.X > 0 }}') + # Logical not + print(r'{{ not (Param.X == 0) }}') + # in / not in (list) + print(r'{{ Param.X in [1, 3, 5, 7] }}') + print(r'{{ Param.X not in [2, 4, 6] }}') + # in / not in (string) + print(r'{{ "ell" in Param.Name }}') + print(r'{{ "xyz" not in Param.Name }}') diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--conditional-expr.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--conditional-expr.yaml new file mode 100644 index 0000000..93c501d --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--conditional-expr.yaml @@ -0,0 +1,40 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Flag + type: BOOL + default: true +- name: X + type: INT + default: 5 +- name: Quality + type: STRING + default: final +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - | + # Basic true/false branches + print(r'{{ "a" if Param.Flag else "b" }}') + # Comparison as condition + print(r'{{ "positive" if Param.X > 0 else "non-positive" }}') + # Nested conditional + print(r'{{ "high" if Param.X > 10 else "mid" if Param.X > 3 else "low" }}') + # Int result + print(r'{{ 16 if Param.Quality == "final" else 4 }}') + # Conditional with not + print(r'{{ "off" if not Param.Flag else "on" }}') + # Conditional with and/or + print(r'{{ "yes" if Param.Flag and Param.X > 0 else "no" }}') + print(r'{{ "yes" if Param.Flag or Param.X < 0 else "no" }}') + # Conditional producing null (omits arg) + - "{{ '--verbose' if Param.Flag else null }}" + # Conditional producing list (flattened into args) + - "{{ ['--quality', Param.Quality] if Param.Flag else null }}" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--function-call-expr.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--function-call-expr.yaml new file mode 100644 index 0000000..7d6cd87 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--function-call-expr.yaml @@ -0,0 +1,41 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Name + type: STRING + default: hello +- name: X + type: INT + default: 5 +- name: Y + type: FLOAT + default: 3.7 +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - | + # Single-arg functions + print(r'{{ len(Param.Name) }}') + print(r'{{ abs(-Param.X) }}') + print(r'{{ floor(Param.Y) }}') + print(r'{{ ceil(Param.Y) }}') + print(r'{{ string(Param.X) }}') + # Multi-arg functions + print(r'{{ min(Param.X, 10) }}') + print(r'{{ max(Param.X, 1) }}') + print(r'{{ round(Param.Y, 1) }}') + print(r'{{ zfill(Param.X, 4) }}') + # Nested function calls + print(r'{{ string(min(Param.X, 10)) }}') + print(r'{{ len(split(Param.Name, "l")) }}') + # Type conversion functions + print(r'{{ int(Param.Y) }}') + print(r'{{ float(Param.X) }}') + print(r'{{ bool(Param.X) }}') diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--list-comprehension-expr.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--list-comprehension-expr.yaml new file mode 100644 index 0000000..bd3f39b --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--list-comprehension-expr.yaml @@ -0,0 +1,39 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Items + type: LIST[STRING] + default: ["a", "b", "c"] +- name: Numbers + type: LIST[INT] + default: [1, 2, 3, 4, 5] +- name: Frames + type: RANGE_EXPR + default: "1-5" +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - | + # Identity + print(r'{{ [x for x in Param.Items] }}') + # Transform + print(r'{{ [x * 2 for x in Param.Numbers] }}') + # String transform + print(r'{{ [s.upper() for s in Param.Items] }}') + # With filter + print(r'{{ [x for x in Param.Numbers if x > 2] }}') + # Over range_expr + print(r'{{ [x for x in Param.Frames] }}') + # Over range() function + print(r'{{ [x * x for x in range(5)] }}') + # Nested list result + print(r'{{ [["-e", s] for s in Param.Items] }}') + # Underscore variable name + print(r'{{ [_i + 1 for _i in Param.Numbers] }}') diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--list-literal-expr.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--list-literal-expr.yaml new file mode 100644 index 0000000..e2fdd89 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--list-literal-expr.yaml @@ -0,0 +1,41 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - | + # Int list + print(r'{{ [1, 2, 3] }}') + # String list + print(r'{{ ["a", "b", "c"] }}') + # Float list + print(r'{{ [1.0, 2.5, 3.0] }}') + # Bool list + print(r'{{ [true, false, true] }}') + # Empty list + print(r'{{ [] }}') + # Single element + print(r'{{ [42] }}') + # Trailing comma + print(r'{{ [1, 2, 3,] }}') + # Nested list + print(r'{{ [[1, 2], [3, 4]] }}') + # Expressions in list + print(r'{{ [Param.X, Param.X + 1, Param.X * 2] }}') + # Mixed int/float (promotes to float) + print(r'{{ [1, 2.0, 3] }}') + # List concatenation + print(r'{{ [1, 2] + [3, 4] }}') + # List repetition + print(r'{{ [0] * 3 }}') diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--method-call-expr.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--method-call-expr.yaml new file mode 100644 index 0000000..bc8e452 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--method-call-expr.yaml @@ -0,0 +1,43 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Name + type: STRING + default: "hello world" +- name: File + type: PATH + default: /renders/shot_001.exr +- name: Csv + type: STRING + default: "a,b,c" +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - | + # String methods + print(r'{{ Param.Name.upper() }}') + print(r'{{ Param.Name.lower() }}') + print(r'{{ Param.Name.capitalize() }}') + print(r'{{ Param.Name.strip() }}') + print(r'{{ Param.Name.startswith("hello") }}') + print(r'{{ Param.Name.replace("world", "there") }}') + # Chained methods + print(r'{{ Param.Csv.split(",").join(";") }}') + # Path properties (UFCS) + print(r'{{ Param.File.name }}') + print(r'{{ Param.File.stem }}') + print(r'{{ Param.File.suffix }}') + print(r'{{ Param.File.parent }}') + # Path methods + print(r'{{ Param.File.with_suffix(".png") }}') + print(r'{{ Param.File.with_name("output.exr") }}') + # Method with args + print(r'{{ Param.Name.count("l") }}') + print(r'{{ Param.Name.endswith("world") }}') diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--multiline-expr.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--multiline-expr.yaml new file mode 100644 index 0000000..7bc1b2f --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--multiline-expr.yaml @@ -0,0 +1,50 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +- name: Items + type: LIST[INT] + default: [1, 2, 3, 4, 5] +- name: Quality + type: STRING + default: final +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + # Multi-line arithmetic + - |- + print(r'{{ Param.X + + 1 }}') + # Multi-line conditional + - |- + print(r'{{ "high" + if Param.Quality == "final" + else "low" }}') + # Multi-line list literal + - |- + print(r'{{ [ + Param.X, + Param.X + 1, + Param.X + 2 + ] }}') + # Multi-line list comprehension + - |- + print(r'{{ [ + x * 2 + for x in Param.Items + if x > 2 + ] }}') + # Multi-line function call + - |- + print(r'{{ min( + Param.X, + 10 + ) }}') diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-await.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-await.invalid.yaml new file mode 100644 index 0000000..f90754e --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-await.invalid.yaml @@ -0,0 +1,20 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ await Param.X }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-bitwise-and.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-bitwise-and.invalid.yaml new file mode 100644 index 0000000..4519cd7 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-bitwise-and.invalid.yaml @@ -0,0 +1,20 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ 5 & 3 }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-bitwise-not.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-bitwise-not.invalid.yaml new file mode 100644 index 0000000..d80109b --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-bitwise-not.invalid.yaml @@ -0,0 +1,20 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ ~5 }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-bitwise-or.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-bitwise-or.invalid.yaml new file mode 100644 index 0000000..77d4fa6 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-bitwise-or.invalid.yaml @@ -0,0 +1,20 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ 5 | 3 }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-bitwise-xor.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-bitwise-xor.invalid.yaml new file mode 100644 index 0000000..1360c56 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-bitwise-xor.invalid.yaml @@ -0,0 +1,20 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ 5 ^ 3 }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-bstring.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-bstring.invalid.yaml new file mode 100644 index 0000000..a760978 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-bstring.invalid.yaml @@ -0,0 +1,20 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ b'hello' }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-complex-literal.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-complex-literal.invalid.yaml new file mode 100644 index 0000000..5cf904e --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-complex-literal.invalid.yaml @@ -0,0 +1,20 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ 1+2j }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-dict-comp.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-dict-comp.invalid.yaml new file mode 100644 index 0000000..7d29295 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-dict-comp.invalid.yaml @@ -0,0 +1,20 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ {k: k for k in [1,2]} }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-dict-literal.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-dict-literal.invalid.yaml new file mode 100644 index 0000000..f51a324 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-dict-literal.invalid.yaml @@ -0,0 +1,20 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ {'a': 1} }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-double-star-arg.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-double-star-arg.invalid.yaml new file mode 100644 index 0000000..510dcb5 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-double-star-arg.invalid.yaml @@ -0,0 +1,20 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ len(**Items) }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-ellipsis.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-ellipsis.invalid.yaml new file mode 100644 index 0000000..e710d53 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-ellipsis.invalid.yaml @@ -0,0 +1,20 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ ... }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-fstring.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-fstring.invalid.yaml new file mode 100644 index 0000000..cf3d8b1 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-fstring.invalid.yaml @@ -0,0 +1,20 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ f'hello' }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-generator-expr.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-generator-expr.invalid.yaml new file mode 100644 index 0000000..735df82 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-generator-expr.invalid.yaml @@ -0,0 +1,20 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ (x for x in [1,2]) }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-is-not-operator.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-is-not-operator.invalid.yaml new file mode 100644 index 0000000..909ad9a --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-is-not-operator.invalid.yaml @@ -0,0 +1,20 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ Param.X is not None }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-is-operator.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-is-operator.invalid.yaml new file mode 100644 index 0000000..21dfd70 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-is-operator.invalid.yaml @@ -0,0 +1,20 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ Param.X is None }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-keyword-arg.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-keyword-arg.invalid.yaml new file mode 100644 index 0000000..709b898 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-keyword-arg.invalid.yaml @@ -0,0 +1,20 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ len(x=1) }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-lambda.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-lambda.invalid.yaml new file mode 100644 index 0000000..f577f99 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-lambda.invalid.yaml @@ -0,0 +1,20 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ lambda x: x + 1 }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-left-shift.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-left-shift.invalid.yaml new file mode 100644 index 0000000..1359769 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-left-shift.invalid.yaml @@ -0,0 +1,20 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ 5 << 1 }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-matmul.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-matmul.invalid.yaml new file mode 100644 index 0000000..605bbbe --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-matmul.invalid.yaml @@ -0,0 +1,20 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ Param.X @ Param.X }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-multi-generator.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-multi-generator.invalid.yaml new file mode 100644 index 0000000..94925f7 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-multi-generator.invalid.yaml @@ -0,0 +1,20 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ [x + y for x in [1,2] for y in [3,4]] }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-multi-if-comp.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-multi-if-comp.invalid.yaml new file mode 100644 index 0000000..f724a83 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-multi-if-comp.invalid.yaml @@ -0,0 +1,20 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ [x for x in [1,2,3,4,5] if x > 1 if x < 5] }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-right-shift.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-right-shift.invalid.yaml new file mode 100644 index 0000000..c8274df --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-right-shift.invalid.yaml @@ -0,0 +1,20 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ 5 >> 1 }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-set-comp.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-set-comp.invalid.yaml new file mode 100644 index 0000000..e9c1187 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-set-comp.invalid.yaml @@ -0,0 +1,20 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ {x for x in [1,2]} }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-set-literal.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-set-literal.invalid.yaml new file mode 100644 index 0000000..d51c072 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-set-literal.invalid.yaml @@ -0,0 +1,20 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ {1, 2, 3} }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-star-arg.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-star-arg.invalid.yaml new file mode 100644 index 0000000..8e25ed5 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-star-arg.invalid.yaml @@ -0,0 +1,20 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ len(*Items) }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-star-unpack.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-star-unpack.invalid.yaml new file mode 100644 index 0000000..99e4686 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-star-unpack.invalid.yaml @@ -0,0 +1,20 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ [*[1,2], 3] }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-tuple-unpack-comp.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-tuple-unpack-comp.invalid.yaml new file mode 100644 index 0000000..0213118 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-tuple-unpack-comp.invalid.yaml @@ -0,0 +1,20 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ [a + b for a, b in [[1,2], [3,4]]] }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-tuple.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-tuple.invalid.yaml new file mode 100644 index 0000000..a98b791 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-tuple.invalid.yaml @@ -0,0 +1,20 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ (1, 2, 3) }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-walrus.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-walrus.invalid.yaml new file mode 100644 index 0000000..02a9f78 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--reject-walrus.invalid.yaml @@ -0,0 +1,20 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: X + type: INT + default: 5 +- name: Items + type: LIST[INT] + default: [1, 2, 3] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ (x := 5) }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--slice-expr.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--slice-expr.yaml new file mode 100644 index 0000000..a40fc45 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--slice-expr.yaml @@ -0,0 +1,42 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Name + type: STRING + default: hello +- name: Items + type: LIST[INT] + default: [10, 20, 30, 40, 50] +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - | + # String indexing + print(r'{{ Param.Name[0] }}') + print(r'{{ Param.Name[-1] }}') + # String slicing + print(r'{{ Param.Name[1:3] }}') + print(r'{{ Param.Name[:3] }}') + print(r'{{ Param.Name[2:] }}') + print(r'{{ Param.Name[::-1] }}') + # List indexing + print(r'{{ Param.Items[0] }}') + print(r'{{ Param.Items[-1] }}') + # List slicing + print(r'{{ Param.Items[1:4] }}') + print(r'{{ Param.Items[:3] }}') + print(r'{{ Param.Items[2:] }}') + print(r'{{ Param.Items[::2] }}') + print(r'{{ Param.Items[::-1] }}') + # Slice with step + print(r'{{ Param.Items[0:5:2] }}') + # Negative slice + print(r'{{ Param.Items[-3:] }}') + print(r'{{ Param.Items[:-2] }}') diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--syntax-error.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--syntax-error.invalid.yaml new file mode 100644 index 0000000..4737cd8 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--syntax-error.invalid.yaml @@ -0,0 +1,13 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ 1 + }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--type-error.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--type-error.invalid.yaml new file mode 100644 index 0000000..0c67ed5 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--type-error.invalid.yaml @@ -0,0 +1,17 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Name + type: STRING + default: hello +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ Param.Name + 5 }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--unclosed-bracket.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--unclosed-bracket.invalid.yaml new file mode 100644 index 0000000..70052c9 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--unclosed-bracket.invalid.yaml @@ -0,0 +1,13 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ [1, 2, 3 }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--unclosed-paren.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--unclosed-paren.invalid.yaml new file mode 100644 index 0000000..74d6782 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--unclosed-paren.invalid.yaml @@ -0,0 +1,13 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ (1 + 2 }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1--unknown-variable.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--unknown-variable.invalid.yaml new file mode 100644 index 0000000..07b123c --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1--unknown-variable.invalid.yaml @@ -0,0 +1,13 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ Param.DoesNotExist }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1.3--contextual-keywords.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1.3--contextual-keywords.yaml new file mode 100644 index 0000000..0471b37 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1.3--contextual-keywords.yaml @@ -0,0 +1,68 @@ +# Tests that Python keywords work as parameter attribute names after '.' +# Covers: if, def, else, and, or, not, for, in, True, False, None +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: "if" + type: STRING + default: cond_val +- name: "def" + type: STRING + default: def_val +- name: "else" + type: STRING + default: else_val +- name: "and" + type: STRING + default: and_val +- name: "or" + type: STRING + default: or_val +- name: "not" + type: STRING + default: not_val +- name: "for" + type: STRING + default: for_val +- name: "in" + type: STRING + default: in_val +- name: "True" + type: STRING + default: true_val +- name: "False" + type: STRING + default: false_val +- name: "None" + type: STRING + default: none_val +- name: flag + type: BOOL + default: true +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - | + # Simple keyword attribute access + print(r'{{Param.if}}') + print(r'{{Param.def}}') + print(r'{{Param.else}}') + print(r'{{Param.and}}') + print(r'{{Param.or}}') + print(r'{{Param.not}}') + print(r'{{Param.for}}') + print(r'{{Param.in}}') + print(r'{{Param.True}}') + print(r'{{Param.False}}') + print(r'{{Param.None}}') + # Keyword attrs combined with keyword operators + print(r'{{ Param.if if Param.flag else Param.else }}') + print(r'{{ Param.and if Param.flag and true else Param.or }}') + print(r'{{ Param.not if not false else Param.None }}') diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1.4--json-yaml-aliases.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1.4--json-yaml-aliases.yaml new file mode 100644 index 0000000..1bd3621 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1.4--json-yaml-aliases.yaml @@ -0,0 +1,32 @@ +# Tests that null/true/false are accepted as aliases for None/True/False +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Flag + type: BOOL + default: true +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - | + # JSON/YAML aliases + print(r'{{ true }}') + print(r'{{ false }}') + print(r'{{ null }}') + # Python-style + print(r'{{ True }}') + print(r'{{ False }}') + print(r'{{ None }}') + # Mixed in expressions + print(r'{{ true if Param.Flag else false }}') + print(r'{{ True if Param.Flag else False }}') + # null in conditional (omits arg) + - "{{ '--flag' if true else null }}" + - "{{ '--other' if True else None }}" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.1.6--leading-zeros.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.1.6--leading-zeros.invalid.yaml new file mode 100644 index 0000000..bb98071 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.1.6--leading-zeros.invalid.yaml @@ -0,0 +1,14 @@ +# Leading zeros on decimal integers are a syntax error (prevents C-style octal confusion) +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{ 007 }}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.3.11--float-range-expression.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.3.11--float-range-expression.yaml new file mode 100644 index 0000000..8778e82 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.3.11--float-range-expression.yaml @@ -0,0 +1,22 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Scale + type: FLOAT + default: "2.5" +steps: +- name: Step1 + parameterSpace: + taskParameterDefinitions: + - name: Factor + type: FLOAT + range: "{{ [Param.Scale * 2, Param.Scale + 0.5] }}" + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{Task.Param.Factor}}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.3.11--path-range-expression.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.3.11--path-range-expression.yaml new file mode 100644 index 0000000..003962b --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.3.11--path-range-expression.yaml @@ -0,0 +1,22 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Dir + type: STRING + default: /tmp/renders +steps: +- name: Step1 + parameterSpace: + taskParameterDefinitions: + - name: File + type: PATH + range: "{{ [path(Param.Dir) / 'a.exr', path(Param.Dir) / 'b.exr'] }}" + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{Task.Param.File}}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.3.11--string-range-expression.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.3.11--string-range-expression.yaml new file mode 100644 index 0000000..1bc7263 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.3.11--string-range-expression.yaml @@ -0,0 +1,22 @@ +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +parameterDefinitions: +- name: Prefix + type: STRING + default: scene +steps: +- name: Step1 + parameterSpace: + taskParameterDefinitions: + - name: Name + type: STRING + range: "{{ [Param.Prefix + '_a', Param.Prefix + '_b'] }}" + script: + actions: + onRun: + command: python + args: + - "-c" + - "print(r'{{Task.Param.Name}}')" diff --git a/conformance-tests/2023-09/EXPR/job_templates/expr1.3.7--loop-var-uppercase.invalid.yaml b/conformance-tests/2023-09/EXPR/job_templates/expr1.3.7--loop-var-uppercase.invalid.yaml new file mode 100644 index 0000000..949651b --- /dev/null +++ b/conformance-tests/2023-09/EXPR/job_templates/expr1.3.7--loop-var-uppercase.invalid.yaml @@ -0,0 +1,15 @@ +# Uppercase loop variable should be rejected (could shadow spec-defined symbols) +specificationVersion: jobtemplate-2023-09 +extensions: +- EXPR +name: TestJob +steps: +- name: Step1 + script: + actions: + onRun: + command: python + args: + - "-c" + - >- + print(r'{{ [X for X in [1, 2]] }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/2.10--range-expr-param-runtime.test.yaml b/conformance-tests/2023-09/EXPR/jobs/2.10--range-expr-param-runtime.test.yaml new file mode 100644 index 0000000..3881ea8 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/2.10--range-expr-param-runtime.test.yaml @@ -0,0 +1,33 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + parameterDefinitions: + - name: Frames + type: RANGE_EXPR + default: "1-5:2,10,20-18:-1" + steps: + - name: Step1 + parameterSpace: + taskParameterDefinitions: + - name: Frame + type: INT + range: "{{Param.Frames}}" + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'FRAME:{{ Task.Param.Frame }}') +expected: + output: + - FRAME:1 + - FRAME:3 + - FRAME:5 + - FRAME:10 + - FRAME:18 + - FRAME:19 + - FRAME:20 diff --git a/conformance-tests/2023-09/EXPR/jobs/2.11--list-string-param-runtime.test.yaml b/conformance-tests/2023-09/EXPR/jobs/2.11--list-string-param-runtime.test.yaml new file mode 100644 index 0000000..8659318 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/2.11--list-string-param-runtime.test.yaml @@ -0,0 +1,37 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + parameterDefinitions: + - name: Items + type: LIST[STRING] + default: ["alpha", "beta", "gamma"] + steps: + - name: Step1 + parameterSpace: + taskParameterDefinitions: + - name: Item + type: STRING + range: "{{Param.Items}}" + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'LEN:{{ len(Param.Items) }}') + print(r'FIRST:{{ Param.Items[0] }}') + print(r'UPPER:{{ [x.upper() for x in Param.Items] }}') + print(r'JOIN:{{ Param.Items.join(",") }}') + print(r'TASK:{{ Task.Param.Item }}') +expected: + output: + - LEN:3 + - FIRST:alpha + - 'UPPER:["ALPHA", "BETA", "GAMMA"]' + - "JOIN:alpha,beta,gamma" + - "TASK:alpha" + - "TASK:beta" + - "TASK:gamma" diff --git a/conformance-tests/2023-09/EXPR/jobs/2.12--list-path-param-mapping.test.yaml b/conformance-tests/2023-09/EXPR/jobs/2.12--list-path-param-mapping.test.yaml new file mode 100644 index 0000000..60d38a9 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/2.12--list-path-param-mapping.test.yaml @@ -0,0 +1,44 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + parameterDefinitions: + - name: Paths + type: LIST[PATH] + default: ["/mnt/shared/a.exr", "/mnt/shared/b.exr"] + steps: + - name: Step1 + parameterSpace: + taskParameterDefinitions: + - name: Path + type: PATH + range: "{{RawParam.Paths}}" + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'RAW:{{ RawParam.Paths }}') + print(r'MAPPED:{{ Param.Paths }}') + print(r'TASK_MAPPED:{{ Task.Param.Path }}') + print(r'TASK_RAW:{{ Task.RawParam.Path }}') +pathMapping: + - source_path_format: POSIX + source_path: /mnt/shared + destination_path: /local/cache +expected: + output: + - 'RAW:["/mnt/shared/a.exr", "/mnt/shared/b.exr"]' + - "TASK_RAW:/mnt/shared/a.exr" + - "TASK_RAW:/mnt/shared/b.exr" + output_posix: + - 'MAPPED:["/local/cache/a.exr", "/local/cache/b.exr"]' + - "TASK_MAPPED:/local/cache/a.exr" + - "TASK_MAPPED:/local/cache/b.exr" + output_windows: + - 'MAPPED:["\\local\\cache\\a.exr", "\\local\\cache\\b.exr"]' + - "TASK_MAPPED:\\local\\cache\\a.exr" + - "TASK_MAPPED:\\local\\cache\\b.exr" diff --git a/conformance-tests/2023-09/EXPR/jobs/2.12--list-path-param-runtime.test.yaml b/conformance-tests/2023-09/EXPR/jobs/2.12--list-path-param-runtime.test.yaml new file mode 100644 index 0000000..32cb92e --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/2.12--list-path-param-runtime.test.yaml @@ -0,0 +1,45 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + parameterDefinitions: + - name: Paths + type: LIST[PATH] + default: ["renders/a.exr", "renders/b.exr"] + steps: + - name: Step1 + parameterSpace: + taskParameterDefinitions: + - name: Path + type: PATH + range: "{{RawParam.Paths}}" + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'LEN:{{ len(Param.Paths) }}') + print(r'PATHS:{{ Param.Paths }}') + print(r'RAW:{{ RawParam.Paths }}') + print(r'TASK:{{ Task.Param.Path }}') + print(r'TASK_RAW:{{ Task.RawParam.Path }}') + print(r'TASK_STEM:{{ Task.Param.Path.stem }}') +expected: + output: + - LEN:2 + - 'RAW:["renders/a.exr", "renders/b.exr"]' + - "TASK_RAW:renders/a.exr" + - "TASK_RAW:renders/b.exr" + - "TASK_STEM:a" + - "TASK_STEM:b" + output_posix: + - 'PATHS:["renders/a.exr", "renders/b.exr"]' + - "TASK:renders/a.exr" + - "TASK:renders/b.exr" + output_windows: + - 'PATHS:["renders\\a.exr", "renders\\b.exr"]' + - "TASK:renders\\a.exr" + - "TASK:renders\\b.exr" diff --git a/conformance-tests/2023-09/EXPR/jobs/2.13--list-int-param-runtime.test.yaml b/conformance-tests/2023-09/EXPR/jobs/2.13--list-int-param-runtime.test.yaml new file mode 100644 index 0000000..b29fe00 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/2.13--list-int-param-runtime.test.yaml @@ -0,0 +1,37 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + parameterDefinitions: + - name: Values + type: LIST[INT] + default: [10, 20, 30] + steps: + - name: Step1 + parameterSpace: + taskParameterDefinitions: + - name: Val + type: INT + range: "{{Param.Values}}" + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'LEN:{{ len(Param.Values) }}') + print(r'FIRST:{{ Param.Values[0] }}') + print(r'LAST:{{ Param.Values[-1] }}') + print(r'SUM:{{ sum(Param.Values) }}') + print(r'TASK:{{ Task.Param.Val }}') +expected: + output: + - LEN:3 + - FIRST:10 + - LAST:30 + - SUM:60 + - TASK:10 + - TASK:20 + - TASK:30 diff --git a/conformance-tests/2023-09/EXPR/jobs/2.14--list-float-param-runtime.test.yaml b/conformance-tests/2023-09/EXPR/jobs/2.14--list-float-param-runtime.test.yaml new file mode 100644 index 0000000..b4f0e4e --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/2.14--list-float-param-runtime.test.yaml @@ -0,0 +1,29 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + parameterDefinitions: + - name: Scales + type: LIST[FLOAT] + default: [0.5, 1.0, 2.0] + steps: + - name: Step1 + parameterSpace: + taskParameterDefinitions: + - name: Scale + type: FLOAT + range: "{{Param.Scales}}" + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'TASK:{{ Task.Param.Scale }}') +expected: + output: + - TASK:0.5 + - TASK:1.0 + - TASK:2.0 diff --git a/conformance-tests/2023-09/EXPR/jobs/2.16--list-list-int-param-runtime.test.yaml b/conformance-tests/2023-09/EXPR/jobs/2.16--list-list-int-param-runtime.test.yaml new file mode 100644 index 0000000..629dbed --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/2.16--list-list-int-param-runtime.test.yaml @@ -0,0 +1,28 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + parameterDefinitions: + - name: Matrix + type: LIST[LIST[INT]] + default: [[1, 2], [3, 4], [5, 6]] + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'LEN:{{ len(Param.Matrix) }}') + print(r'ROW0:{{ Param.Matrix[0] }}') + print(r'ELEM:{{ Param.Matrix[1][0] }}') + print(r'FLAT:{{ flatten(Param.Matrix) }}') +expected: + output: + - LEN:3 + - "ROW0:[1, 2]" + - ELEM:3 + - "FLAT:[1, 2, 3, 4, 5, 6]" diff --git a/conformance-tests/2023-09/EXPR/jobs/2.9--bool-param-runtime.test.yaml b/conformance-tests/2023-09/EXPR/jobs/2.9--bool-param-runtime.test.yaml new file mode 100644 index 0000000..ffe9899 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/2.9--bool-param-runtime.test.yaml @@ -0,0 +1,29 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + parameterDefinitions: + - name: UseGpu + type: BOOL + default: true + - name: Debug + type: BOOL + default: false + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'GPU:{{ "yes" if Param.UseGpu else "no" }}') + print(r'DEBUG:{{ "yes" if Param.Debug else "no" }}') + print(r'NOT:{{ not Param.Debug }}') +expected: + output: + - GPU:yes + - DEBUG:no + - NOT:true diff --git a/conformance-tests/2023-09/EXPR/jobs/3.6--let-binding-with-param.test.yaml b/conformance-tests/2023-09/EXPR/jobs/3.6--let-binding-with-param.test.yaml new file mode 100644 index 0000000..19dfbf3 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/3.6--let-binding-with-param.test.yaml @@ -0,0 +1,23 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + parameterDefinitions: + - name: X + type: INT + default: 5 + steps: + - name: Step1 + let: + - "doubled = Param.X * 2" + script: + actions: + onRun: + command: python + args: + - -c + - print(r'OUT:{{ doubled }}') +expected: + output: + - OUT:10 diff --git a/conformance-tests/2023-09/EXPR/jobs/3.6--let-env-script-binding.test.yaml b/conformance-tests/2023-09/EXPR/jobs/3.6--let-env-script-binding.test.yaml new file mode 100644 index 0000000..14ff2c9 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/3.6--let-env-script-binding.test.yaml @@ -0,0 +1,28 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + jobEnvironments: + - name: TestEnv + script: + let: + - "greeting = 'hello'" + actions: + onEnter: + command: python + args: + - -c + - "print('openjd_env: GREETING={{ greeting }}')" + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - import os; print('ENV:' + os.environ.get('GREETING', '')) +expected: + output: + - ENV:hello diff --git a/conformance-tests/2023-09/EXPR/jobs/3.6--let-in-host-requirements.test.yaml b/conformance-tests/2023-09/EXPR/jobs/3.6--let-in-host-requirements.test.yaml new file mode 100644 index 0000000..a12743a --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/3.6--let-in-host-requirements.test.yaml @@ -0,0 +1,29 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + - FEATURE_BUNDLE_1 + name: TestJob + parameterDefinitions: + - name: NeedGpu + type: BOOL + default: true + steps: + - name: Step1 + let: + - "gpu_count = 1 if Param.NeedGpu else 0" + hostRequirements: + amounts: + - name: amount.worker.gpu + min: "{{ gpu_count }}" + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'GPU:{{ gpu_count }}') +expected: + output: + - GPU:1 diff --git a/conformance-tests/2023-09/EXPR/jobs/3.6--let-scopes.test.yaml b/conformance-tests/2023-09/EXPR/jobs/3.6--let-scopes.test.yaml new file mode 100644 index 0000000..9869d20 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/3.6--let-scopes.test.yaml @@ -0,0 +1,26 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + let: + - "step_val = 10" + script: + let: + - "script_val = 20" + actions: + onRun: + command: python + args: + - -c + - | + print(r'STEP:{{ step_val }}') + print(r'SCRIPT:{{ script_val }}') + print(r'SUM:{{ step_val + script_val }}') +expected: + output: + - STEP:10 + - SCRIPT:20 + - SUM:30 diff --git a/conformance-tests/2023-09/EXPR/jobs/3.6--let-script-binding.test.yaml b/conformance-tests/2023-09/EXPR/jobs/3.6--let-script-binding.test.yaml new file mode 100644 index 0000000..2bdb94a --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/3.6--let-script-binding.test.yaml @@ -0,0 +1,26 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + let: + - "x = 1" + - "y = x + 1" + - "z = y * 3" + actions: + onRun: + command: python + args: + - -c + - | + print(r'X:{{ x }}') + print(r'Y:{{ y }}') + print(r'Z:{{ z }}') +expected: + output: + - X:1 + - Y:2 + - Z:6 diff --git a/conformance-tests/2023-09/EXPR/jobs/3.6--let-simple-action-binding.test.yaml b/conformance-tests/2023-09/EXPR/jobs/3.6--let-simple-action-binding.test.yaml new file mode 100644 index 0000000..5ccacf4 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/3.6--let-simple-action-binding.test.yaml @@ -0,0 +1,15 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + - FEATURE_BUNDLE_1 + name: TestJob + steps: + - name: Step1 + bash: + let: + - "msg = 'hello from bash'" + script: echo "BASH:{{ msg }}" +expected: + output: + - BASH:hello from bash diff --git a/conformance-tests/2023-09/EXPR/jobs/3.6--let-step-binding.test.yaml b/conformance-tests/2023-09/EXPR/jobs/3.6--let-step-binding.test.yaml new file mode 100644 index 0000000..8122c73 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/3.6--let-step-binding.test.yaml @@ -0,0 +1,23 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + let: + - "base = 100" + - "doubled = base * 2" + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'BASE:{{ base }}') + print(r'DOUBLED:{{ doubled }}') +expected: + output: + - BASE:100 + - DOUBLED:200 diff --git a/conformance-tests/2023-09/EXPR/jobs/7.3--host-context-symbols.test.yaml b/conformance-tests/2023-09/EXPR/jobs/7.3--host-context-symbols.test.yaml new file mode 100644 index 0000000..0ae8624 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/7.3--host-context-symbols.test.yaml @@ -0,0 +1,26 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'WD_LEN:{{ len(string(Session.WorkingDirectory)) > 0 }}') + print(r'WD_IS_PATH:{{ len(Session.WorkingDirectory.name) > 0 }}') + print(r'HAS_PM:{{ Session.HasPathMappingRules }}') + print(r'HAS_PM_COND:{{ "yes" if Session.HasPathMappingRules else "no" }}') + print(r'PM_FILE_IS_PATH:{{ Session.PathMappingRulesFile.suffix }}') +expected: + output: + - WD_LEN:true + - WD_IS_PATH:true + - HAS_PM:false + - HAS_PM_COND:no + - PM_FILE_IS_PATH:.json diff --git a/conformance-tests/2023-09/EXPR/jobs/error--non-bool-condition.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/error--non-bool-condition.invalid.test.yaml new file mode 100644 index 0000000..17caa7b --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/error--non-bool-condition.invalid.test.yaml @@ -0,0 +1,17 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - >- + print(r'{{ "yes" if 1 else "no" }}') +expected: + output: [] diff --git a/conformance-tests/2023-09/EXPR/jobs/error--null-in-list.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/error--null-in-list.invalid.test.yaml new file mode 100644 index 0000000..e22f6d6 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/error--null-in-list.invalid.test.yaml @@ -0,0 +1,17 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - >- + print(r'{{ [1, null, 2] }}') +expected: + output: [] diff --git a/conformance-tests/2023-09/EXPR/jobs/error--type-mismatch-add.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/error--type-mismatch-add.invalid.test.yaml new file mode 100644 index 0000000..d165627 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/error--type-mismatch-add.invalid.test.yaml @@ -0,0 +1,17 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - >- + print(r'{{ "hello" + 5 }}') +expected: + output: [] diff --git a/conformance-tests/2023-09/EXPR/jobs/error--unknown-function.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/error--unknown-function.invalid.test.yaml new file mode 100644 index 0000000..eb6b541 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/error--unknown-function.invalid.test.yaml @@ -0,0 +1,17 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - >- + print(r'{{ nonexistent(1) }}') +expected: + output: [] diff --git a/conformance-tests/2023-09/EXPR/jobs/error--unknown-variable.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/error--unknown-variable.invalid.test.yaml new file mode 100644 index 0000000..82044b3 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/error--unknown-variable.invalid.test.yaml @@ -0,0 +1,17 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - >- + print(r'{{ Param.DoesNotExist }}') +expected: + output: [] diff --git a/conformance-tests/2023-09/EXPR/jobs/expr1.1.3--keyword-attrs-in-exprs.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr1.1.3--keyword-attrs-in-exprs.test.yaml new file mode 100644 index 0000000..911bc98 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr1.1.3--keyword-attrs-in-exprs.test.yaml @@ -0,0 +1,221 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + parameterDefinitions: + # Every Python hard keyword (as of 3.14) as a parameter name + - name: "False" + type: STRING + default: v_False + - name: "None" + type: STRING + default: v_None + - name: "True" + type: STRING + default: v_True + - name: "and" + type: STRING + default: v_and + - name: "as" + type: STRING + default: v_as + - name: "assert" + type: STRING + default: v_assert + - name: "async" + type: STRING + default: v_async + - name: "await" + type: STRING + default: v_await + - name: "break" + type: STRING + default: v_break + - name: "class" + type: STRING + default: v_class + - name: "continue" + type: STRING + default: v_continue + - name: "def" + type: STRING + default: v_def + - name: "del" + type: STRING + default: v_del + - name: "elif" + type: STRING + default: v_elif + - name: "else" + type: STRING + default: v_else + - name: "except" + type: STRING + default: v_except + - name: "finally" + type: STRING + default: v_finally + - name: "for" + type: STRING + default: v_for + - name: "from" + type: STRING + default: v_from + - name: "global" + type: STRING + default: v_global + - name: "if" + type: STRING + default: v_if + - name: "import" + type: STRING + default: v_import + - name: "in" + type: STRING + default: v_in + - name: "is" + type: STRING + default: v_is + - name: "lambda" + type: STRING + default: v_lambda + - name: "nonlocal" + type: STRING + default: v_nonlocal + - name: "not" + type: STRING + default: v_not + - name: "or" + type: STRING + default: v_or + - name: "pass" + type: STRING + default: v_pass + - name: "raise" + type: STRING + default: v_raise + - name: "return" + type: STRING + default: v_return + - name: "try" + type: STRING + default: v_try + - name: "while" + type: STRING + default: v_while + - name: "with" + type: STRING + default: v_with + - name: "yield" + type: STRING + default: v_yield + # Python soft keywords (as of 3.14) + - name: "_" + type: STRING + default: v__ + - name: "case" + type: STRING + default: v_case + - name: "match" + type: STRING + default: v_match + - name: "type" + type: STRING + default: v_type + - name: flag + type: BOOL + default: true + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'False:{{ Param.False }}') + print(r'None:{{ Param.None }}') + print(r'True:{{ Param.True }}') + print(r'and:{{ Param.and }}') + print(r'as:{{ Param.as }}') + print(r'assert:{{ Param.assert }}') + print(r'async:{{ Param.async }}') + print(r'await:{{ Param.await }}') + print(r'break:{{ Param.break }}') + print(r'class:{{ Param.class }}') + print(r'continue:{{ Param.continue }}') + print(r'def:{{ Param.def }}') + print(r'del:{{ Param.del }}') + print(r'elif:{{ Param.elif }}') + print(r'else:{{ Param.else }}') + print(r'except:{{ Param.except }}') + print(r'finally:{{ Param.finally }}') + print(r'for:{{ Param.for }}') + print(r'from:{{ Param.from }}') + print(r'global:{{ Param.global }}') + print(r'if:{{ Param.if }}') + print(r'import:{{ Param.import }}') + print(r'in:{{ Param.in }}') + print(r'is:{{ Param.is }}') + print(r'lambda:{{ Param.lambda }}') + print(r'nonlocal:{{ Param.nonlocal }}') + print(r'not:{{ Param.not }}') + print(r'or:{{ Param.or }}') + print(r'pass:{{ Param.pass }}') + print(r'raise:{{ Param.raise }}') + print(r'return:{{ Param.return }}') + print(r'try:{{ Param.try }}') + print(r'while:{{ Param.while }}') + print(r'with:{{ Param.with }}') + print(r'yield:{{ Param.yield }}') + print(r'_:{{ Param._ }}') + print(r'case:{{ Param.case }}') + print(r'match:{{ Param.match }}') + print(r'type:{{ Param.type }}') + print(r'COND:{{ Param.if if Param.flag else Param.else }}') + print(r'LOGIC:{{ Param.and if Param.flag and true else Param.or }}') +expected: + output: + - "False:v_False" + - "None:v_None" + - "True:v_True" + - "and:v_and" + - "as:v_as" + - "assert:v_assert" + - "async:v_async" + - "await:v_await" + - "break:v_break" + - "class:v_class" + - "continue:v_continue" + - "def:v_def" + - "del:v_del" + - "elif:v_elif" + - "else:v_else" + - "except:v_except" + - "finally:v_finally" + - "for:v_for" + - "from:v_from" + - "global:v_global" + - "if:v_if" + - "import:v_import" + - "in:v_in" + - "is:v_is" + - "lambda:v_lambda" + - "nonlocal:v_nonlocal" + - "not:v_not" + - "or:v_or" + - "pass:v_pass" + - "raise:v_raise" + - "return:v_return" + - "try:v_try" + - "while:v_while" + - "with:v_with" + - "yield:v_yield" + - "_:v__" + - "case:v_case" + - "match:v_match" + - "type:v_type" + - COND:v_if + - LOGIC:v_and diff --git a/conformance-tests/2023-09/EXPR/jobs/expr1.1.5--string-literals.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr1.1.5--string-literals.test.yaml new file mode 100644 index 0000000..c615b21 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr1.1.5--string-literals.test.yaml @@ -0,0 +1,49 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + let: + - "tdq = \"\"\"triple\ndouble\"\"\"" + - "tsq = '''triple\nsingle'''" + - "rtdq = r\"\"\"raw\\ntriple\"\"\"" + - "rtsq = r'''raw\\ntriple'''" + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'DQ:{{ len("hello") }}') + print(r"SQ:{{ len('world') }}") + print(r'RDQ:{{ r"hello\n" }}') + print(r"RSQ:{{ r'hello\n' }}") + print(r'RDQ_LEN:{{ len(r"hello\n") }}') + print(r"RSQ_LEN:{{ len(r'hello\n') }}") + print(r'ESC_LEN:{{ len("hello\nworld") }}') + print(r'ESC_T_LEN:{{ len("a\tb") }}') + print(r'ESC_BS:{{ "a\\b" }}') + print(r'RAW_BS:{{ r"C:\Users\test" }}') + print(r'TDQ_LEN:{{ len(tdq) }}') + print(r'TSQ_LEN:{{ len(tsq) }}') + print(r'RTDQ:{{ rtdq }}') + print(r'RTSQ:{{ rtsq }}') +expected: + output: + - DQ:5 + - SQ:5 + - "RDQ:hello\\n" + - "RSQ:hello\\n" + - RDQ_LEN:7 + - RSQ_LEN:7 + - ESC_LEN:11 + - ESC_T_LEN:3 + - "ESC_BS:a\\b" + - "RAW_BS:C:\\Users\\test" + - TDQ_LEN:13 + - TSQ_LEN:13 + - "RTDQ:raw\\ntriple" + - "RTSQ:raw\\ntriple" diff --git a/conformance-tests/2023-09/EXPR/jobs/expr1.1.6--numeric-literals.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr1.1.6--numeric-literals.test.yaml new file mode 100644 index 0000000..b44d3e8 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr1.1.6--numeric-literals.test.yaml @@ -0,0 +1,40 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'HEX:{{ 0x2A }}') + print(r'OCT:{{ 0o52 }}') + print(r'BIN:{{ 0b101010 }}') + print(r'USCORE:{{ 1_000_000 }}') + print(r'SCI_PASS:{{ 1.5e3 }}') + print(r'SCI_NEG_PASS:{{ 1.5e-3 }}') + print(r'SCI_ADD:{{ 1.5e3 + 0 }}') + print(r'SCI_NEG_ADD:{{ 1.5e-3 + 0 }}') + print(r'SCI_POS:{{ +1.5e3 }}') + print(r'SCI_NEG_POS:{{ +1.5e-3 }}') + print(r'HEX_US:{{ 0xFF_FF }}') + print(r'BIN_US:{{ 0b1010_1010 }}') +expected: + output: + - HEX:42 + - OCT:42 + - BIN:42 + - USCORE:1000000 + - SCI_PASS:1.5e3 + - SCI_NEG_PASS:1.5e-3 + - SCI_ADD:1500.0 + - SCI_NEG_ADD:0.0015 + - SCI_POS:1500.0 + - SCI_NEG_POS:0.0015 + - HEX_US:65535 + - BIN_US:170 diff --git a/conformance-tests/2023-09/EXPR/jobs/expr1.1.7--multiline.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr1.1.7--multiline.test.yaml new file mode 100644 index 0000000..5c8edc3 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr1.1.7--multiline.test.yaml @@ -0,0 +1,30 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'ARITH:{{ 1 + + 2 + + 3 }}') + print(r'COND:{{ "high" + if 90 > 80 + else "low" }}') + print(r'COMP:{{ [ + x * 2 + for x in [1, 2, 3] + if x > 1 + ] }}') +expected: + output: + - ARITH:6 + - COND:high + - "COMP:[4, 6]" diff --git a/conformance-tests/2023-09/EXPR/jobs/expr1.2.3--implicit-coercion.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr1.2.3--implicit-coercion.test.yaml new file mode 100644 index 0000000..0233829 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr1.2.3--implicit-coercion.test.yaml @@ -0,0 +1,43 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + let: + - 'r = range_expr("1-3")' + - 'path_join = [path("/a"), path("/b")].join(",")' + - 'path_sw = startswith(path("/mnt/out"), "/mnt")' + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'INTFLOAT:{{ 2 + 1.5 }}') + print(r'PATHSTR:{{ path_sw }}') + print(r'RANGECAT:{{ [10, 20] + range_expr("1-3") }}') + print(r'RANGE_STR:{{ string(r) }}') + print(r'RANGE_FMT:frames={{ r }}') + print(r'RANGE_ADD:{{ "frames: " + r }}') + print(r'RANGE_ADD2:{{ r + " are frames" }}') + print(r'LIST_PATH_STR:{{ path_join }}') + print(r'FMT_EMBED:Count is {{ len([1,2,3]) }} items') + print(r'BOOL_FMT:val={{ true }}') +expected: + output: + - INTFLOAT:3.5 + - PATHSTR:true + - "RANGECAT:[10, 20, 1, 2, 3]" + - "RANGE_STR:1-3" + - "RANGE_FMT:frames=1-3" + - "RANGE_ADD:frames: 1-3" + - "RANGE_ADD2:1-3 are frames" + - "FMT_EMBED:Count is 3 items" + - "BOOL_FMT:val=true" + output_posix: + - "LIST_PATH_STR:/a,/b" + output_windows: + - "LIST_PATH_STR:\\a,\\b" diff --git a/conformance-tests/2023-09/EXPR/jobs/expr1.2.4--function-vs-method-coercion.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr1.2.4--function-vs-method-coercion.test.yaml new file mode 100644 index 0000000..747e883 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr1.2.4--function-vs-method-coercion.test.yaml @@ -0,0 +1,33 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + let: + - 'func_coerce = startswith(path("/foo/bar"), "/foo")' + - 'method_ok = "/foo/bar".startswith("/foo")' + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'FUNC_COERCE:{{ func_coerce }}') + print(r'METHOD_OK:{{ method_ok }}') + print(r'HOST_FUNC:{{ startswith(path("/foo/bar"), "/foo") }}') + print(r'HOST_METHOD:{{ "/foo/bar".startswith("/foo") }}') + print(r'HOST_FUNC_WIN:{{ startswith(path("/foo/bar"), r"\foo") }}') +expected: + output: + - FUNC_COERCE:true + - METHOD_OK:true + - HOST_METHOD:true + output_posix: + - HOST_FUNC:true + - HOST_FUNC_WIN:false + output_windows: + - HOST_FUNC:false + - HOST_FUNC_WIN:true diff --git a/conformance-tests/2023-09/EXPR/jobs/expr1.2.4--method-no-receiver-coercion.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr1.2.4--method-no-receiver-coercion.invalid.test.yaml new file mode 100644 index 0000000..03313f4 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr1.2.4--method-no-receiver-coercion.invalid.test.yaml @@ -0,0 +1,20 @@ +# Method call: no implicit coercion on receiver +# path('/foo/bar').startswith('/foo') should error because startswith is defined +# for string, and path receiver cannot be coerced in method-call position. +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - >- + print(r'{{ path("/foo/bar").startswith("/foo") }}') +expected: + output: [] diff --git a/conformance-tests/2023-09/EXPR/jobs/expr1.2.5--cross-type-equality.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr1.2.5--cross-type-equality.test.yaml new file mode 100644 index 0000000..86cc591 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr1.2.5--cross-type-equality.test.yaml @@ -0,0 +1,44 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + let: + - 'r = range_expr("1-3")' + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'LIST_RANGE:{{ [1, 2, 3] == range_expr("1-3") }}') + print(r'RANGE_LIST:{{ range_expr("1-3") == [1, 2, 3] }}') + print(r'SCALAR_LIST:{{ 1 == [1] }}') + print(r'STR_PATH:{{ "/a/b" == path("/a/b") }}') + print(r'PATH_STR:{{ path("/a/b") == "/a/b" }}') + print(r'ELEM_CROSS:{{ [5] == [5.0] }}') + print(r'NESTED_CROSS:{{ [[5]] == [[5.0]] }}') + print(r'BOOL_INT:{{ true == 1 }}') + print(r'BOOL_STR:{{ true == "true" }}') + print(r'STR_INT:{{ "5" == 5 }}') + print(r'NULL_ZERO:{{ null == 0 }}') + print(r'NULL_NULL:{{ null == null }}') + print(r'LIST_DIFF_LEN:{{ [1, 2] == [1, 2, 3] }}') +expected: + output: + - LIST_RANGE:true + - RANGE_LIST:true + - SCALAR_LIST:false + - STR_PATH:true + - PATH_STR:true + - ELEM_CROSS:true + - NESTED_CROSS:true + - BOOL_INT:false + - BOOL_STR:false + - STR_INT:false + - NULL_ZERO:false + - NULL_NULL:true + - LIST_DIFF_LEN:false diff --git a/conformance-tests/2023-09/EXPR/jobs/expr1.2.6--bool-int-mix.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr1.2.6--bool-int-mix.invalid.test.yaml new file mode 100644 index 0000000..d404e25 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr1.2.6--bool-int-mix.invalid.test.yaml @@ -0,0 +1,16 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - "print(r'{{ [true, 1] }}')" +expected: + output: [] diff --git a/conformance-tests/2023-09/EXPR/jobs/expr1.2.6--incompatible-types.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr1.2.6--incompatible-types.invalid.test.yaml new file mode 100644 index 0000000..777472a --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr1.2.6--incompatible-types.invalid.test.yaml @@ -0,0 +1,14 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - print(r'{{ [1, "a"] }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/expr1.2.6--list-type-inference.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr1.2.6--list-type-inference.test.yaml new file mode 100644 index 0000000..6456b3b --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr1.2.6--list-type-inference.test.yaml @@ -0,0 +1,59 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + # List literals are evaluated in step-level let (TEMPLATE scope) so that + # path() uses PurePosixPath for cross-platform consistency (Expression + # Language §2.3.2). In host context, path() would use native OS semantics. + let: + - 'homo_int = [1, 2, 3]' + - 'homo_float = [1.0, 2.0]' + - 'homo_str = ["a", "b"]' + - 'homo_path = [path("/a"), path("/b")]' + - 'mix_int_float = [1, 2.0]' + - 'mix_path_str = [path("/a"), "b"]' + - 'empty = []' + - 'nested_int = [[1, 2], [3, 4]]' + - 'nested_float = [[1.0, 2.0], [3.0, 4.0]]' + - 'nested_str = [["a", "b"], ["c"]]' + - 'nested_path = [[path("/a")], [path("/b")]]' + - 'nested_mix_int_float = [[1], [2.0]]' + - 'nested_mix_path_str = [[path("/a")], ["b"]]' + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'HOMO_INT:{{ homo_int }}') + print(r'HOMO_FLOAT:{{ homo_float }}') + print(r'HOMO_STR:{{ homo_str }}') + print(r'HOMO_PATH:{{ homo_path }}') + print(r'MIX_INT_FLOAT:{{ mix_int_float }}') + print(r'MIX_PATH_STR:{{ mix_path_str }}') + print(r'EMPTY:{{ empty }}') + print(r'NESTED_INT:{{ nested_int }}') + print(r'NESTED_FLOAT:{{ nested_float }}') + print(r'NESTED_STR:{{ nested_str }}') + print(r'NESTED_PATH:{{ nested_path }}') + print(r'NESTED_MIX_INT_FLOAT:{{ nested_mix_int_float }}') + print(r'NESTED_MIX_PATH_STR:{{ nested_mix_path_str }}') +expected: + output: + - "HOMO_INT:[1, 2, 3]" + - "HOMO_FLOAT:[1.0, 2.0]" + - 'HOMO_STR:["a", "b"]' + - 'HOMO_PATH:["/a", "/b"]' + - "MIX_INT_FLOAT:[1.0, 2.0]" + - 'MIX_PATH_STR:["/a", "b"]' + - "EMPTY:[]" + - "NESTED_INT:[[1, 2], [3, 4]]" + - "NESTED_FLOAT:[[1.0, 2.0], [3.0, 4.0]]" + - 'NESTED_STR:[["a", "b"], ["c"]]' + - 'NESTED_PATH:[["/a"], ["/b"]]' + - "NESTED_MIX_INT_FLOAT:[[1.0], [2.0]]" + - 'NESTED_MIX_PATH_STR:[["/a"], ["b"]]' diff --git a/conformance-tests/2023-09/EXPR/jobs/expr1.2.6--scalar-list-mix.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr1.2.6--scalar-list-mix.invalid.test.yaml new file mode 100644 index 0000000..a75d8a0 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr1.2.6--scalar-list-mix.invalid.test.yaml @@ -0,0 +1,16 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - "print(r'{{ [1, [2]] }}')" +expected: + output: [] diff --git a/conformance-tests/2023-09/EXPR/jobs/expr1.3.2--format-string-semantics.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr1.3.2--format-string-semantics.test.yaml new file mode 100644 index 0000000..66fb02f --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr1.3.2--format-string-semantics.test.yaml @@ -0,0 +1,30 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + parameterDefinitions: + - name: Verbose + type: BOOL + default: true + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + import shlex, sys + print('ARGS:' + ' '.join(shlex.quote(a) for a in sys.argv[1:])) + - "--input" + - "file.exr" + - "{{ '--verbose' if Param.Verbose else null }}" + - "{{ ['--quality', '90'] if Param.Verbose else null }}" + - "prefix {{ 42 }} suffix" + - "list: {{ [1, 2, 3] }}" + - "null:{{ null }}end" +expected: + output: + - "ARGS:--input file.exr --verbose --quality 90 'prefix 42 suffix' 'list: [1, 2, 3]' null:nullend" diff --git a/conformance-tests/2023-09/EXPR/jobs/expr1.3.2--null-in-list-literal-error.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr1.3.2--null-in-list-literal-error.invalid.test.yaml new file mode 100644 index 0000000..093c353 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr1.3.2--null-in-list-literal-error.invalid.test.yaml @@ -0,0 +1,14 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - print(r'{{ [1, null, 2] }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/expr1.3.2--null-skips-arg.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr1.3.2--null-skips-arg.test.yaml new file mode 100644 index 0000000..94fbe53 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr1.3.2--null-skips-arg.test.yaml @@ -0,0 +1,32 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + parameterDefinitions: + - name: Verbose + type: BOOL + default: false + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + import sys + sys.stdout.write('ARGS:') + sys.stdout.write(' '.join(sys.argv[1:])) + sys.stdout.write('\n') + - "--input" + - "file.exr" + - "{{ '--verbose' if Param.Verbose else null }}" + - "{{ ['--quality', '90'] if Param.Verbose else null }}" +expected: + output: + - "ARGS:--input file.exr" + forbidden: + - "--verbose" + - "--quality" diff --git a/conformance-tests/2023-09/EXPR/jobs/expr1.3.3--ufcs.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr1.3.3--ufcs.test.yaml new file mode 100644 index 0000000..56b980d --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr1.3.3--ufcs.test.yaml @@ -0,0 +1,33 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + parameterDefinitions: + - name: File + type: PATH + default: projects/shot01/render.exr + steps: + - name: Step1 + let: + - 'func_upper = upper("hello")' + - 'method_upper = "hello".upper()' + - 'chained = "a,b,c".split(",").join(";")' + - 'prop = Param.File.stem' + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'FUNC:{{ func_upper }}') + print(r'METHOD:{{ method_upper }}') + print(r'CHAINED:{{ chained }}') + print(r'PROP:{{ prop }}') +expected: + output: + - FUNC:HELLO + - METHOD:HELLO + - "CHAINED:a;b;c" + - PROP:render diff --git a/conformance-tests/2023-09/EXPR/jobs/expr1.3.4--float-passthrough.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr1.3.4--float-passthrough.test.yaml new file mode 100644 index 0000000..b80c8f7 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr1.3.4--float-passthrough.test.yaml @@ -0,0 +1,35 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + parameterDefinitions: + - name: V + type: FLOAT + default: "3.500" + steps: + - name: Step1 + let: + - "from_let = 1.30" + - "from_list = [1.70]" + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'PARAM:{{Param.V}}') + print(r'PARAM_CALC:{{Param.V + 1}}') + print(r'LET:{{from_let}}') + print(r'LIST_IDX:{{from_list[0]}}') + print(r'IF_TRUE:{{Param.V if true else 0.0}}') + print(r'IF_FALSE:{{0.0 if false else Param.V}}') +expected: + output: + - PARAM:3.500 + - PARAM_CALC:4.5 + - LET:1.30 + - LIST_IDX:1.70 + - IF_TRUE:3.500 + - IF_FALSE:3.500 diff --git a/conformance-tests/2023-09/EXPR/jobs/expr1.3.5--conditionals.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr1.3.5--conditionals.test.yaml new file mode 100644 index 0000000..e620d07 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr1.3.5--conditionals.test.yaml @@ -0,0 +1,26 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + parameterDefinitions: + - name: Flag + type: BOOL + default: true + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'TRUE:{{ "yes" if Param.Flag else "no" }}') + print(r'FALSE:{{ "yes" if not Param.Flag else "no" }}') + print(r'CALC:{{ 10 * 2 if Param.Flag else 0 }}') +expected: + output: + - TRUE:yes + - FALSE:no + - CALC:20 diff --git a/conformance-tests/2023-09/EXPR/jobs/expr1.3.7--comprehensions.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr1.3.7--comprehensions.test.yaml new file mode 100644 index 0000000..30a159f --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr1.3.7--comprehensions.test.yaml @@ -0,0 +1,26 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + parameterDefinitions: + - name: Frames + type: RANGE_EXPR + default: "1-5" + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'BASIC:{{ [x * 2 for x in [1, 2, 3]] }}') + print(r'FILTER:{{ [x for x in [1, 2, 3, 4, 5] if x > 2] }}') + print(r'RANGE:{{ [x * 10 for x in Param.Frames] }}') +expected: + output: + - "BASIC:[2, 4, 6]" + - "FILTER:[3, 4, 5]" + - "RANGE:[10, 20, 30, 40, 50]" diff --git a/conformance-tests/2023-09/EXPR/jobs/expr1.3.7--loop-var-shadows-binding.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr1.3.7--loop-var-shadows-binding.invalid.test.yaml new file mode 100644 index 0000000..4d2cc3f --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr1.3.7--loop-var-shadows-binding.invalid.test.yaml @@ -0,0 +1,19 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + let: + - "x = 99" + script: + actions: + onRun: + command: python + args: + - -c + - >- + print(r'{{ [x for x in [1, 2]] }}') +expected: + output: [] diff --git a/conformance-tests/2023-09/EXPR/jobs/expr1.3.7--loop-var-valid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr1.3.7--loop-var-valid.test.yaml new file mode 100644 index 0000000..6d5b20b --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr1.3.7--loop-var-valid.test.yaml @@ -0,0 +1,20 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'LOWER:{{ [x * 2 for x in [1, 2, 3]] }}') + print(r'UNDER:{{ [_x + 1 for _x in [10, 20]] }}') +expected: + output: + - "LOWER:[2, 4, 6]" + - "UNDER:[11, 21]" diff --git a/conformance-tests/2023-09/EXPR/jobs/expr1.3.8--slice-edge-cases.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr1.3.8--slice-edge-cases.test.yaml new file mode 100644 index 0000000..1365103 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr1.3.8--slice-edge-cases.test.yaml @@ -0,0 +1,22 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'CLAMP:{{ [1, 2, 3][0:100] }}') + print(r'NEG_STEP:{{ [1, 2, 3, 4, 5][4:1:-1] }}') + print(r'NEG_FULL:{{ "abcde"[3:0:-1] }}') +expected: + output: + - "CLAMP:[1, 2, 3]" + - "NEG_STEP:[5, 4, 3]" + - "NEG_FULL:dcb" diff --git a/conformance-tests/2023-09/EXPR/jobs/expr1.3.8--slice-step-zero.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr1.3.8--slice-step-zero.invalid.test.yaml new file mode 100644 index 0000000..b9cd042 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr1.3.8--slice-step-zero.invalid.test.yaml @@ -0,0 +1,17 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - >- + print(r'{{ [1, 2, 3][::0] }}') +expected: + output: [] diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.1.1--division-by-zero.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.1.1--division-by-zero.invalid.test.yaml new file mode 100644 index 0000000..139b405 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.1.1--division-by-zero.invalid.test.yaml @@ -0,0 +1,14 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - print(r'{{ 1 / 0 }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.1.1--division.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.1.1--division.test.yaml new file mode 100644 index 0000000..f4b5a24 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.1.1--division.test.yaml @@ -0,0 +1,24 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'TRUEDIV:{{ 7 / 2 }}') + print(r'FLOORDIV:{{ 7 // 2 }}') + print(r'EXACT:{{ 6 / 2 }}') + print(r'FFLOORDIV:{{ 7.0 // 2.0 }}') +expected: + output: + - TRUEDIV:3.5 + - FLOORDIV:3 + - EXACT:3.0 + - FFLOORDIV:3 diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.1.1--exponentiation.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.1.1--exponentiation.test.yaml new file mode 100644 index 0000000..73350fc --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.1.1--exponentiation.test.yaml @@ -0,0 +1,26 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'POSEXP:{{ 2 ** 3 }}') + print(r'NEGEXP:{{ 2 ** -3 }}') + print(r'FEXP:{{ 2.0 ** 3.0 }}') + print(r'ZEROEXP:{{ 2 ** 0 }}') + print(r'ZEROF:{{ 5.0 ** 0 }}') +expected: + output: + - POSEXP:8 + - NEGEXP:0.125 + - FEXP:8.0 + - ZEROEXP:1 + - ZEROF:1.0 diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.1.1--float-arithmetic.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.1.1--float-arithmetic.test.yaml new file mode 100644 index 0000000..8bcf2a0 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.1.1--float-arithmetic.test.yaml @@ -0,0 +1,33 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + parameterDefinitions: + - name: X + type: INT + default: 2 + - name: Y + type: FLOAT + default: 1.5 + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'FADD:{{ 1.5 + 2.5 }}') + print(r'PROMOTE:{{ Param.X + Param.Y }}') + print(r'FSUB:{{ 5.0 - 1.5 }}') + print(r'FMUL:{{ Param.Y * 2 }}') + print(r'FMOD:{{ 7.5 % 2.0 }}') +expected: + output: + - FADD:4.0 + - PROMOTE:3.5 + - FSUB:3.5 + - FMUL:3.0 + - FMOD:1.5 diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.1.1--int-arithmetic.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.1.1--int-arithmetic.test.yaml new file mode 100644 index 0000000..87a9737 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.1.1--int-arithmetic.test.yaml @@ -0,0 +1,34 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + parameterDefinitions: + - name: X + type: INT + default: 10 + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'ADD:{{ Param.X + 3 }}') + print(r'SUB:{{ Param.X - 3 }}') + print(r'MUL:{{ Param.X * 3 }}') + print(r'FLOORDIV:{{ Param.X // 3 }}') + print(r'MOD:{{ Param.X % 3 }}') + print(r'NEG:{{ -Param.X }}') + print(r'POS:{{ +Param.X }}') +expected: + output: + - ADD:13 + - SUB:7 + - MUL:30 + - FLOORDIV:3 + - MOD:1 + - NEG:-10 + - POS:10 diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.1.2--string-ops.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.1.2--string-ops.test.yaml new file mode 100644 index 0000000..896baa3 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.1.2--string-ops.test.yaml @@ -0,0 +1,26 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'CONCAT:{{ "hello" + " world" }}') + print(r'REPEAT:{{ "ab" * 3 }}') + print(r'REPEAT0:{{ len("ab" * 0) }} chars') + print(r'IN:{{ "ell" in "hello" }}') + print(r'NOTIN:{{ "xyz" not in "hello" }}') +expected: + output: + - "CONCAT:hello world" + - REPEAT:ababab + - "REPEAT0:0 chars" + - IN:true + - NOTIN:true diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.1.3--incompatible-list-concat.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.1.3--incompatible-list-concat.invalid.test.yaml new file mode 100644 index 0000000..cbad315 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.1.3--incompatible-list-concat.invalid.test.yaml @@ -0,0 +1,14 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - print(r'{{ [1] + ["a"] }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.1.3--list-ops.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.1.3--list-ops.test.yaml new file mode 100644 index 0000000..d1c80e6 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.1.3--list-ops.test.yaml @@ -0,0 +1,36 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'CONCAT:{{ [1, 2] + [3, 4] }}') + print(r'INTFLOAT:{{ [1, 2] + [3.0, 4.0] }}') + print(r'REPEAT:{{ [1, 2] * 2 }}') + print(r'REPEAT0:{{ [1, 2] * 0 }}') + print(r'IN:{{ 2 in [1, 2, 3] }}') + print(r'NOTIN:{{ 5 not in [1, 2, 3] }}') + print(r'RANGE_IN:{{ 5 in range_expr("1-10") }}') + print(r'RANGE_NOTIN:{{ 15 not in range_expr("1-10") }}') + print(r'RANGE_RANGE:{{ range_expr("1-3") + range_expr("7-9") }}') + print(r'EMPTY_CONCAT:{{ [] + [1, 2] }}') +expected: + output: + - "CONCAT:[1, 2, 3, 4]" + - "INTFLOAT:[1.0, 2.0, 3.0, 4.0]" + - "REPEAT:[1, 2, 1, 2]" + - "REPEAT0:[]" + - IN:true + - NOTIN:true + - RANGE_IN:true + - RANGE_NOTIN:true + - "RANGE_RANGE:[1, 2, 3, 7, 8, 9]" + - "EMPTY_CONCAT:[1, 2]" diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.1.4--comparisons.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.1.4--comparisons.test.yaml new file mode 100644 index 0000000..fff9ec0 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.1.4--comparisons.test.yaml @@ -0,0 +1,61 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + let: + - 'path_lt = path("/a") < path("/b")' + - 'path_eq = path("/a") == path("/a")' + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'EQ:{{ 5 == 5 }}') + print(r'NE:{{ 5 != 3 }}') + print(r'LT:{{ 3 < 5 }}') + print(r'GT:{{ 5 > 3 }}') + print(r'LE:{{ 5 <= 5 }}') + print(r'GE:{{ 5 >= 5 }}') + print(r'LE2:{{ 3 <= 5 }}') + print(r'GE2:{{ 5 >= 3 }}') + print(r'INTFLOAT:{{ 5 == 5.0 }}') + print(r'BOOLINT:{{ true == 1 }}') + print(r'STRINT:{{ "5" == 5 }}') + print(r'CHAIN:{{ 1 < 2 < 3 }}') + print(r'STREQ:{{ "abc" == "abc" }}') + print(r'STRLT:{{ "abc" < "abd" }}') + print(r'LISTEQ:{{ [1, 2, 3] == [1, 2, 3] }}') + print(r'LISTNE:{{ [1, 2] == [1, 3] }}') + print(r'LISTLT:{{ [1, 2] < [1, 3] }}') + print(r'LISTLEN:{{ [1, 2] < [1, 2, 3] }}') + print(r'BOOL_ORD:{{ false < true }}') + print(r'PATH_LT:{{ path_lt }}') + print(r'PATH_EQ:{{ path_eq }}') +expected: + output: + - EQ:true + - NE:true + - LT:true + - GT:true + - LE:true + - GE:true + - LE2:true + - GE2:true + - INTFLOAT:true + - BOOLINT:false + - STRINT:false + - CHAIN:true + - STREQ:true + - STRLT:true + - LISTEQ:true + - LISTNE:false + - LISTLT:true + - LISTLEN:true + - BOOL_ORD:true + - PATH_LT:true + - PATH_EQ:true diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.1.5--path-host-context.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.1.5--path-host-context.test.yaml new file mode 100644 index 0000000..5a07102 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.1.5--path-host-context.test.yaml @@ -0,0 +1,45 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + let: + - 'posix_abs = path("/mnt/renders/scene.exr")' + - 'posix_rel = path("sub/file.exr")' + - 'posix_joined = path("/mnt/output") / "sub" / "file.exr"' + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'HOST_POSIX_ABS:{{ path("/mnt/renders/scene.exr") }}') + print(r'HOST_REL:{{ path("sub/file.exr") }}') + print(r'HOST_JOIN:{{ path("/mnt/output") / "sub" / "file.exr" }}') + print(r'HOST_WIN_ABS:{{ path("D:/renders/scene.exr") }}') + print(r'HOST_PARENT:{{ path("/mnt/renders/scene.exr").parent }}') + print(r'AS_POSIX:{{ path("/mnt/renders/scene.exr").as_posix() }}') + print(r'LET_ABS:{{ posix_abs }}') + print(r'LET_REL:{{ posix_rel }}') + print(r'LET_JOINED:{{ posix_joined }}') +expected: + output: + - AS_POSIX:/mnt/renders/scene.exr + - LET_ABS:/mnt/renders/scene.exr + - LET_REL:sub/file.exr + - LET_JOINED:/mnt/output/sub/file.exr + output_posix: + - HOST_POSIX_ABS:/mnt/renders/scene.exr + - HOST_REL:sub/file.exr + - HOST_JOIN:/mnt/output/sub/file.exr + - HOST_WIN_ABS:D:/renders/scene.exr + - HOST_PARENT:/mnt/renders + output_windows: + - HOST_POSIX_ABS:\mnt\renders\scene.exr + - HOST_REL:sub\file.exr + - HOST_JOIN:\mnt\output\sub\file.exr + - HOST_WIN_ABS:D:\renders\scene.exr + - HOST_PARENT:\mnt\renders diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.1.5--path-ops.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.1.5--path-ops.test.yaml new file mode 100644 index 0000000..56c574a --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.1.5--path-ops.test.yaml @@ -0,0 +1,33 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + let: + - 'joined = path("/mnt/output") / "renders" / "scene.exr"' + - 'appended = path("/mnt/output/base") + ".txt"' + - 'abs_replace = path("/mnt/output") / path("/other/path")' + - 'path_path = path("/mnt") / path("sub/dir")' + - 'joined_posix = joined.as_posix()' + - 'appended_posix = appended.as_posix()' + - 'abs_posix = abs_replace.as_posix()' + - 'pp_posix = path_path.as_posix()' + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'JOIN:{{ joined_posix }}') + print(r'APPEND:{{ appended_posix }}') + print(r'ABS_REPLACE:{{ abs_posix }}') + print(r'PATH_PATH:{{ pp_posix }}') +expected: + output: + - JOIN:/mnt/output/renders/scene.exr + - APPEND:/mnt/output/base.txt + - ABS_REPLACE:/other/path + - PATH_PATH:/mnt/sub/dir diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.1.6--logical-ops.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.1.6--logical-ops.test.yaml new file mode 100644 index 0000000..07656e3 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.1.6--logical-ops.test.yaml @@ -0,0 +1,30 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'AND:{{ true and true }}') + print(r'ANDFALSE:{{ true and false }}') + print(r'OR:{{ false or true }}') + print(r'ORFALSE:{{ false or false }}') + print(r'NOT:{{ not false }}') + print(r'SHORT:{{ false and (1/0 > 0) }}') + print(r'SHORT_OR:{{ true or (1/0 > 0) }}') +expected: + output: + - AND:true + - ANDFALSE:false + - OR:true + - ORFALSE:false + - NOT:true + - SHORT:false + - SHORT_OR:true diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.1.7--index-out-of-bounds.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.1.7--index-out-of-bounds.invalid.test.yaml new file mode 100644 index 0000000..d5d3c5f --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.1.7--index-out-of-bounds.invalid.test.yaml @@ -0,0 +1,14 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - print(r'{{ [1][5] }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.1.7--indexing.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.1.7--indexing.test.yaml new file mode 100644 index 0000000..47de534 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.1.7--indexing.test.yaml @@ -0,0 +1,26 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'LIST:{{ [10, 20, 30][1] }}') + print(r'NEG:{{ [10, 20, 30][-1] }}') + print(r'STR:{{ "hello"[0] }}') + print(r'STR_NEG:{{ "hello"[-1] }}') + print(r'RANGE:{{ range_expr("5-10")[2] }}') +expected: + output: + - LIST:20 + - NEG:30 + - STR:h + - STR_NEG:o + - RANGE:7 diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.1.8--slicing.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.1.8--slicing.test.yaml new file mode 100644 index 0000000..2da5107 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.1.8--slicing.test.yaml @@ -0,0 +1,28 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + parameterDefinitions: + - name: Frames + type: RANGE_EXPR + default: "1-5" + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'LSLICE:{{ [10, 20, 30, 40, 50][1:4] }}') + print(r'REV:{{ [1, 2, 3][::-1] }}') + print(r'SSLICE:{{ "hello"[1:4] }}') + print(r'RSLICE:{{ Param.Frames[1:3] }}') +expected: + output: + - "LSLICE:[20, 30, 40]" + - "REV:[3, 2, 1]" + - SSLICE:ell + - "RSLICE:[2, 3]" diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--bool-conversion.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--bool-conversion.test.yaml new file mode 100644 index 0000000..8bb2a32 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--bool-conversion.test.yaml @@ -0,0 +1,40 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'INT1:{{ bool(1) }}') + print(r'INT0:{{ bool(0) }}') + print(r'FLOAT1:{{ bool(1.0) }}') + print(r'FLOAT0:{{ bool(0.0) }}') + print(r'PASS:{{ bool(true) }}') + print(r'NULL:{{ bool(null) }}') + print(r'TRUE:{{ bool("true") }}') + print(r'FALSE:{{ bool("false") }}') + print(r'YES:{{ bool("yes") }}') + print(r'NO:{{ bool("no") }}') + print(r'ON:{{ bool("on") }}') + print(r'OFF:{{ bool("off") }}') +expected: + output: + - INT1:true + - INT0:false + - FLOAT1:true + - FLOAT0:false + - PASS:true + - "NULL:false" + - TRUE:true + - FALSE:false + - YES:true + - NO:false + - ON:true + - OFF:false diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--bool-from-list.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--bool-from-list.invalid.test.yaml new file mode 100644 index 0000000..cadbb3f --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--bool-from-list.invalid.test.yaml @@ -0,0 +1,14 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - print(r'{{ bool([1, 2]) }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--bool-from-path.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--bool-from-path.invalid.test.yaml new file mode 100644 index 0000000..a7fcf4d --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--bool-from-path.invalid.test.yaml @@ -0,0 +1,14 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - print(r'{{ bool(path("/tmp")) }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--bool-from-string-invalid.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--bool-from-string-invalid.invalid.test.yaml new file mode 100644 index 0000000..fea9268 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--bool-from-string-invalid.invalid.test.yaml @@ -0,0 +1,14 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - print(r'{{ bool('maybe') }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--float-conversion.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--float-conversion.test.yaml new file mode 100644 index 0000000..37d98dc --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--float-conversion.test.yaml @@ -0,0 +1,32 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'INT:{{ float(3) }}') + print(r'STR:{{ float("2.5") }}') + print(r'STR_INT:{{ float("42") }}') + print(r'SCI:{{ float("1.5e3") }}') + print(r'SCI_NEG:{{ float("2.5e-1") }}') + print(r'LEADING:{{ float(" 2.5") }}') + print(r'TRAILING:{{ float("2.5 ") }}') + print(r'BOTH:{{ float(" 2.5 ") }}') +expected: + output: + - INT:3.0 + - STR:2.5 + - STR_INT:42.0 + - SCI:1500.0 + - SCI_NEG:0.25 + - LEADING:2.5 + - TRAILING:2.5 + - BOTH:2.5 diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--float-from-complex.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--float-from-complex.invalid.test.yaml new file mode 100644 index 0000000..894429a --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--float-from-complex.invalid.test.yaml @@ -0,0 +1,14 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - print(r'{{ float('1.5j') }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--float-from-empty-string.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--float-from-empty-string.invalid.test.yaml new file mode 100644 index 0000000..3f8d648 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--float-from-empty-string.invalid.test.yaml @@ -0,0 +1,14 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - print(r'{{ float('') }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--float-from-string-invalid.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--float-from-string-invalid.invalid.test.yaml new file mode 100644 index 0000000..221f07e --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--float-from-string-invalid.invalid.test.yaml @@ -0,0 +1,14 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - print(r'{{ float('hello') }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--int-conversion.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--int-conversion.test.yaml new file mode 100644 index 0000000..4796849 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--int-conversion.test.yaml @@ -0,0 +1,32 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'FLOAT:{{ int(3.0) }}') + print(r'STR:{{ int("42") }}') + print(r'SCI:{{ int(1e2) }}') + print(r'NEG:{{ int(-5.0) }}') + print(r'STR_NEG:{{ int("-7") }}') + print(r'LEADING:{{ int(" 42") }}') + print(r'TRAILING:{{ int("42 ") }}') + print(r'BOTH:{{ int(" 42 ") }}') +expected: + output: + - FLOAT:3 + - STR:42 + - SCI:100 + - NEG:-5 + - STR_NEG:-7 + - LEADING:42 + - TRAILING:42 + - BOTH:42 diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--int-from-empty-string.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--int-from-empty-string.invalid.test.yaml new file mode 100644 index 0000000..2f96d09 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--int-from-empty-string.invalid.test.yaml @@ -0,0 +1,14 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - print(r'{{ int('') }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--int-from-float-inexact.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--int-from-float-inexact.invalid.test.yaml new file mode 100644 index 0000000..3da3da0 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--int-from-float-inexact.invalid.test.yaml @@ -0,0 +1,14 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - print(r'{{ int(3.75) }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--int-from-float-string.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--int-from-float-string.invalid.test.yaml new file mode 100644 index 0000000..9b4d4ad --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--int-from-float-string.invalid.test.yaml @@ -0,0 +1,14 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - print(r'{{ int('3.5') }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--int-from-string-invalid.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--int-from-string-invalid.invalid.test.yaml new file mode 100644 index 0000000..5bccdb0 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--int-from-string-invalid.invalid.test.yaml @@ -0,0 +1,14 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - print(r'{{ int('hello') }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--len.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--len.test.yaml new file mode 100644 index 0000000..e5a8852 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--len.test.yaml @@ -0,0 +1,28 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + parameterDefinitions: + - name: Frames + type: RANGE_EXPR + default: "1-5" + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'LIST:{{ len([10, 20, 30]) }}') + print(r'STR:{{ len("hello") }}') + print(r'RANGE:{{ len(Param.Frames) }}') + print(r'PATH:{{ len(path("/mnt/out")) }}') +expected: + output: + - LIST:3 + - STR:5 + - RANGE:5 + - PATH:8 diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--list-from-range-expr.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--list-from-range-expr.test.yaml new file mode 100644 index 0000000..9e8f69b --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--list-from-range-expr.test.yaml @@ -0,0 +1,28 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'SIMPLE:{{ list(range_expr("1-3")) }}') + print(r'STEP:{{ list(range_expr("1-9:2")) }}') + print(r'DESC:{{ list(range_expr("5-1:-1")) }}') + print(r'VALUES:{{ list(range_expr("2,6,9")) }}') + print(r'MIXED:{{ list(range_expr("1-3,10,20-22")) }}') + print(r'FROM_LIST:{{ list(range_expr([1, 2, 3])) }}') +expected: + output: + - "SIMPLE:[1, 2, 3]" + - "STEP:[1, 3, 5, 7, 9]" + - "DESC:[5, 4, 3, 2, 1]" + - "VALUES:[2, 6, 9]" + - "MIXED:[1, 2, 3, 10, 20, 21, 22]" + - "FROM_LIST:[1, 2, 3]" diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--range-expr-conversion.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--range-expr-conversion.test.yaml new file mode 100644 index 0000000..2fb6bfe --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--range-expr-conversion.test.yaml @@ -0,0 +1,30 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'STR:{{ range_expr("1-3") }}') + print(r'LIST:{{ range_expr([1, 2, 3]) }}') + print(r'STEP:{{ range_expr("1-9:2") }}') + print(r'DESC:{{ range_expr("5-1:-1") }}') + print(r'VALUES:{{ range_expr("2,6,9") }}') + print(r'MIXED:{{ range_expr("1-3,10,20-22") }}') + print(r'COMPLEX:{{ range_expr("100,-1--2:-1,3-5,9,8,7,12") }}') +expected: + output: + - "STR:1-3" + - "LIST:1-3" + - "STEP:1-9:2" + - "DESC:1-5" + - "VALUES:2,6,9" + - "MIXED:1-3,10,20-22" + - "COMPLEX:-2,-1,3-5,7-9,12,100" diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--range-expr-from-empty-list.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--range-expr-from-empty-list.invalid.test.yaml new file mode 100644 index 0000000..9e08dcd --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--range-expr-from-empty-list.invalid.test.yaml @@ -0,0 +1,14 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - print(r'{{ range_expr([]) }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--range-expr-from-empty-string.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--range-expr-from-empty-string.invalid.test.yaml new file mode 100644 index 0000000..473730f --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--range-expr-from-empty-string.invalid.test.yaml @@ -0,0 +1,17 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - >- + print(r'{{ range_expr("") }}') +expected: + output: [] diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--string-conversion.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--string-conversion.test.yaml new file mode 100644 index 0000000..6036862 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.1--string-conversion.test.yaml @@ -0,0 +1,37 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + # TEMPLATE scope for cross-platform POSIX path semantics (Expression Language §2.3.2) + let: + - 'from_path = string(path("/mnt/out"))' + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'INT:{{ string(42) }}') + print(r'FLOAT:{{ string(3.14) }}') + print(r'BOOL_T:{{ string(true) }}') + print(r'BOOL_F:{{ string(false) }}') + print(r'STR:{{ string("hello") }}') + print(r'NULL:{{ string(null) }}') + print(r'PATH:{{ from_path }}') + print(r'RANGE:{{ string(range_expr("1-5")) }}') + print(r'LIST:{{ string([1, 2, 3]) }}') +expected: + output: + - INT:42 + - FLOAT:3.14 + - BOOL_T:true + - BOOL_F:false + - STR:hello + - NULL:null + - PATH:/mnt/out + - RANGE:1-5 + - "LIST:[1, 2, 3]" diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--abs.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--abs.test.yaml new file mode 100644 index 0000000..e6bacc4 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--abs.test.yaml @@ -0,0 +1,22 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'INT:{{ abs(-5) }}') + print(r'FLOAT:{{ abs(-3.14) }}') + print(r'POS:{{ abs(5) }}') +expected: + output: + - INT:5 + - FLOAT:3.14 + - POS:5 diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--floor-ceil.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--floor-ceil.test.yaml new file mode 100644 index 0000000..b1b64f8 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--floor-ceil.test.yaml @@ -0,0 +1,32 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'FLOOR:{{ floor(3.7) }}') + print(r'CEIL:{{ ceil(3.2) }}') + print(r'FLOOR_NEG:{{ floor(-3.2) }}') + print(r'CEIL_NEG:{{ ceil(-3.7) }}') + print(r'FLOOR_INT:{{ floor(5) }}') + print(r'CEIL_INT:{{ ceil(5) }}') + print(r'FLOOR_EXACT:{{ floor(4.0) }}') + print(r'CEIL_EXACT:{{ ceil(4.0) }}') +expected: + output: + - FLOOR:3 + - CEIL:4 + - FLOOR_NEG:-4 + - CEIL_NEG:-3 + - FLOOR_INT:5 + - CEIL_INT:5 + - FLOOR_EXACT:4 + - CEIL_EXACT:4 diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--max-empty-list.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--max-empty-list.invalid.test.yaml new file mode 100644 index 0000000..c2169bf --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--max-empty-list.invalid.test.yaml @@ -0,0 +1,13 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - print(r'{{ max([]) }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--max.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--max.test.yaml new file mode 100644 index 0000000..6196d23 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--max.test.yaml @@ -0,0 +1,28 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'TWO:{{ max(3, 1) }}') + print(r'THREE:{{ max(1, 3, 2) }}') + print(r'LIST:{{ max([5, 2, 8]) }}') + print(r'RANGE:{{ max(range_expr("3-7")) }}') + print(r'FLOAT:{{ max(1.5, 2.5) }}') + print(r'MIXED:{{ max(1, 2.5) }}') +expected: + output: + - TWO:3 + - THREE:3 + - LIST:8 + - RANGE:7 + - FLOAT:2.5 + - MIXED:2.5 diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--min-empty-list.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--min-empty-list.invalid.test.yaml new file mode 100644 index 0000000..20ca558 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--min-empty-list.invalid.test.yaml @@ -0,0 +1,14 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - print(r'{{ min([]) }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--min.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--min.test.yaml new file mode 100644 index 0000000..8a65bc5 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--min.test.yaml @@ -0,0 +1,26 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'TWO:{{ min(3, 1) }}') + print(r'THREE:{{ min(3, 1, 2) }}') + print(r'LIST:{{ min([5, 2, 8]) }}') + print(r'RANGE:{{ min(range_expr("3-7")) }}') + print(r'MIXED:{{ min(1, 2.5) }}') +expected: + output: + - TWO:1 + - THREE:1 + - LIST:2 + - RANGE:3 + - MIXED:1.0 diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--round.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--round.test.yaml new file mode 100644 index 0000000..7fad5df --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--round.test.yaml @@ -0,0 +1,36 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'HALF_EVEN:{{ round(2.5) }}') + print(r'HALF_EVEN2:{{ round(3.5) }}') + print(r'NEG_HALF_EVEN:{{ round(-2.5) }}') + print(r'NEG_HALF_EVEN2:{{ round(-3.5) }}') + print(r'DIGITS:{{ round(3.14159, 2) }}') + print(r'TRAILING:{{ round(3.495, 2) }}') + print(r'TENS:{{ round(1234.5, -1) }}') + print(r'HUNDREDS:{{ round(1250.0, -2) }}') + print(r'ZERO_NDIGITS:{{ round(3.7, 0) }}') + print(r'INT_NDIGITS:{{ round(5, 2) }}') +expected: + output: + - HALF_EVEN:2 + - HALF_EVEN2:4 + - NEG_HALF_EVEN:-2 + - NEG_HALF_EVEN2:-4 + - DIGITS:3.14 + - TRAILING:3.50 + - TENS:1230 + - HUNDREDS:1200 + - ZERO_NDIGITS:4 + - INT_NDIGITS:5 diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--sum.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--sum.test.yaml new file mode 100644 index 0000000..ab08618 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.2--sum.test.yaml @@ -0,0 +1,28 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'INT_LIST:{{ sum([1, 2, 3]) }}') + print(r'EMPTY:{{ sum([]) }}') + print(r'RANGE:{{ sum(range_expr("1-5")) }}') + print(r'FLOAT_LIST:{{ sum([1.5, 2.5, 3.0]) }}') + print(r'SINGLE:{{ sum([42]) }}') + print(r'NEGATIVE:{{ sum([-1, -2, 3]) }}') +expected: + output: + - INT_LIST:6 + - EMPTY:0 + - RANGE:15 + - FLOAT_LIST:7.0 + - SINGLE:42 + - NEGATIVE:0 diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.3--any-all.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.3--any-all.test.yaml new file mode 100644 index 0000000..bf02879 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.3--any-all.test.yaml @@ -0,0 +1,28 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'ANY_T:{{ any([false, true]) }}') + print(r'ANY_F:{{ any([false, false]) }}') + print(r'ANY_EMPTY:{{ any([]) }}') + print(r'ALL_T:{{ all([true, true]) }}') + print(r'ALL_F:{{ all([true, false]) }}') + print(r'ALL_EMPTY:{{ all([]) }}') +expected: + output: + - ANY_T:true + - ANY_F:false + - ANY_EMPTY:false + - ALL_T:true + - ALL_F:false + - ALL_EMPTY:true diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.3--flatten.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.3--flatten.test.yaml new file mode 100644 index 0000000..ccc0ccb --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.3--flatten.test.yaml @@ -0,0 +1,35 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + # TEMPLATE scope for cross-platform POSIX path semantics (Expression Language §2.3.2) + let: + - 'paths = flatten([[path("/a"), path("/b")], [path("/c")]])' + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'INT:{{ flatten([[1, 2], [3]]) }}') + print(r'STR:{{ flatten([["a", "b"], ["c"]]) }}') + print(r'PATH:{{ paths }}') + print(r'SINGLES:{{ flatten([[1], [2], [3]]) }}') + print(r'MIXED_LEN:{{ flatten([[], [1], [2, 3]]) }}') + print(r'EMPTY:{{ flatten([]) }}') + print(r'FLAT_INT:{{ flatten([1, 2, 3]) }}') + print(r'FLAT_STR:{{ flatten(["a", "b"]) }}') +expected: + output: + - "INT:[1, 2, 3]" + - 'STR:["a", "b", "c"]' + - 'PATH:["/a", "/b", "/c"]' + - "SINGLES:[1, 2, 3]" + - "MIXED_LEN:[1, 2, 3]" + - "EMPTY:[]" + - "FLAT_INT:[1, 2, 3]" + - 'FLAT_STR:["a", "b"]' diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.3--range.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.3--range.test.yaml new file mode 100644 index 0000000..f7243a6 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.3--range.test.yaml @@ -0,0 +1,28 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'STOP:{{ range(5) }}') + print(r'START_STOP:{{ range(1, 5) }}') + print(r'STEP:{{ range(0, 10, 2) }}') + print(r'NEG:{{ range(5, 0, -1) }}') + print(r'EMPTY:{{ range(0) }}') + print(r'EMPTY2:{{ range(5, 5) }}') +expected: + output: + - "STOP:[0, 1, 2, 3, 4]" + - "START_STOP:[1, 2, 3, 4]" + - "STEP:[0, 2, 4, 6, 8]" + - "NEG:[5, 4, 3, 2, 1]" + - "EMPTY:[]" + - "EMPTY2:[]" diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.3--reversed.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.3--reversed.test.yaml new file mode 100644 index 0000000..9b440f0 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.3--reversed.test.yaml @@ -0,0 +1,26 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'INT:{{ reversed([3, 1, 2]) }}') + print(r'STR:{{ reversed(["c", "a", "b"]) }}') + print(r'SINGLE:{{ reversed([42]) }}') + print(r'NESTED:{{ reversed([[1], [], [1, 3, 5]]) }}') + print(r'EMPTY:{{ reversed([]) }}') +expected: + output: + - "INT:[2, 1, 3]" + - 'STR:["b", "a", "c"]' + - "SINGLE:[42]" + - "NESTED:[[1, 3, 5], [], [1]]" + - "EMPTY:[]" diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.3--sorted.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.3--sorted.test.yaml new file mode 100644 index 0000000..5e74d6e --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.3--sorted.test.yaml @@ -0,0 +1,28 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'INT:{{ sorted([3, 1, 2]) }}') + print(r'STR:{{ sorted(["b", "a", "c"]) }}') + print(r'ALREADY:{{ sorted([1, 2, 3]) }}') + print(r'EMPTY:{{ sorted([]) }}') + print(r'FLOAT:{{ sorted([2.5, 1.0, 3.5]) }}') + print(r'NESTED:{{ sorted([[1, 3], [1], [1, 2], []]) }}') +expected: + output: + - "INT:[1, 2, 3]" + - 'STR:["a", "b", "c"]' + - "ALREADY:[1, 2, 3]" + - "EMPTY:[]" + - "FLOAT:[1.0, 2.5, 3.5]" + - "NESTED:[[], [1], [1, 2], [1, 3]]" diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--case-transforms.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--case-transforms.test.yaml new file mode 100644 index 0000000..f87317e --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--case-transforms.test.yaml @@ -0,0 +1,24 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'UPPER:{{ upper("hello") }}') + print(r'LOWER:{{ lower("HELLO") }}') + print(r'CAP:{{ capitalize("hello world") }}') + print(r'TITLE:{{ title("hello world") }}') +expected: + output: + - UPPER:HELLO + - LOWER:hello + - CAP:Hello world + - TITLE:Hello World diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--find.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--find.test.yaml new file mode 100644 index 0000000..0de650a --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--find.test.yaml @@ -0,0 +1,22 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'FOUND:{{ find("hello world", "world") }}') + print(r'NOT_FOUND:{{ find("hello world", "xyz") }}') + print(r'START:{{ find("hello", "hel") }}') +expected: + output: + - FOUND:6 + - NOT_FOUND:-1 + - START:0 diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--padding.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--padding.test.yaml new file mode 100644 index 0000000..eb2f898 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--padding.test.yaml @@ -0,0 +1,36 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'ZFILL:{{ zfill("42", 5) }}') + print(r'ZFILL_INT:{{ zfill(7, 4) }}') + print(r'ZFILL_METHOD:{{ "42".zfill(5) }}') + print(r'ZFILL_INT_METHOD:{{ (7).zfill(4) }}') + print(r'ZFILL_NEG_INT:{{ zfill(-1, 3) }}') + print(r'ZFILL_NEG_STR:{{ zfill("-10", 4) }}') + print(r'ZFILL_PLUS:{{ zfill("+5", 4) }}') + print(r'LJUST:[{{ ljust("hi", 6) }}]') + print(r'RJUST:[{{ rjust("hi", 6) }}]') + print(r'CENTER:[{{ center("hi", 6) }}]') +expected: + output: + - ZFILL:00042 + - ZFILL_INT:0007 + - ZFILL_METHOD:00042 + - ZFILL_INT_METHOD:0007 + - ZFILL_NEG_INT:-01 + - ZFILL_NEG_STR:-010 + - ZFILL_PLUS:+005 + - "LJUST:[hi ]" + - "RJUST:[ hi]" + - "CENTER:[ hi ]" diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--removeprefix-removesuffix.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--removeprefix-removesuffix.test.yaml new file mode 100644 index 0000000..0080291 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--removeprefix-removesuffix.test.yaml @@ -0,0 +1,24 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'RP_MATCH:{{ removeprefix("TestHook", "Test") }}') + print(r'RP_NONE:{{ removeprefix("TestHook", "Xyz") }}') + print(r'RS_MATCH:{{ removesuffix("render.exr", ".exr") }}') + print(r'RS_NONE:{{ removesuffix("render.exr", ".png") }}') +expected: + output: + - RP_MATCH:Hook + - RP_NONE:TestHook + - RS_MATCH:render + - RS_NONE:render.exr diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--replace.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--replace.test.yaml new file mode 100644 index 0000000..dec6718 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--replace.test.yaml @@ -0,0 +1,20 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'MATCH:{{ replace("hello", "l", "r") }}') + print(r'NONE:{{ replace("hello", "xyz", "r") }}') +expected: + output: + - MATCH:herro + - NONE:hello diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--search-test.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--search-test.test.yaml new file mode 100644 index 0000000..491a4de --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--search-test.test.yaml @@ -0,0 +1,28 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'SW_T:{{ startswith("hello", "hel") }}') + print(r'SW_F:{{ startswith("hello", "world") }}') + print(r'EW_T:{{ endswith("hello", "llo") }}') + print(r'EW_F:{{ endswith("hello", "world") }}') + print(r'COUNT:{{ count("banana", "an") }}') + print(r'COUNT_ZERO:{{ count("hello", "xyz") }}') +expected: + output: + - SW_T:true + - SW_F:false + - EW_T:true + - EW_F:false + - COUNT:2 + - COUNT_ZERO:0 diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--split-join.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--split-join.test.yaml new file mode 100644 index 0000000..dbe81c3 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--split-join.test.yaml @@ -0,0 +1,28 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + # TEMPLATE scope for cross-platform POSIX path semantics (Expression Language §2.3.2) + let: + - 'paths = [path("/a"), path("/b"), path("/c")]' + - 'path_join = join(paths, ":")' + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'SPLIT:{{ split("a,b,c", ",") }}') + print(r'JOIN:{{ join(["a", "b", "c"], "-") }}') + print(r'PATH_JOIN:{{ path_join }}') + print(r'EMPTY_JOIN:{{ len(join([], ",")) }} chars') +expected: + output: + - 'SPLIT:["a", "b", "c"]' + - JOIN:a-b-c + - "PATH_JOIN:/a:/b:/c" + - "EMPTY_JOIN:0 chars" diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--strip.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--strip.test.yaml new file mode 100644 index 0000000..5b163f9 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.4--strip.test.yaml @@ -0,0 +1,22 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'STRIP:[{{ strip(" hi ") }}]') + print(r'LSTRIP:[{{ lstrip(" hi ") }}]') + print(r'RSTRIP:[{{ rstrip(" hi ") }}]') +expected: + output: + - STRIP:[hi] + - "LSTRIP:[hi ]" + - "RSTRIP:[ hi]" diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-backreference.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-backreference.invalid.test.yaml new file mode 100644 index 0000000..5f4ae76 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-backreference.invalid.test.yaml @@ -0,0 +1,14 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - print(r'{{ re_search("abc", r"(a)\1") }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-backslash-Z.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-backslash-Z.invalid.test.yaml new file mode 100644 index 0000000..c0f463e --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-backslash-Z.invalid.test.yaml @@ -0,0 +1,14 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - print(r'{{ re_search("hello", r"llo\Z") }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-backslash-z.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-backslash-z.invalid.test.yaml new file mode 100644 index 0000000..7d4b6e9 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-backslash-z.invalid.test.yaml @@ -0,0 +1,14 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - print(r'{{ re_search("hello", r"llo\z") }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-escape.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-escape.test.yaml new file mode 100644 index 0000000..c81e9c6 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-escape.test.yaml @@ -0,0 +1,20 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'DOT:{{ re_escape("file.exr") }}') + print(r'BRACKETS:{{ re_escape("file[1].txt") }}') +expected: + output: + - DOT:file\.exr + - BRACKETS:file\[1\]\.txt diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-findall.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-findall.test.yaml new file mode 100644 index 0000000..ed46c1a --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-findall.test.yaml @@ -0,0 +1,24 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'PLAIN:{{ re_findall("a1b2c3", r"\d+") }}') + print(r'ONE_GROUP:{{ re_findall("shot010_shot020", r"shot(\d+)") }}') + print(r'MULTI_GROUP:{{ re_findall("v1.2.3 and v4.5.6", r"v(\d+)\.(\d+)\.(\d+)") }}') + print(r'NO_MATCH:{{ re_findall("hello", r"\d+") }}') +expected: + output: + - 'PLAIN:["1", "2", "3"]' + - 'ONE_GROUP:["010", "020"]' + - 'MULTI_GROUP:[["1", "2", "3"], ["4", "5", "6"]]' + - "NO_MATCH:[]" diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-lookahead.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-lookahead.invalid.test.yaml new file mode 100644 index 0000000..5ce4320 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-lookahead.invalid.test.yaml @@ -0,0 +1,14 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - print(r'{{ re_search("foobar", r"foo(?=bar)") }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-lookbehind.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-lookbehind.invalid.test.yaml new file mode 100644 index 0000000..bf925f6 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-lookbehind.invalid.test.yaml @@ -0,0 +1,14 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - print(r'{{ re_search("foobar", r"(?<=foo)bar") }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-match.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-match.test.yaml new file mode 100644 index 0000000..c754755 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-match.test.yaml @@ -0,0 +1,24 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'ONE_GROUP:{{ re_match("v042_final", r"v(\d+)") }}') + print(r'NO_GROUPS:{{ re_match("hello123", r"hello") }}') + print(r'MULTI:{{ re_match("shot010_v003", r"shot(\d+)_v(\d+)") }}') + print(r'NONE:{{ re_match("asset_v042", r"v(\d+)") == null }}') +expected: + output: + - 'ONE_GROUP:["v042", "042"]' + - 'NO_GROUPS:["hello"]' + - 'MULTI:["shot010_v003", "010", "003"]' + - NONE:true diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-named-backref.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-named-backref.invalid.test.yaml new file mode 100644 index 0000000..d139ce5 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-named-backref.invalid.test.yaml @@ -0,0 +1,17 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - >- + print(r'{{ re_search("abc", "(?P=name)") }}') +expected: + output: [] diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-portable-syntax.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-portable-syntax.test.yaml new file mode 100644 index 0000000..3fac41d --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-portable-syntax.test.yaml @@ -0,0 +1,30 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'DOLLAR:{{ re_search("hello", r"llo$") != null }}') + print(r'CARET:{{ re_match("hello", r"^hel") != null }}') + print(r'WORD_BOUND:{{ re_findall("cat catalog", r"\bcat\b") }}') + print(r'NON_CAPTURE:{{ re_search("abc", r"(?:a)(b)") }}') + print(r'ALTERNATION:{{ re_findall("cat bat hat", r"[cbh]at") }}') + print(r'QUANTIFIER:{{ re_search("aab", r"a{2}b")[0] }}') + print(r'NON_GREEDY:{{ re_search("", r"<.*?>")[0] }}') +expected: + output: + - DOLLAR:true + - CARET:true + - 'WORD_BOUND:["cat"]' + - 'NON_CAPTURE:["ab", "b"]' + - 'ALTERNATION:["cat", "bat", "hat"]' + - QUANTIFIER:aab + - "NON_GREEDY:" diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-replace.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-replace.test.yaml new file mode 100644 index 0000000..43ea450 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-replace.test.yaml @@ -0,0 +1,22 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'SINGLE:{{ re_replace("frame_001", r"\d+", "002") }}') + print(r'MULTI:{{ re_replace("a1b2c3", r"\d", "X") }}') + print(r'NO_MATCH:{{ re_replace("hello", r"\d+", "X") }}') +expected: + output: + - SINGLE:frame_002 + - MULTI:aXbXcX + - NO_MATCH:hello diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-search.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-search.test.yaml new file mode 100644 index 0000000..5babd24 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.5--re-search.test.yaml @@ -0,0 +1,28 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'FULL:{{ re_search("asset_v042_final.abc", r"_v(\d+)")[0] }}') + print(r'GROUP:{{ re_search("asset_v042_final.abc", r"_v(\d+)")[1] }}') + print(r'NO_GROUPS:{{ re_search("hello123", r"\d+")[0] }}') + print(r'MULTI:{{ re_search("shot010_v003", r"shot(\d+)_v(\d+)") }}') + print(r'NONE:{{ re_search("no match", r"frame") == null }}') + print(r'ANCHOR:{{ re_search("render_001.exr", r"_\d+\.exr$") != null }}') +expected: + output: + - FULL:_v042 + - GROUP:042 + - NO_GROUPS:123 + - 'MULTI:["shot010_v003", "010", "003"]' + - NONE:true + - ANCHOR:true diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-cmd.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-cmd.test.yaml new file mode 100644 index 0000000..c149a8e --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-cmd.test.yaml @@ -0,0 +1,51 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + let: + - 'simple = repr_cmd("hello")' + - 'amp = repr_cmd("a & b")' + - 'pipe = repr_cmd("x | y")' + - 'caret = repr_cmd("a ^ b")' + - 'angles = repr_cmd("a < b > c")' + - "dquote = repr_cmd(\"say \\\"hi\\\"\")" + - 'parens = repr_cmd("(foo)")' + - 'pct = repr_cmd("100%")' + - 'bang = repr_cmd("hello!")' + - 'spaces = repr_cmd("hello world")' + - "list_val = repr_cmd([\"echo\", \"a & b\"])" + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'SIMPLE:{{ simple }}') + print(r'AMP:{{ amp }}') + print(r'PIPE:{{ pipe }}') + print(r'CARET:{{ caret }}') + print(r'ANGLES:{{ angles }}') + print(r'DQUOTE:{{ dquote }}') + print(r'PARENS:{{ parens }}') + print(r'PCT:{{ pct }}') + + print(r'BANG:{{ bang }}') + print(r'SPACES:{{ spaces }}') + print(r'LIST:{{ list_val }}') +expected: + output: + - SIMPLE:hello + - "AMP:\"a & b\"" + - "PIPE:\"x | y\"" + - "CARET:\"a ^^ b\"" + - "ANGLES:\"a < b > c\"" + - "DQUOTE:\"say ^\"hi^\"\"" + - "PARENS:\"(foo)\"" + - "PCT:\"100%%\"" + - "BANG:\"hello!\"" + - "SPACES:\"hello world\"" + - "LIST:echo \"a & b\"" diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-json.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-json.test.yaml new file mode 100644 index 0000000..feea679 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-json.test.yaml @@ -0,0 +1,41 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + let: + - 'str_val = repr_json("hello")' + - 'int_val = repr_json(42)' + - 'float_val = repr_json(3.14)' + - 'bool_t = repr_json(true)' + - 'bool_f = repr_json(false)' + - 'null_val = repr_json(null)' + - 'list_val = repr_json([1, 2, 3])' + - "range_val = repr_json(range_expr(\"1-10\"))" + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'STR:{{ str_val }}') + print(r'INT:{{ int_val }}') + print(r'FLOAT:{{ float_val }}') + print(r'BOOL_T:{{ bool_t }}') + print(r'BOOL_F:{{ bool_f }}') + print(r'NULL:{{ null_val }}') + print(r'LIST:{{ list_val }}') + print(r'RANGE:{{ range_val }}') +expected: + output: + - 'STR:"hello"' + - INT:42 + - FLOAT:3.14 + - BOOL_T:true + - BOOL_F:false + - "NULL:null" + - "LIST:[1, 2, 3]" + - 'RANGE:"1-10"' diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-pwsh.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-pwsh.test.yaml new file mode 100644 index 0000000..bf1c155 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-pwsh.test.yaml @@ -0,0 +1,44 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + let: + - 'str_val = repr_pwsh("hello")' + - "quote_val = repr_pwsh(\"it's\")" + - 'bool_t = repr_pwsh(true)' + - 'bool_f = repr_pwsh(false)' + - 'int_val = repr_pwsh(42)' + - 'float_val = repr_pwsh(3.14)' + - 'path_val = repr_pwsh(path("/tmp/out"))' + - "range_val = repr_pwsh(range_expr(\"1-10\"))" + - "list_val = repr_pwsh([\"a\", \"b\"])" + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r"STR:{{ str_val }}") + print(r"QUOTE:{{ quote_val }}") + print(r"BOOL_T:{{ bool_t }}") + print(r"BOOL_F:{{ bool_f }}") + print(r"INT:{{ int_val }}") + print(r"FLOAT:{{ float_val }}") + print(r"PATH:{{ path_val }}") + print(r"RANGE:{{ range_val }}") + print(r"LIST:{{ list_val }}") +expected: + output: + - "STR:'hello'" + - "QUOTE:'it''s'" + - BOOL_T:$true + - BOOL_F:$false + - INT:42 + - FLOAT:3.14 + - "PATH:'/tmp/out'" + - "RANGE:'1-10'" + - "LIST:@('a', 'b')" diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-py.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-py.test.yaml new file mode 100644 index 0000000..2c35370 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-py.test.yaml @@ -0,0 +1,44 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + let: + - 'str_val = repr_py("hello")' + - 'int_val = repr_py(42)' + - 'float_val = repr_py(3.14)' + - 'bool_t = repr_py(true)' + - 'bool_f = repr_py(false)' + - 'null_val = repr_py(null)' + - 'list_val = repr_py([1, 2])' + - 'path_val = repr_py(path("/tmp/out"))' + - "range_val = repr_py(range_expr(\"1-10\"))" + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r"STR:{{ str_val }}") + print(r"INT:{{ int_val }}") + print(r"FLOAT:{{ float_val }}") + print(r"BOOL_T:{{ bool_t }}") + print(r"BOOL_F:{{ bool_f }}") + print(r"NULL:{{ null_val }}") + print(r"LIST:{{ list_val }}") + print(r"PATH:{{ path_val }}") + print(r"RANGE:{{ range_val }}") +expected: + output: + - "STR:'hello'" + - INT:42 + - FLOAT:3.14 + - BOOL_T:True + - BOOL_F:False + - NULL:None + - "LIST:[1, 2]" + - "PATH:'/tmp/out'" + - "RANGE:'1-10'" diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-sh.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-sh.test.yaml new file mode 100644 index 0000000..e2524fc --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.2.6--repr-sh.test.yaml @@ -0,0 +1,35 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + let: + - 'simple = repr_sh("hello")' + - 'spaces = repr_sh("hello world")' + - "list_val = repr_sh([\"echo\", \"hello world\"])" + - "squote_len = len(repr_sh(\"it's\"))" + - 'empty = repr_sh("")' + - 'path_val = repr_sh(path("/tmp/out"))' + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'SIMPLE:{{ simple }}') + print("SPACES:{{ spaces }}") + print("LIST:{{ list_val }}") + print(r'SQUOTE_LEN:{{ squote_len }}') + print("EMPTY:{{ empty }}") + print("PATH:{{ path_val }}") +expected: + output: + - SIMPLE:hello + - "SPACES:'hello world'" + - "LIST:echo 'hello world'" + - SQUOTE_LEN:10 + - "EMPTY:''" + - "PATH:/tmp/out" diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.3.1--path-compound-suffix.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.3.1--path-compound-suffix.test.yaml new file mode 100644 index 0000000..d0f35a1 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.3.1--path-compound-suffix.test.yaml @@ -0,0 +1,28 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + # TEMPLATE scope for cross-platform POSIX path semantics (Expression Language §2.3.2) + let: + - 'p = path("/data/backup.tar.gz")' + - 'psuffix = p.suffix' + - 'psuffixes = p.suffixes' + - 'pstem = p.stem' + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'SUFFIX:{{ psuffix }}') + print(r'SUFFIXES:{{ psuffixes }}') + print(r'STEM:{{ pstem }}') +expected: + output: + - SUFFIX:.gz + - 'SUFFIXES:[".tar", ".gz"]' + - STEM:backup.tar diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.3.1--path-properties.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.3.1--path-properties.test.yaml new file mode 100644 index 0000000..c5d7a46 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.3.1--path-properties.test.yaml @@ -0,0 +1,53 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + let: + - 'p = path("/mnt/renders/scene.exr")' + - 'pname = p.name' + - 'pstem = p.stem' + - 'psuffix = p.suffix' + - 'psuffixes = p.suffixes' + - 'pparent = p.parent' + - 'pparts = p.parts' + - 'noext = path("/mnt/renders/Makefile")' + - 'noext_suffix = noext.suffix' + - 'noext_suffixes = noext.suffixes' + - 'root = path("/")' + - 'root_name = root.name' + - 'root_parent = root.parent' + - 'root_parts = root.parts' + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'NAME:{{ pname }}') + print(r'STEM:{{ pstem }}') + print(r'SUFFIX:{{ psuffix }}') + print(r'SUFFIXES:{{ psuffixes }}') + print(r'PARENT:{{ pparent }}') + print(r'PARTS:{{ pparts }}') + print(r'NOEXT_SUFFIX:{{ noext_suffix }}') + print(r'NOEXT_SUFFIXES:{{ noext_suffixes }}') + print(r'ROOT_NAME:{{ len(root_name) }} chars') + print(r'ROOT_PARENT:{{ root_parent }}') + print(r'ROOT_PARTS:{{ root_parts }}') +expected: + output: + - NAME:scene.exr + - STEM:scene + - SUFFIX:.exr + - 'SUFFIXES:[".exr"]' + - PARENT:/mnt/renders + - 'PARTS:["/", "mnt", "renders", "scene.exr"]' + - "NOEXT_SUFFIX:" + - "NOEXT_SUFFIXES:[]" + - "ROOT_NAME:0 chars" + - "ROOT_PARENT:/" + - 'ROOT_PARTS:["/"]' diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.3.2--apply-path-mapping.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.3.2--apply-path-mapping.test.yaml new file mode 100644 index 0000000..ae1a5d8 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.3.2--apply-path-mapping.test.yaml @@ -0,0 +1,36 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'POSIX_MAPPED:{{ apply_path_mapping("/mnt/shared/project/scene.exr") }}') + print(r'POSIX_UNMAPPED:{{ apply_path_mapping("/other/path/file.txt") }}') + print(r'WIN_MAPPED:{{ apply_path_mapping("D:\\assets\\textures\\wood.png") }}') + print(r'WIN_UNMAPPED:{{ apply_path_mapping("E:\\other\\file.txt") }}') +pathMapping: + - source_path_format: POSIX + source_path: /mnt/shared + destination_path: /local/cache + - source_path_format: WINDOWS + source_path: "D:\\assets" + destination_path: /data/assets +expected: + output_posix: + - POSIX_MAPPED:/local/cache/project/scene.exr + - POSIX_UNMAPPED:/other/path/file.txt + - WIN_MAPPED:/data/assets/textures/wood.png + - "WIN_UNMAPPED:E:\\other\\file.txt" + output_windows: + - POSIX_MAPPED:\local\cache\project\scene.exr + - POSIX_UNMAPPED:\other\path\file.txt + - WIN_MAPPED:\data\assets\textures\wood.png + - WIN_UNMAPPED:E:\other\file.txt diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.3.2--as-posix.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.3.2--as-posix.test.yaml new file mode 100644 index 0000000..9210642 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.3.2--as-posix.test.yaml @@ -0,0 +1,23 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + let: + - 'posix = as_posix(path("/mnt/renders/scene.exr"))' + - 'relative = as_posix(path("a/b/c"))' + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'ABS:{{ posix }}') + print(r'REL:{{ relative }}') +expected: + output: + - ABS:/mnt/renders/scene.exr + - REL:a/b/c diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.3.2--path-construction.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.3.2--path-construction.test.yaml new file mode 100644 index 0000000..b224158 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.3.2--path-construction.test.yaml @@ -0,0 +1,26 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + let: + - 'from_str = path("/a/b")' + - 'from_parts = path(["a", "b", "c"])' + - 'from_abs_parts = path(["/", "usr", "local"])' + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'STR:{{ from_str }}') + print(r'PARTS:{{ from_parts }}') + print(r'ABS_PARTS:{{ from_abs_parts }}') +expected: + output: + - STR:/a/b + - PARTS:a/b/c + - ABS_PARTS:/usr/local diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.3.2--path-in-submission-context.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.3.2--path-in-submission-context.test.yaml new file mode 100644 index 0000000..7131a39 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.3.2--path-in-submission-context.test.yaml @@ -0,0 +1,30 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + parameterDefinitions: + - name: Dir + type: STRING + default: renders + steps: + - name: Step1 + let: + - 'p = path("base") / Param.Dir / "file.exr"' + - 'p_str = string(p)' + parameterSpace: + taskParameterDefinitions: + - name: Item + type: STRING + range: "{{ [p_str] }}" + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'ITEM:{{ Task.Param.Item }}') +expected: + output: + - "ITEM:base/renders/file.exr" diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.3.2--with-name-stem-suffix.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.3.2--with-name-stem-suffix.test.yaml new file mode 100644 index 0000000..da552fe --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.3.2--with-name-stem-suffix.test.yaml @@ -0,0 +1,31 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + # TEMPLATE scope for cross-platform POSIX path semantics (Expression Language §2.3.2) + let: + - 'p = path("/renders/scene.exr")' + - 'wname = with_name(p, "other.png")' + - 'wstem = with_stem(p, "final")' + - 'wsuffix = with_suffix(p, ".png")' + - 'nosuffix = with_suffix(p, "")' + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'NAME:{{ wname }}') + print(r'STEM:{{ wstem }}') + print(r'SUFFIX:{{ wsuffix }}') + print(r'NOSUFFIX:{{ nosuffix }}') +expected: + output: + - NAME:/renders/other.png + - STEM:/renders/final.exr + - SUFFIX:/renders/scene.png + - NOSUFFIX:/renders/scene diff --git a/conformance-tests/2023-09/EXPR/jobs/expr2.3.2--with-number.test.yaml b/conformance-tests/2023-09/EXPR/jobs/expr2.3.2--with-number.test.yaml new file mode 100644 index 0000000..65b077f --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/expr2.3.2--with-number.test.yaml @@ -0,0 +1,50 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + let: + - 'digits = with_number(path("/out/file_003.exr"), 72)' + - 'printf_bare = with_number(path("/out/file_%d.exr"), 72)' + - 'printf_pad = with_number(path("/out/file_%04d.exr"), 72)' + - 'hash4 = with_number(path("/out/file_####.exr"), 72)' + - 'hash6 = with_number(path("/out/file_######.exr"), 72)' + - 'overflow = with_number(path("/out/file_###.exr"), 10000)' + - 'no_pattern = with_number(path("/out/render.exr"), 72)' + - "str_variant = with_number(\"file_003.exr\", 72)" + - 'neg_digits = with_number(path("/out/file_003.exr"), -1)' + - 'neg_printf = with_number(path("/out/file_%04d.exr"), -1)' + - 'neg_hash = with_number(path("/out/file_####.exr"), -1)' + script: + actions: + onRun: + command: python + args: + - -c + - | + print(r'DIGITS:{{ digits }}') + print(r'PRINTF_BARE:{{ printf_bare }}') + print(r'PRINTF_PAD:{{ printf_pad }}') + print(r'HASH4:{{ hash4 }}') + print(r'HASH6:{{ hash6 }}') + print(r'OVERFLOW:{{ overflow }}') + print(r'NO_PATTERN:{{ no_pattern }}') + print(r'STR:{{ str_variant }}') + print(r'NEG_DIGITS:{{ neg_digits }}') + print(r'NEG_PRINTF:{{ neg_printf }}') + print(r'NEG_HASH:{{ neg_hash }}') +expected: + output: + - DIGITS:/out/file_072.exr + - PRINTF_BARE:/out/file_72.exr + - PRINTF_PAD:/out/file_0072.exr + - HASH4:/out/file_0072.exr + - HASH6:/out/file_000072.exr + - OVERFLOW:/out/file_10000.exr + - NO_PATTERN:/out/render_0072.exr + - STR:file_072.exr + - NEG_DIGITS:/out/file_-01.exr + - NEG_PRINTF:/out/file_-001.exr + - NEG_HASH:/out/file_-001.exr diff --git a/conformance-tests/2023-09/EXPR/jobs/fail--basic.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/fail--basic.invalid.test.yaml new file mode 100644 index 0000000..7abcc31 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/fail--basic.invalid.test.yaml @@ -0,0 +1,14 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - print(r'{{ fail('error message') }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/fail--in-conditional-triggers.invalid.test.yaml b/conformance-tests/2023-09/EXPR/jobs/fail--in-conditional-triggers.invalid.test.yaml new file mode 100644 index 0000000..ddcd441 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/fail--in-conditional-triggers.invalid.test.yaml @@ -0,0 +1,14 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - print(r'{{ (-1) if (-1) > 0 else fail('must be positive') }}') diff --git a/conformance-tests/2023-09/EXPR/jobs/fail--in-conditional.test.yaml b/conformance-tests/2023-09/EXPR/jobs/fail--in-conditional.test.yaml new file mode 100644 index 0000000..3072f98 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/fail--in-conditional.test.yaml @@ -0,0 +1,21 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + parameterDefinitions: + - name: X + type: INT + default: 5 + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - print(r'VAL:{{ Param.X if Param.X > 0 else fail("must be positive") }}') +expected: + output: + - VAL:5 diff --git a/conformance-tests/2023-09/EXPR/jobs/noreturn--conditional-type.test.yaml b/conformance-tests/2023-09/EXPR/jobs/noreturn--conditional-type.test.yaml new file mode 100644 index 0000000..c572623 --- /dev/null +++ b/conformance-tests/2023-09/EXPR/jobs/noreturn--conditional-type.test.yaml @@ -0,0 +1,28 @@ +template: + specificationVersion: jobtemplate-2023-09 + extensions: + - EXPR + name: TestJob + parameterDefinitions: + - name: X + type: INT + default: 5 + - name: Flag + type: BOOL + default: true + steps: + - name: Step1 + script: + actions: + onRun: + command: python + args: + - -c + - >- + print(r'INT:{{ (Param.X if Param.X > 0 else fail("bad")) + 10 }}') + + print(r'BOOL:{{ "yes" if (Param.Flag or fail("required")) else "no" }}') +expected: + output: + - "INT:15" + - "BOOL:yes" diff --git a/conformance-tests/EXPR_CONFORMANCE_PLAN.md b/conformance-tests/EXPR_CONFORMANCE_PLAN.md new file mode 100644 index 0000000..5ec79a8 --- /dev/null +++ b/conformance-tests/EXPR_CONFORMANCE_PLAN.md @@ -0,0 +1,335 @@ +# EXPR Conformance Test Plan + +## Overview + +This document plans the addition of EXPR extension conformance tests to the suite. +Tests live in `conformance-tests/2023-09/EXPR/` with three subdirectories: + +- `job_templates/` — Static template validation (pass/fail `openjd check`) +- `env_templates/` — Environment template validation +- `jobs/` — Job execution tests with expected output assertions + +Spec references: +- [Wiki Expression Language](../../wiki/2026-02-Expression-Language.md) — Language grammar, type system, evaluation semantics, function library +- [Wiki Template Schemas](../../wiki/2023-09-Template-Schemas.md) — Schema definitions for new parameter types (§2.9-2.16), let bindings, case-insensitive types +- [RFC 0005](../../rfcs/0005-expression-language.md) — Expression language design and rationale +- [RFC 0006](../../rfcs/0006-expression-function-library.md) — Operators and built-in functions +- [RFC 0007](../../rfcs/0007-extend-parameter-types.md) — BOOL, LIST[*], RANGE_EXPR types, case-insensitive type names + +### Naming Convention + +Following existing patterns, filenames encode the spec section they test: + +``` +--[.invalid][.test].yaml +``` + +EXPR tests reference two specifications: + +- **Template Schemas** (§2.9–2.16 parameter types, §3.6 let bindings, §7.3 format strings, §8 SimpleAction): + Use the standard `
--` prefix, e.g. `2.9--bool-param-default-true.yaml`, `3.6--let-step-level.yaml` +- **Expression Language** (§1.x grammar/types/evaluation, §2.x function library): + Use the `expr
--` prefix, e.g. `expr1.1--arithmetic-expr.yaml`, `expr2.2.4--upper.test.yaml` + +Tests that don't map to a specific section use a descriptive name without a section prefix +(e.g., `let--chained-bindings.test.yaml`, `fail--basic.invalid.test.yaml`). + +### Estimated Size + +- ~60-80 template validation tests (including let binding validation) +- ~80-120 job execution test files (consolidated; each contains multiple assertions) +- ~5-10 env template tests + +--- + +## Phase 1: Foundation (Template Validation) ✅ COMPLETE + +*Completed 2026-02-19. 114 tests, all passing. Includes all planned tests plus additional +reject tests for unsupported Python syntax (29 tests), list parameter constraint violation +tests (~40 tests), and let binding comprehension shadowing tests (5 tests).* + +Schema-level changes: extension activation, new parameter types, case-insensitive types. + +### 1A. Extension Activation + +| File | Description | +|------|-------------| +| `expr-extension-enabled.yaml` | Template with `extensions: [EXPR]` and a simple expression | +| `expr-extension-missing.invalid.yaml` | EXPR syntax used without EXPR in extensions list | +| `expr-with-other-extensions.yaml` | EXPR combined with TASK_CHUNKING | + +### 1B. New Parameter Types (Template Schemas §2.9–2.16) + +| File | Description | +|------|-------------| +| `2.9--bool-param-default-true.yaml` | BOOL param with `default: true` | +| `2.9--bool-param-default-false.yaml` | BOOL param with `default: false` | +| `2.9--bool-param-int-1.yaml` | BOOL param with `default: 1` (accepted as true) | +| `2.9--bool-param-int-0.yaml` | BOOL param with `default: 0` (accepted as false) | +| `2.9--bool-param-string-yes.yaml` | BOOL param with `default: "yes"` (accepted) | +| `2.9--bool-param-string-on.yaml` | BOOL param with `default: "on"` (accepted) | +| `2.9--bool-param-string-invalid.invalid.yaml` | BOOL param with `default: "maybe"` (rejected) | +| `2.9--bool-param-checkbox-ui.yaml` | BOOL param with CHECK_BOX control | +| `2.11--list-string-param.yaml` | LIST[STRING] with default and constraints | +| `2.11--list-string-param-item-constraints.yaml` | LIST[STRING] with item.allowedValues, item.minLength | +| `2.13--list-int-param.yaml` | LIST[INT] with default and item constraints | +| `2.14--list-float-param.yaml` | LIST[FLOAT] with default and item constraints | +| `2.12--list-path-param.yaml` | LIST[PATH] with objectType and dataFlow | +| `2.15--list-bool-param.yaml` | LIST[BOOL] with default | +| `2.16--list-list-int-param.yaml` | LIST[LIST[INT]] with nested item constraints | +| `2.11--list-param-minlength-violation.invalid.yaml` | List shorter than minLength | +| `2.11--list-param-maxlength-violation.invalid.yaml` | List longer than maxLength | +| `2.10--range-expr-param.yaml` | RANGE_EXPR param with valid default | +| `2.10--range-expr-param-invalid-default.invalid.yaml` | RANGE_EXPR with non-range default | + +### 1C. Case-Insensitive Type Names (Template Schemas §2) + +| File | Description | +|------|-------------| +| `2--type-lowercase-int.yaml` | `type: int` | +| `2--type-lowercase-string.yaml` | `type: string` | +| `2--type-lowercase-path.yaml` | `type: path` | +| `2--type-lowercase-float.yaml` | `type: float` | +| `2--type-mixedcase-bool.yaml` | `type: Bool` | +| `2--type-lowercase-list-string.yaml` | `type: list[string]` | + +### 1D. Basic Grammar Acceptance (Expression Language §1.1) + +| File | Description | +|------|-------------| +| `expr1.1--arithmetic-expr.yaml` | `{{ Param.X + 1 }}` in args | +| `expr1.1--conditional-expr.yaml` | `{{ 'a' if Param.Flag else 'b' }}` | +| `expr1.1--comparison-expr.yaml` | `{{ Param.X > 0 }}` | +| `expr1.1--list-literal-expr.yaml` | `{{ [1, 2, 3] }}` | +| `expr1.1--list-comprehension-expr.yaml` | `{{ [x for x in Param.Items] }}` | +| `expr1.1--function-call-expr.yaml` | `{{ len(Param.Name) }}` | +| `expr1.1--method-call-expr.yaml` | `{{ Param.Name.upper() }}` | +| `expr1.1--slice-expr.yaml` | `{{ Param.Name[1:3] }}` | +| `expr1.1--multiline-expr.yaml` | Multi-line expression in YAML block scalar | +| `expr1.1--syntax-error.invalid.yaml` | Malformed expression `{{ 1 + }}` | + +### 1E. Contextual Keywords (Expression Language §1.1.3) + +| File | Description | +|------|-------------| +| `expr1.1.3--param-named-if.yaml` | `Param.if` as attribute name | +| `expr1.1.3--param-named-def.yaml` | `Param.def` as attribute name | +| `expr1.1.3--param-named-true.yaml` | `Param.True` as attribute name | +| `expr1.1.3--param-named-none.yaml` | `Param.None` as attribute name | +| `expr1.1.3--param-named-for.yaml` | `Param.for` as attribute name | + +### 1F. JSON/YAML Aliases (Expression Language §1.1.4) + +| File | Description | +|------|-------------| +| `expr1.1.4--null-alias.yaml` | `null` accepted as `None` | +| `expr1.1.4--true-false-aliases.yaml` | `true`/`false` accepted as `True`/`False` | + +### 1G. Task Parameter Range Extensions (Expression Language §1.3.11) + +| File | Description | +|------|-------------| +| `expr1.3.11--float-range-expression.yaml` | FLOAT task param with `range: "{{ [1.0, 2.0] }}"` | +| `expr1.3.11--string-range-expression.yaml` | STRING task param with expression range | +| `expr1.3.11--path-range-expression.yaml` | PATH task param with expression range | + +### 1H. Let Bindings (Template Schemas §3.6) + +Let bindings (`let:`) allow defining named expressions evaluated once and available to +child fields. The binding syntax is ` = ` where `UserIdentifier` +starts with a lowercase letter or underscore. + +Scoping levels (see Template Schemas §3.6.2): +- `StepTemplate.let` — available to script, parameterSpace, hostRequirements, stepEnvironments +- `StepScript.let` — available to actions, embeddedFiles +- `EnvironmentScript.let` — available to actions, embeddedFiles +- `SimpleAction.let` — available to script, args (requires FEATURE_BUNDLE_1 + EXPR) + +| File | Description | +|------|-------------| +| `3.6--let-step-level.yaml` | `let:` on StepTemplate with binding used in script | +| `3.6--let-script-level.yaml` | `let:` on StepScript with binding used in action args | +| `3.6--let-env-script-level.yaml` | `let:` on EnvironmentScript | +| `3.6--let-simple-action-level.yaml` | `let:` on SimpleAction (bash/python) with FEATURE_BUNDLE_1 | +| `3.6--let-requires-expr.invalid.yaml` | `let:` without EXPR extension → error | +| `3.6--let-empty-list.invalid.yaml` | `let: []` → error (must have at least one) | +| `3.6--let-too-many.invalid.yaml` | More than 50 let bindings → error | +| `3.6.1--let-missing-equals.invalid.yaml` | `let: ["x"]` → error (no `=`) | +| `3.6.1--let-uppercase-name.invalid.yaml` | `let: ["Foo = 1"]` → error (must start lowercase) | +| `3.6.1--let-self-reference.invalid.yaml` | `let: ["x = x + 1"]` → error | +| `3.6--let-duplicate-name.invalid.yaml` | `let: ["x = 1", "x = 2"]` → error (shadows) | +| `3.6--let-shadow-enclosing.invalid.yaml` | Step `let: ["x = 1"]` + script `let: ["x = 2"]` → error | +| `3.6--let-multiple-bindings.yaml` | Multiple bindings, later ones reference earlier ones | +| `3.6.1--let-complex-expression.yaml` | Binding with arithmetic/function calls | + +--- + +## Phase 2: Core Expression Evaluation (Job Execution Tests) ✅ COMPLETE + +*Completed 2026-02-19. 28 tests, all passing.* + +Runtime behavior of operators, type coercion, and core semantics. Success cases are +consolidated: one test file per operator/concept with multiple output assertions. +Error cases remain separate since each must fail independently. + +### 2A. Arithmetic Operators (Expression Language §2.1.1) + +| File | Description | +|------|-------------| +| `expr2.1.1--int-arithmetic.test.yaml` | `+`, `-`, `*`, `//`, `%` on ints, unary `-`/`+` | +| `expr2.1.1--float-arithmetic.test.yaml` | Float `+`, int/float promotion (`2 + 1.5` → `3.5`) | +| `expr2.1.1--division.test.yaml` | `7 / 2` → `3.5` (true div returns float), `7 // 2` → `3` | +| `expr2.1.1--exponentiation.test.yaml` | `2 ** 3` → `8.0` | +| `expr2.1.1--division-by-zero.invalid.test.yaml` | `{{ 1 / 0 }}` | + +### 2B. String and List Operators (Expression Language §2.1.2, §2.1.3) + +| File | Description | +|------|-------------| +| `expr2.1.2--string-ops.test.yaml` | Concatenation `'hello' + ' world'`, repetition `'ab' * 3`, `'ell' in 'hello'`, `'xyz' not in 'hello'` | +| `expr2.1.3--list-ops.test.yaml` | Concatenation `[1,2]+[3,4]`, int/float concat, repetition `[1,2]*2`, `in`/`not in` | +| `expr2.1.3--incompatible-list-concat.invalid.test.yaml` | `{{ [1] + ['a'] }}` | + +### 2C. Path Operators (Expression Language §2.1.5) + +| File | Description | +|------|-------------| +| `expr2.1.5--path-ops.test.yaml` | `/` join (`Param.Dir / 'file.txt'`), `+` append (`stem + '.txt'`) | +| `expr2.1.5--path-host-context.test.yaml` | Path in host context uses native OS separators; `let` scope stays POSIX; `as_posix` always forward slashes. Platform-specific assertions. | + +### 2D. Comparison Operators (Expression Language §2.1.4) + +| File | Description | +|------|-------------| +| `expr2.1.4--comparisons.test.yaml` | `==`, `!=`, `<`, `>` on ints; `5 == 5.0` → true; `true == 1` → false; `'5' == 5` → false; chained `1 < 2 < 3`; string ordering; list equality | + +### 2E. Logical Operators (Expression Language §2.1.6) + +| File | Description | +|------|-------------| +| `expr2.1.6--logical-ops.test.yaml` | `and`, `or`, `not`, short-circuit (`false and (1/0 > 0)` → false without error) | + +### 2F. Subscript and Slice (Expression Language §2.1.7, §2.1.8) + +| File | Description | +|------|-------------| +| `expr2.1.7--indexing.test.yaml` | List index, negative index, string index | +| `expr2.1.7--index-out-of-bounds.invalid.test.yaml` | `{{ [1][5] }}` | +| `expr2.1.8--slicing.test.yaml` | List slice `[1:4]`, reverse `[::-1]`, string slice, range_expr slice | + +### 2G. Conditional Expressions (Expression Language §1.3.5) + +| File | Description | +|------|-------------| +| `expr1.3.5--conditionals.test.yaml` | True branch, false branch, conditional with param value | + +### 2H. Type Coercion (Expression Language §1.2.3) + +| File | Description | +|------|-------------| +| `expr1.2.3--implicit-coercion.test.yaml` | int→float, path→string, range_expr→list[int], scalar→string in format strings | + +### 2I. Null Semantics (Expression Language §1.3.2) + +| File | Description | +|------|-------------| +| `expr1.3.2--null-skips-arg.test.yaml` | `{{ null }}` in args skips item, `{{ ['a','b'] }}` flattened inline | +| `expr1.3.2--null-in-list-literal-error.invalid.test.yaml` | `{{ [1, null, 2] }}` is error | + +### 2J. Float Pass-Through (Expression Language §1.3.4) + +| File | Description | +|------|-------------| +| `expr1.3.4--float-passthrough.test.yaml` | `{{Param.V}}` preserves `"3.500"`, `{{Param.V + 1}}` uses shortest repr | + +### 2K. List Comprehensions (Expression Language §1.3.7) + +| File | Description | +|------|-------------| +| `expr1.3.7--comprehensions.test.yaml` | Basic `[x*2 for x in [1,2,3]]`, with filter `if x > 2`, over range_expr | + +### 2L. List Literal Type Inference (Expression Language §1.2.6) + +| File | Description | +|------|-------------| +| `expr1.2.6--list-type-inference.test.yaml` | Homogeneous `[1,2,3]`, int/float mix `[1, 2.0]`, empty list `[]` | +| `expr1.2.6--incompatible-types.invalid.test.yaml` | `[1, 'a']` → error | + +### 2M. Let Bindings at Runtime (Template Schemas §3.6) + +| File | Description | +|------|-------------| +| `3.6--let-step-binding.test.yaml` | Step-level binding used in args | +| `3.6--let-script-binding.test.yaml` | Script-level binding used in args, with chained bindings (`x = 1`, `y = x + 1`) | +| `3.6--let-binding-with-param.test.yaml` | Binding references param (`out = Param.Dir / 'sub'`), used in embedded file | +| `3.6--let-env-script-binding.test.yaml` | EnvironmentScript binding used in onEnter | +| `3.6--let-scopes.test.yaml` | Step `let` + script `let` with different names, both accessible | +| `3.6--let-simple-action-binding.test.yaml` | SimpleAction (bash) binding (requires FEATURE_BUNDLE_1) | + +--- + +## Phase 3: Function Library (Job Execution Tests) + +See separate file: [EXPR_CONFORMANCE_PLAN_FUNCTIONS.md](EXPR_CONFORMANCE_PLAN_FUNCTIONS.md) + +--- + +## Phase 4: Edge Cases (Job Execution Tests) + +See separate file: [EXPR_CONFORMANCE_PLAN_EDGE_CASES.md](EXPR_CONFORMANCE_PLAN_EDGE_CASES.md) + +--- + +## Implementation Notes + +- Each phase can be implemented and reviewed independently +- Within a phase, tests can be added incrementally +- Run `uv run run_openjd_cli_tests.py 2023-09/EXPR` to execute EXPR tests +- Template validation tests are simpler to write; start there +- Job execution tests use `print(r'OUTPUT:...')` pattern for assertions +- Extension tests require `extensions: [EXPR]` in templates + +--- + +## Follow-Up: Eager Evaluation of Constant Expressions in `openjd check` + +Currently `openjd check` only validates expression *syntax* (parsing), not *semantics* +(evaluation). This means errors like `float('')`, `1 / 0`, `[1, "a"]`, and `[1][5]` are +only caught at job execution time, requiring the slower `jobs/` test format. + +### Proposed Change + +After parsing an expression, check whether it can be evaluated eagerly: + +1. `parsed.accessed_symbols` is empty (no references to `Param.*`, `Task.*`, `Session.*`, + `Env.*`, `RawParam.*`, or let bindings from enclosing scopes) +2. `parsed.called_functions` does not include host-context-only functions + (`apply_path_mapping` and any future host-only functions) + +If both conditions hold, the expression is fully constant and can be evaluated immediately +during model parsing. Any evaluation error (type error, division by zero, conversion failure, +index out of bounds, `fail()`) is reported as a validation error. + +This applies to expressions in any context — let bindings, args, name, embedded file data — +regardless of whether the context is TEMPLATE scope or host scope. + +### Errors This Would Catch at Validation Time + +- `float('')`, `float('hello')`, `float('1.5j')` — conversion errors +- `int('hello')`, `int('')`, `int('3.5')`, `int(3.75)` — conversion errors +- `bool('maybe')`, `bool(path('/tmp'))`, `bool([1,2])` — conversion errors +- `1 / 0` — arithmetic errors +- `[1, 'a']`, `[1] + ['a']` — type incompatibility errors +- `[1][5]` — index out of bounds +- `[1, null, 2]` — null in list literal +- `min([])` — empty collection errors +- `range_expr([])` — empty range errors +- `fail('message')` — explicit validation failures +- `len(42)`, `len(3.14)`, `len(true)` — type errors + +### Impact on Conformance Tests + +Once implemented, many `jobs/*.invalid.test.yaml` tests could move to +`job_templates/*.invalid.yaml`, making them faster to run and simpler to write. +The `jobs/` format would only be needed for errors that depend on runtime values +(e.g., parameter values, session state, path mapping). diff --git a/conformance-tests/EXPR_CONFORMANCE_PLAN_EDGE_CASES.md b/conformance-tests/EXPR_CONFORMANCE_PLAN_EDGE_CASES.md new file mode 100644 index 0000000..4bc8cb3 --- /dev/null +++ b/conformance-tests/EXPR_CONFORMANCE_PLAN_EDGE_CASES.md @@ -0,0 +1,122 @@ +# EXPR Conformance Plan: Phase 4 — Edge Cases + +Tests for subtle behaviors, corner cases, and cross-cutting concerns. +All files go in `2023-09/EXPR/jobs/` unless noted as template validation tests. +Files referencing Expression Language sections use the `expr` prefix; +files referencing Template Schema sections use the standard numeric prefix. + +Success cases are consolidated where possible. Error cases remain separate. + +## Method Call Coercion Restriction (Expression Language §1.2.4) + +| File | Description | +|------|-------------| +| `expr1.2.4--method-no-receiver-coercion.invalid.test.yaml` | `path('/foo').startswith('/f')` → error (no coercion on receiver) | +| `expr1.2.4--function-vs-method-coercion.test.yaml` | `startswith(path('/foo'), '/f')` → true (function coerces), and other args in method call still coerced | + +## Contextual Keywords in Expressions (Expression Language §1.1.3) + +| File | Description | +|------|-------------| +| `expr1.1.3--keyword-attrs-in-exprs.test.yaml` | `Param.if if Param.flag else Param.else`, `Param.if and Param.or`, `Param.Value.if.else.and` | + +## String Literal Formats (Expression Language §1.1.5) + +| File | Description | +|------|-------------| +| `expr1.1.5--string-literals.test.yaml` | Single-quoted, double-quoted, raw string (`r'hello\n'` → literal backslash-n), escape sequences (`'\n'`, `'\t'`, `'\\'`), triple-quoted | + +## Numeric Literal Formats (Expression Language §1.1.6) + +| File | Description | +|------|-------------| +| `expr1.1.6--numeric-literals.test.yaml` | Hex `0x2A`, octal `0o52`, binary `0b101010` (all → 42), underscore `1_000_000`, scientific `1.5e3` | +| `expr1.1.6--leading-zeros.invalid.yaml` | `007` → syntax error (in `job_templates/`) | + +## Multi-line Expressions (Expression Language §1.1.7) + +| File | Description | +|------|-------------| +| `expr1.1.7--multiline.test.yaml` | Multi-line arithmetic, list comprehension, and conditional across lines | + +## New Parameter Types at Runtime (Template Schemas §2.9–2.16) + +| File | Description | +|------|-------------| +| `2.9--bool-param-runtime.test.yaml` | BOOL param in conditional, and BOOL param with default (no override) | +| `2.11--list-string-param-runtime.test.yaml` | LIST[STRING] iterated in comprehension | +| `2.13--list-int-param-runtime.test.yaml` | LIST[INT] indexed with `[i]` and `len()` | +| `2.12--list-path-param-mapping.test.yaml` | LIST[PATH] with path mapping | +| `2.10--range-expr-param-runtime.test.yaml` | RANGE_EXPR used as task param range and `list(Param.Frames)` | +| `2.16--list-list-int-param-runtime.test.yaml` | LIST[LIST[INT]] accessed with `[i][j]` | + +## Format String Behavior (Expression Language §1.3.2) + +| File | Description | +|------|-------------| +| `expr1.3.2--format-string-semantics.test.yaml` | Bare `"{{expr}}"` inherits type, `"prefix {{expr}} suffix"` is string, list in format string → `"[1, 2, 3]"`, null in format string → empty | + +## Error Cases + +| File | Description | +|------|-------------| +| `error--type-mismatch-add.invalid.test.yaml` | `{{ 'hello' + 5 }}` → type error | +| `error--unknown-variable.invalid.test.yaml` | `{{ Param.DoesNotExist }}` → error | +| `error--unknown-function.invalid.test.yaml` | `{{ nonexistent(1) }}` → error | +| `error--null-in-list.invalid.test.yaml` | `{{ [1, null, 2] }}` → error | +| `error--non-bool-condition.invalid.test.yaml` | `{{ 'yes' if 1 else 'no' }}` → error (1 is not bool) | + +## List Comprehension Variable Naming (Expression Language §1.3.7) + +| File | Description | +|------|-------------| +| `expr1.3.7--loop-var-valid.test.yaml` | Lowercase `x` and underscore-prefixed `_x` both work | +| `expr1.3.7--loop-var-uppercase.invalid.test.yaml` | `[X for X in [1,2]]` — uppercase var rejected (in `job_templates/`) | +| `expr1.3.7--loop-var-shadows-binding.invalid.test.yaml` | Loop var shadows a let binding → error | + +## UFCS (Uniform Function Call Syntax) (Expression Language §1.3.3) + +| File | Description | +|------|-------------| +| `expr1.3.3--ufcs.test.yaml` | `upper('hello')` = `'hello'.upper()`, chained `'a,b,c'.split(',').join(';')` → `a;b;c`, property access `Param.File.stem` | + +## Cross-Type Equality Edge Cases (Expression Language §1.2.5) + +| File | Description | +|------|-------------| +| `expr1.2.5--cross-type-equality.test.yaml` | `[1,2,3] == range_expr('1-3')` → true, `1 == [1]` → false, `'/a/b' == path('/a/b')` → true, `[5] == [5.0]` → true | + +## noreturn Type Behavior + +| File | Description | +|------|-------------| +| `noreturn--conditional-type.test.yaml` | `Param.X if Param.X > 0 else fail('bad')` result is int (not int?), `Param.Flag or fail('required')` result is bool | + +## Submission-Time vs Host Context (Template Schemas §7.3, Expression Language §2.3.2) + +The spec distinguishes `@fmtstring` (submission-time/TEMPLATE scope) from `@fmtstring[host]` +(runtime on worker). Key differences: + +- **Path behavior**: TEMPLATE scope uses `PurePosixPath` for cross-platform consistency; + host context uses native OS path semantics. +- **`apply_path_mapping()`**: Only available in `@fmtstring[host]` contexts. Using it in + submission-time expressions (e.g., `JobName`, host requirements `min`/`max`, task param `range`) + is an error. +- **Symbol availability**: `Session.*`, `Task.Param.*`, `Task.File.*`, `Env.File.*` are only + available in host contexts. `Param.*` and `RawParam.*` are available in both. + +### Template validation tests (`job_templates/`) + +| File | Description | +|------|-------------| +| `7.3--apply-path-mapping-in-job-name.invalid.yaml` | `apply_path_mapping()` in `name:` (submission-time) → error | +| `7.3--task-param-in-job-name.invalid.yaml` | `Task.Param.X` in `name:` (submission-time) → error (already exists in base, verify EXPR doesn't change) | +| `7.3--session-in-host-requirements.invalid.yaml` | `Session.WorkingDirectory` in host requirements (submission-time) → error | + +### Job execution tests (`jobs/`) + +| File | Description | +|------|-------------| +| `expr2.3.2--apply-path-mapping.test.yaml` | `apply_path_mapping()` in `args` (host context) with path mapping rules | +| `expr2.3.2--path-in-submission-context.test.yaml` | Path `/` operator in task param `range` expression uses POSIX semantics regardless of OS | +| `7.3--host-context-symbols.test.yaml` | `Session.WorkingDirectory`, `Task.File.*`, `Env.File.*` accessible in `args`/`data` | diff --git a/conformance-tests/EXPR_CONFORMANCE_PLAN_FUNCTIONS.md b/conformance-tests/EXPR_CONFORMANCE_PLAN_FUNCTIONS.md new file mode 100644 index 0000000..4d74e8d --- /dev/null +++ b/conformance-tests/EXPR_CONFORMANCE_PLAN_FUNCTIONS.md @@ -0,0 +1,118 @@ +# EXPR Conformance Plan: Phase 3 — Function Library + +Tests for built-in functions defined in Expression Language §2.2 and §2.3. +All files go in `2023-09/EXPR/jobs/`. All filenames use the `expr` prefix since they +reference the Expression Language specification. + +Success cases are consolidated: one test file per function/concept, with multiple +`expected.output` assertions. Error cases remain separate since each must fail independently. + +## General Functions (Expression Language §2.2.1) + +| File | Description | +|------|-------------| +| `expr2.2.1--len.test.yaml` | `len` on list, string, and range_expr | +| `expr2.2.1--string-conversion.test.yaml` | `string()` from int, bool, null, float, path, list | +| `expr2.2.1--int-conversion.test.yaml` | `int()` from float (exact), string; also `int(3.0)` → `3` | +| `expr2.2.1--int-from-float-inexact.invalid.test.yaml` | `int(3.75)` → error | +| `expr2.2.1--int-from-empty-string.invalid.test.yaml` | `int('')` → error | +| `expr2.2.1--int-from-float-string.invalid.test.yaml` | `int('3.5')` → error (string of float) | +| `expr2.2.1--int-from-string-invalid.invalid.test.yaml` | `int('abc')` → error | +| `expr2.2.1--float-conversion.test.yaml` | `float()` from int and string | +| `expr2.2.1--float-from-complex.invalid.test.yaml` | `float('1.5j')` → error | +| `expr2.2.1--float-from-empty-string.invalid.test.yaml` | `float('')` → error | +| `expr2.2.1--float-from-string-invalid.invalid.test.yaml` | `float('abc')` → error | +| `expr2.2.1--list-from-range-expr.test.yaml` | `list(range_expr('1-3'))` → `[1,2,3]` | +| `expr2.2.1--bool-conversion.test.yaml` | `bool()` from int (0/1), float, and strings (`'true'`, `'false'`, `'yes'`, `'no'`, `'on'`, `'off'`) | +| `expr2.2.1--bool-from-string-invalid.invalid.test.yaml` | `bool('maybe')` → error | +| `expr2.2.1--bool-from-path.invalid.test.yaml` | `bool(path('/tmp'))` → error | +| `expr2.2.1--bool-from-list.invalid.test.yaml` | `bool([1,2])` → error | +| `expr2.2.1--range-expr-conversion.test.yaml` | `range_expr('1-3')` from string and `range_expr([1,2,3])` from list | +| `expr2.2.1--range-expr-from-empty-list.invalid.test.yaml` | `range_expr([])` → error | + +## Validation Functions (Expression Language §2.2) + +| File | Description | +|------|-------------| +| `fail--basic.invalid.test.yaml` | `{{ fail('error message') }}` → fails with message | +| `fail--in-conditional.test.yaml` | `{{ Param.X if Param.X > 0 else fail('must be positive') }}` succeeds when X > 0, and `{{ Param.Mode in ['fast','slow'] or fail('bad mode') }}` succeeds | +| `fail--in-conditional-triggers.invalid.test.yaml` | Conditional with X ≤ 0 → fails | + +## Math Functions (Expression Language §2.2.2) + +| File | Description | +|------|-------------| +| `expr2.2.2--abs.test.yaml` | `abs()` on int and float | +| `expr2.2.2--min.test.yaml` | `min` with 2 args, 3 args, list, and range_expr | +| `expr2.2.2--min-empty-list.invalid.test.yaml` | `min([])` → error | +| `expr2.2.2--max.test.yaml` | `max` with 2 args, 3 args, list, range_expr, and float | +| `expr2.2.2--max-empty-list.invalid.test.yaml` | `max([])` → error | +| `expr2.2.2--sum.test.yaml` | `sum` on int list, float list, empty list (→ 0), single-element, negatives, and range_expr | +| `expr2.2.2--floor-ceil.test.yaml` | `floor`/`ceil` on positive/negative floats, integers (identity), and exact floats | +| `expr2.2.2--round.test.yaml` | `round(2.5)` → 2 (half-even), `round(3.5)` → 4, `round(3.14159, 2)` → 3.14 | + +## List Functions (Expression Language §2.2.3) + +| File | Description | +|------|-------------| +| `expr2.2.3--range.test.yaml` | `range(5)`, `range(1,5)`, `range(0,10,2)`, `range(5,0,-1)` | +| `expr2.2.3--flatten.test.yaml` | `flatten([[1,2],[3]])` → `[1,2,3]` | +| `expr2.2.3--sorted.test.yaml` | `sorted` on int, string, already-sorted, empty, and float lists | +| `expr2.2.3--reversed.test.yaml` | `reversed` on int, string, single-element, and empty lists | +| `expr2.2.3--any-all.test.yaml` | `any`/`all` with mixed bools and empty list edge cases (`any([])` → false, `all([])` → true) | + +## String Functions (Expression Language §2.2.4) + +| File | Description | +|------|-------------| +| `expr2.2.4--case-transforms.test.yaml` | `upper`, `lower`, `capitalize`, `title` | +| `expr2.2.4--strip.test.yaml` | `strip`, `lstrip`, `rstrip` | +| `expr2.2.4--removeprefix-removesuffix.test.yaml` | `removeprefix`/`removesuffix` with match and no-match cases | +| `expr2.2.4--search-test.test.yaml` | `startswith`/`endswith` true and false, `count` with matches and zero | +| `expr2.2.4--find.test.yaml` | `find` found (index), not found (→ -1), and at-start (→ 0) | +| `expr2.2.4--replace.test.yaml` | `replace` with match and no-match cases | +| `expr2.2.4--split-join.test.yaml` | `split`, `join` on string list, and `join` on path list | +| `expr2.2.4--padding.test.yaml` | `zfill` (string and int) in function and method syntax, negative sign preservation, `ljust`, `rjust`, `center` | + +## Regular Expression Functions (Expression Language §2.2.5) + +| File | Description | +|------|-------------| +| `expr2.2.5--re-match.test.yaml` | `re_match` full match (index 0), group, no-groups, multiple groups, null on not-at-start | +| `expr2.2.5--re-search.test.yaml` | `re_search` full match, group, no-groups, multiple groups, null, `$` anchor | +| `expr2.2.5--re-findall.test.yaml` | `re_findall` no groups, one group, multiple groups (list of lists), no match (→ `[]`) | +| `expr2.2.5--re-replace.test.yaml` | `re_replace` single, replace-all, and no-match cases | +| `expr2.2.5--re-escape.test.yaml` | `re_escape` on dot and bracket metacharacters | +| `expr2.2.5--re-portable-syntax.test.yaml` | Portable regex features: `$`, `^`, `\b`, `(?:...)`, alternation, `{n}`, non-greedy | +| `expr2.2.5--re-backreference.invalid.test.yaml` | Backreference `\1` → error (unsupported) | +| `expr2.2.5--re-lookahead.invalid.test.yaml` | Lookahead `(?=...)` → error (unsupported) | +| `expr2.2.5--re-lookbehind.invalid.test.yaml` | Lookbehind `(?<=...)` → error (unsupported) | +| `expr2.2.5--re-backslash-Z.invalid.test.yaml` | `\Z` anchor → error (Python-only, use `$`) | +| `expr2.2.5--re-backslash-z.invalid.test.yaml` | `\z` anchor → error (Rust-only, use `$`) | + +## Serialization Functions (Expression Language §2.2.6) + +| File | Description | +|------|-------------| +| `expr2.2.6--repr-sh.test.yaml` | `repr_sh` on simple string (no quoting), spaces (single-quoted), and list (joined) | +| `expr2.2.6--repr-py.test.yaml` | `repr_py` on string, int, float, bool, null, list, path, range_expr | +| `expr2.2.6--repr-json.test.yaml` | `repr_json` on string, int, float, bool, null, list, range_expr | +| `expr2.2.6--repr-cmd.test.yaml` | `repr_cmd` on string with `&`, `|`, `^` escaping, and list | +| `expr2.2.6--repr-pwsh.test.yaml` | `repr_pwsh` on string, embedded quote, bool, int, float, path, range_expr, list | + +## Path Properties (Expression Language §2.3.1) + +| File | Description | +|------|-------------| +| `expr2.3.1--path-properties.test.yaml` | `name`, `stem`, `suffix`, `suffixes`, `parent`, `parts`; empty suffix/suffixes for extensionless file | +| `expr2.3.1--path-compound-suffix.test.yaml` | `suffix` → `.gz`, `suffixes` → `['.tar', '.gz']`, `stem` → `backup.tar` | + +## Path Functions (Expression Language §2.3.2) + +| File | Description | +|------|-------------| +| `expr2.3.2--path-construction.test.yaml` | `path` from string, from relative parts, and from absolute parts with root component | +| `expr2.3.2--with-name-stem-suffix.test.yaml` | `with_name`, `with_stem`, `with_suffix` | +| `expr2.3.2--with-number.test.yaml` | `with_number` on digits, `%d`, `%04d`, `####`, `######`, overflow, no-pattern fallback, string overload, and negative numbers | +| `expr2.3.2--as-posix.test.yaml` | `as_posix` on absolute and relative paths | +| `expr2.3.2--apply-path-mapping.test.yaml` | `apply_path_mapping()` with mapping rules: matched prefix substitution and unmatched passthrough | diff --git a/conformance-tests/README.md b/conformance-tests/README.md index b3b070d..dac6694 100644 --- a/conformance-tests/README.md +++ b/conformance-tests/README.md @@ -52,12 +52,26 @@ Filenames encode the spec section they test: - `.invalid` - Test should FAIL validation/execution - `.test` - Job execution test (in `jobs/` directory) +For the `EXPR` extension, tests may reference either the Template Schema or the +[Expression Language](../wiki/2026-02-Expression-Language.md) specification. Tests referencing +the Expression Language use the `expr` prefix: + +``` +expr
--[.invalid][.suffix].yaml +``` + +- `expr
` - Reference to the Expression Language section (e.g., `expr1.1`, `expr2.2.4`) + Examples: - `1.1--minimal-job-template.yaml` - Section 1.1 (Job Template root) - `3.3.2--allof.yaml` - Section 3.3.2 (AttributeRequirement) - `5--cancelation-notify-then-terminate.yaml` - Section 5 (Action) - `2.1--missing-name.invalid.yaml` - Invalid test for Section 2.1 - `contiguous-even.test.yaml` - TASK_CHUNKING extension execution test (in `TASK_CHUNKING/jobs/`) +- `2.9--bool-param-default-true.yaml` - EXPR: Template Schema §2.9 (JobBoolParameterDefinition) +- `3.6--let-step-level.yaml` - EXPR: Template Schema §3.6 (LetBindings) +- `expr1.1--arithmetic-expr.yaml` - EXPR: Expression Language §1.1 (Extended Format String Grammar) +- `expr2.2.4--upper.test.yaml` - EXPR: Expression Language §2.2.4 (String Functions) ### Extension Tests diff --git a/conformance-tests/run_openjd_cli_tests.py b/conformance-tests/run_openjd_cli_tests.py index 302c2ec..88a987f 100644 --- a/conformance-tests/run_openjd_cli_tests.py +++ b/conformance-tests/run_openjd_cli_tests.py @@ -102,7 +102,7 @@ def run_job(test_path: Path) -> tuple[bool, str]: forbidden = expected.get("forbidden", []) + expected.get(f"forbidden_{PLATFORM}", []) for line in expected_output: if line not in output: - return False, f"Missing expected output: {line}\n--- Actual output (last 500 chars) ---\n{output[-500:]}" + return False, f"Missing expected output: {line}\n--- Actual output ---\n{output}" for line in forbidden: if line in output: return False, f"Found forbidden output: {line}" diff --git a/rfcs/0005-expression-language.md b/rfcs/0005-expression-language.md new file mode 100644 index 0000000..b665243 --- /dev/null +++ b/rfcs/0005-expression-language.md @@ -0,0 +1,2245 @@ +* Feature Name: Expression Language +* RFC Tracking Issue: https://github.com/OpenJobDescription/openjd-specifications/issues/112 +* Start Date: 2026-01-30 +* Specification Version: 2023-09 extension EXPR +* Accepted On: (pending) + +## Summary + +Open Job Description templates need a flexible way to customize job structure and express glue transformations between +different interfaces. Schedulers must understand job structure without running tasks to determine it, so need +the ability to evaluate it in an isolated, secure, and bounded context. We propose a domain-specific +expression language to provide this flexibility. The language we define has a type system +and set of operations rich enough to cover the diverse use cases identified in community discussions, +with well-defined, bounded expression evaluation semantics. + +## Basic Examples + +### Arithmetic Operations + +Calculate frame ranges dynamically: + +```yaml +steps: + - name: Render + parameterSpace: + taskParameterDefinitions: + - name: Frame + type: int + range: "{{Param.FrameStart}}-{{Param.FrameEnd}}:{{Param.FramesPerTask}}" + script: + actions: + onRun: + command: render + args: + - "--start" + - "{{Task.Param.Frame}}" + - "--end" + - "{{min(Task.Param.Frame + Param.FramesPerTask, Param.FrameEnd) - 1}}" +``` + +### Conditional Expressions + +Select values based on parameters: + +```yaml +parameterDefinitions: + - name: Quality + type: STRING + allowedValues: ["draft", "final"] +steps: + - name: Render + script: + actions: + onRun: + command: render + args: + - "--samples" + - "{{ 16 if Param.Quality == 'final' else 4 }}" +``` + +### Slicing + +Extract subsets of lists, strings, and paths using Python-style slicing: + +```yaml +parameterDefinitions: + - name: Files + type: STRING + default: "file1.exr;file2.exr;file3.exr;file4.exr;file5.exr" +steps: + - name: Process + script: + actions: + onRun: + command: process + args: + # First three files + - "{{ Param.Files.split(';')[:3].join(';') }}" + # Every other file + - "{{ Param.Files.split(';')[::2].join(';') }}" + # Last two files + - "{{ Param.Files.split(';')[-2:].join(';') }}" +``` + +### Multi-line Expressions + +Complex expressions can span multiple lines for readability. This example shows a list +comprehension that generates output file paths for each frame in a chunk: + +```yaml +specificationVersion: jobtemplate-2023-09 +name: Chunked Frame Processing +extensions: ["FEATURE_BUNDLE_1", "TASK_CHUNKING", "EXPR"] +parameterDefinitions: + - name: Frames + type: RANGE_EXPR + default: "1-10,12,17-19,40-42,50-70,78,90,100" + - name: OutputDir + type: PATH + default: "renders" + - name: FilePattern + type: STRING + default: "frame_####.exr" +steps: + - name: Render + parameterSpace: + taskParameterDefinitions: + - name: Frame + type: CHUNK[INT] + range: "{{Param.Frames}}" + chunks: + defaultTaskCount: 10 + rangeConstraint: NONCONTIGUOUS + bash: + script: | + echo "Chunk: {{Task.Param.Frame}} ({{len(Task.Param.Frame)}} frames)" + echo "Files:" + for FILE in {{ repr_sh([ + Param.OutputDir / Param.FilePattern.with_number(frame) + for frame in Task.Param.Frame + ]) }}; do + echo "Processing $FILE" + done +``` + +## Motivation + +The current template substitution syntax is limited to direct value references. This creates +friction for common use cases: + +1. **Arithmetic on job parameters** - Users frequently need to provide values to commands derived from + job parameters in addition to their raw value, for example to determine a range of + values to process. Currently this requires external scripting or embedding calculations + in shell scripts. + +2. **Conditional logic** - Selecting different values based on parameter settings requires + workarounds like implementing a wrapper script whose sole purpose is to act as glue between + the job parameter interface and a command. + +3. **Conditional omission of fields/elements** - There is no way to conditionally omit an + optional field or array element. Users must either pass empty strings (which may not be + valid for the target command) or maintain multiple template variants. + +4. **Inter-dependent job parameter defaults** - The proposal to conditionally show job parameter UI + elements involves using one parameter value to affect another. In order to do this, the code that + is controlling the UI must be able to evaluate expressions to perform the show/hide or other potential + operations. This requires that the expression language be available in all the UI framework languages, + e.g. in ECMAScript for web UIs as well as in Python and native code for desktop UIs. + +These motivations are in community discussions including: +- [Extend types and the template substitution language #79](https://github.com/OpenJobDescription/openjd-specifications/discussions/79) +- [Allowing simple math operations on parameters #49](https://github.com/OpenJobDescription/openjd-specifications/discussions/49) +- [Container support with onWrapTaskRun #83](https://github.com/OpenJobDescription/openjd-specifications/discussions/83) +- [Conditionally show job parameter UI elements #42](https://github.com/OpenJobDescription/openjd-specifications/discussions/42) +- [Task-task dependencies with adjacency graphs #82](https://github.com/OpenJobDescription/openjd-specifications/discussions/82) +- [Include/exclude parts of a template #81](https://github.com/OpenJobDescription/openjd-specifications/discussions/81) + +## Technical Requirements + +1. **Backward Compatibility** - All existing valid templates must continue to work identically + with no changes. See [Appendix A: Backward Compatibility Analysis](#appendix-a-backward-compatibility-analysis). + +2. **Opt-in Activation** - The extended expression syntax is only enabled when the + `EXPR` extension is requested in the template. Groundwork for this was laid in + [openjd-model-for-python#182](https://github.com/OpenJobDescription/openjd-model-for-python/pull/182). + +3. **Reuse Existing Parser** - Avoid writing a custom parser from scratch. Use an existing + language parser and accept a subset of its grammar/AST. This reduces implementation effort + and leverages well-tested parsing infrastructure. + +4. **Deterministic Evaluation** - Expressions must evaluate deterministically with no side + effects. The same inputs must always produce the same outputs. + +5. **Fail-Fast Errors** - Invalid expressions must be rejected at template validation/submission + time, not at task runtime. This includes syntax errors, undefined symbol references, and type + errors. See [Static Type Checking](#static-type-checking) about catching type errors early, + even for expressions that won't be evaluated until task runtime. + +6. **No Filesystem, Network, or Environment Variable Access** - Expressions have no access to + the filesystem, network, or environment variables. The evaluation context is fully defined + by the template's parameters and runtime context variables. This ensures expressions do not + depend on any outside state. + +7. **Memory-Bounded Evaluation** - Expression evaluation must operate within bounded memory. + Implementations accept a configurable memory limit and track the memory size of live values + during evaluation. This prevents unbounded resource consumption from expressions like + `"a" * 10000000` or large list comprehensions. + +8. **No User-Defined Functions** - All functions, operators, and type properties are defined by + the specification. There is no mechanism for users to define custom functions or extend the + language within templates. This ensures templates are portable and evaluation is bounded and + predictable. + +## Design Choices + +### 1. Language Syntax: Python Subset with Compatibility Extensions + +The expression language uses a **Python expression subset** to satisfy +[Technical Requirement #3](#technical-requirements). Python implementations can use the +[`ast`](https://docs.python.org/3/library/ast.html) standard library module, Rust implementations +can use the [ruff Python parser](https://github.com/astral-sh/ruff/tree/main/crates/ruff_python_parser), +and JavaScript implementations can use +[dt-python-parser](https://github.com/DTStack/dt-python-parser). + +Three compatibility extensions are added: + +- **Contextual keywords** for backward compatibility - `if`, `else`, `and`, `or`, `not`, `for`, + `in`, `True`, `False`, `None` are only keywords in operator positions, not after `.` in + attribute access (e.g., `Param.if` remains valid). + +- **JSON/YAML-compatible literals** - Accept `null`, `true`, `false` as aliases for `None`, + `True`, `False` to reduce friction with the surrounding template syntax. + +- **Implicit line continuation** - Multi-line expressions are supported without requiring + backslash (`\`) continuation or enclosing parentheses. See + [Appendix D: Implicit Line Continuation](#appendix-d-implicit-line-continuation) for + implementation details. + +See [Appendix B: Language Syntax Choice](#appendix-b-language-syntax-choice) and +[Appendix A: Backward Compatibility Analysis](#appendix-a-backward-compatibility-analysis) for rationale. + +### 2. Float Value Pass-Through + +When a float value is only copied without modification, the original string representation +is preserved in output string interpolation. When an operation is performed on a float value, +it becomes a 64-bit IEEE floating point number, and string interpolation uses the shortest +decimal string representation. This is an extension of existing float job parameter +handling to the full expression language semantics. + +For example, if a job submission provides the value `"3.500"` to a float parameter: +- `{{Param.V}}` outputs `"3.500"` (original form preserved) +- `{{Param.V + 1}}` outputs `"4.5"` (shortest representation after computation) + +### 3. Uniform Function Call Syntax + +Functions and properties can be accessed using method syntax: + +- For any function `f(x, ...)` where `x` has type `T`, the expression `x.f(...)` is + equivalent to `f(x, ...)`. +- All operators are defined by functions like `__add__` for the `+` operator, using the + same double-underscore names as Python does. +- Properties like `x.p` are defined using the naming convention `__property_p__` in RFC 0006. + +This enables chaining like `Param.Name.upper().strip()` instead of `strip(upper(Param.Name))`, +and allows properties like `Param.File.stem` to be defined uniformly alongside functions. +Using the uniform function call syntax allows RFC 0006 to define functions, methods, and properties +of all the supported types by specifying a single set of functions in a uniform way. + +Note: The `__*__` names are specification conventions and are not directly callable. + +**Important:** When calling a function as a method, implicit type coercion does not apply +to the receiver (first parameter). See [Method Call Coercion Restriction](#method-call-coercion-restriction) +for details. + +### 4. Minimal Explicit Type Casts + +As a glue expression language intended for convenience, implicit non-destructive type +coercion is performed where the intent is obvious. Requiring explicit conversions like +`string(Param.Quality)` when embedding an int in a string context adds noise without +adding clarity. The detailed coercion rules are specified in +[Implicit Type Coercion](#implicit-type-coercion). + +### 5. None/null Semantics and List Flattening + +When evaluating expressions in templates, the evaluation engine is given a target type: + +- For a required field of type `T`, the target type is `T`. +- For an optional field of type `T`, the target type is `T?`. + +If an expression evaluates to `None`/`null` for an optional field, the field is omitted +from the output as if it were not specified. + +For list items (e.g., in `args`), each item's target type is `T? | list[T]` where `T` is +the item type. This enables three behaviors: + +1. If the result is a value of type `T`, it is added as a single item. +2. If the result is `None`/`null`, the item is skipped and the list is one shorter. +3. If the result is a `list[T]`, the list is flattened inline. + +This implicit dropping and flattening simplifies constructing command arguments: + +```yaml +args: + - "--input" + - "{{Param.InputFile}}" + - "{{ '--verbose' if Param.Verbose else null }}" # Dropped when false + - "{{ ['--quality', Param.Quality] if Param.Quality > 0 else null }}" # Flattened or dropped +``` + +### 6. Let Bindings for Common Sub-expressions + +To avoid repeating complex expressions across multiple fields, `let` bindings can be added +to `StepTemplate` and `ScriptTemplate`. Bindings use Python assignment syntax with optional +type annotations, evaluated in declaration order. + +**Step-level bindings** (`let` in `StepTemplate`) are evaluated once per step and available +to `parameterSpace` and `script`: + +```yaml +steps: + - name: ProcessTiles + let: + - proto_udim = int(re_search(Param.ProtoTile.stem, r'\.(10\d{2})')[1]) + - max_u = (proto_udim - 1001) % 10 + - max_v = (proto_udim - 1001) // 10 + parameterSpace: + taskParameterDefinitions: + - name: TileU + type: INT + range: "0-{{ max_u }}" + - name: TileV + type: INT + range: "0-{{ max_v }}" +``` + +**Task-level bindings** (`let` in `ScriptTemplate`) are evaluated once per task and can +reference `Task.Param`: + +```yaml + script: + let: + - udim = 1001 + Task.Param.TileV * 10 + Task.Param.TileU + - tile_file = Param.ProtoTile.parent / (Param.ProtoTile.stem + '.' + string(udim) + '.tx') + actions: + onRun: + command: process + args: ["--input", "{{ tile_file }}"] +``` + +**Environment bindings** (`let` in `EnvironmentScript`) are evaluated once when the environment +is entered and are available in `actions` and `embeddedFiles`: + +```yaml +environments: + - name: Setup + script: + let: + - work_dir = Param.OutputDir / 'work' + actions: + onEnter: + command: mkdir + args: ["-p", "{{ work_dir }}"] +``` + +## Specification + +### Extension Name + +`EXPR` + +### Extended Format String Grammar + +The grammar for `` is extended from: + +```bnf + ::= + ::= + ::= "." | +``` + +To: + +```bnf + ::= + ::= ("if" "else" )? + ::= ("or" )* + ::= ("and" )* + ::= "not" | + ::= (("<" | ">" | "<=" | ">=" | "==" | "!=") )* + ::= (("+" | "-") )* + ::= (("*" | "/" | "//" | "%") )* + ::= ("-" | "+") | + ::= ("**" )? + ::= ( | )* + ::= "[" "]" + ::= | + ::= ? ":" ? (":" ?)? + ::= "(" ? ")" + ::= ("," )* + ::= | | | | "(" ")" + ::= + ::= "." | + ::= | | | | + ::= | | | + ::= [0-9] ("_"? [0-9])* + ::= "0" ("x" | "X") "_"? [0-9a-fA-F] ("_"? [0-9a-fA-F])* + ::= "0" ("o" | "O") "_"? [0-7] ("_"? [0-7])* + ::= "0" ("b" | "B") "_"? [01] ("_"? [01])* + ::= | + ::= ? "." [0-9] ("_"? [0-9])* ? | "." ? + ::= + ::= ("e" | "E") ("+" | "-")? [0-9] ("_"? [0-9])* + ::= ? ( | ) + ::= "r" | "R" + ::= "'" * "'" | '"' * '"' + ::= "'''" * "'''" | '"""' * '"""' + ::= | any character except "\" or newline or the quote + ::= | any character except "\" + ::= "\" any character + ::= "True" | "False" + ::= "None" + ::= "[" ( ("," )* ","?)? "]" + ::= "[" "for" "in" ("if" )? "]" +``` + +Note: Keywords (`if`, `else`, `and`, `or`, `not`, `for`, `in`, `True`, `False`, `None`) are contextual. +They are only recognized as keywords in their syntactic positions, not as attribute names +following `.` in a ``. + +Note: Chained comparisons are supported. The expression `1 < 2 < 3` is equivalent +to `1 < 2 and 2 < 3`, with each intermediate value evaluated only once. + +#### String Literal Formats + +The grammar supports Python's string literal formats: + +| Format | Example | Description | +|--------|---------|-------------| +| Single-quoted | `'hello'` | String with single quotes | +| Double-quoted | `"hello"` | String with double quotes | +| Triple single-quoted | `'''hello'''` | Multi-line string with single quotes | +| Triple double-quoted | `"""hello"""` | Multi-line string with double quotes | +| Raw single-quoted | `r'hello\n'` | Raw string (backslashes are literal) | +| Raw double-quoted | `r"hello\n"` | Raw string (backslashes are literal) | +| Raw triple-quoted | `r'''hello'''` or `r"""hello"""` | Raw multi-line string | + +All Python escape sequences are supported in non-raw strings: + +| Escape | Meaning | +|--------|---------| +| `\\` | Backslash | +| `\'` | Single quote | +| `\"` | Double quote | +| `\n` | Newline | +| `\r` | Carriage return | +| `\t` | Tab | +| `\xhh` | Character with hex value hh | +| `\uhhhh` | Unicode character with 16-bit hex value | +| `\Uhhhhhhhh` | Unicode character with 32-bit hex value | +| `\N{name}` | Unicode character by name | + +In raw strings (prefixed with `r` or `R`), backslashes are treated as literal characters +and escape sequences are not processed. This is useful for regular expressions and +Windows-style paths. + +#### Numeric Literal Formats + +The grammar supports Python's numeric literal formats for convenience: + +| Format | Example | Value | Description | +|--------|---------|-------|-------------| +| Decimal | `42` | 42 | Standard decimal integer | +| Hexadecimal | `0x2A` or `0X2a` | 42 | Base-16 with `0x` prefix | +| Octal | `0o52` or `0O52` | 42 | Base-8 with `0o` prefix | +| Binary | `0b101010` or `0B101010` | 42 | Base-2 with `0b` prefix | +| Underscore separator | `1_000_000` | 1000000 | Underscores for readability | +| Decimal float | `3.14` | 3.14 | Standard decimal float | +| Scientific notation | `1.5e-3` or `1.5E-3` | 0.0015 | Exponential notation | +| Integer exponent | `1e10` | 10000000000.0 | Integer with exponent (produces float) | + +Underscores can appear between digits in any numeric literal for readability (e.g., `0xFF_FF`, +`0b1010_1010`, `1_000.000_001`). They cannot appear at the start or end of a number, or +adjacent to the decimal point or exponent marker. + +Leading zeros on decimal integers are not permitted (e.g., `007` or `0123` are syntax errors). +This prevents confusion with C-style octal notation. Use the `0o` prefix for octal integers +(e.g., `0o7` or `0o123`). The literal `0` and `00` are valid as they unambiguously represent zero. + +### Schema Changes for Multi-line Expressions + +To support multi-line expressions in format strings, the character constraints for certain +string types must be relaxed to allow line feed (LF, U+000A), carriage return (CR, U+000D), +and horizontal tab (TAB, U+0009) characters. + +The base specification defines string constraints that exclude all Unicode Cc (control) +characters (U+0000-U+001F and U+007F-U+009F). When the EXPR extension is enabled, the +following types are amended to allow CR, LF, and TAB: + +| Type | Change | +|------|--------| +| `ArgString` | Allow CR (U+000D), LF (U+000A), and TAB (U+0009) | + +This change allows format strings in `args` lists to contain multi-line expressions using +YAML literal block scalars (`|` or `|-`): + +```yaml +args: + - | + {{ [ + Param.OutputDir / Param.FilePattern.with_number(frame) + for frame in Task.Param.Frame + ] }} +``` + +Other string types (e.g., `CommandString`, `JobName`) retain their original constraints +as multi-line values are not typically needed in those contexts. + +### Expression Evaluation Types + +Expressions are evaluated with a target type determined by context: + +- For a required field of type `T`, the target type is `T`. +- For an optional field of type `T`, the target type is `T?`. If the expression evaluates + to `None`/`null`, the field is omitted. +- Within a format string like `"The {{}} value."`, the target type is `string?`. + A `None`/`null` result is treated as the empty string. +- For list items (e.g., in `args`), the target type is `T? | list[T]`. A `None`/`null` + result skips the item; a `list[T]` result is flattened inline. + +#### Format String Coercion to String + +When an expression within a format string (e.g., `"prefix {{}} suffix"`) evaluates to +a non-string type, the format string processor applies the following logic: + +1. First, attempt to evaluate the expression with `string` as the target type. If the + expression can produce a string directly (e.g., string literals, string parameters, + or expressions that naturally return strings), this succeeds. + +2. If step 1 fails due to a type mismatch (e.g., the expression returns `list[int]`), + evaluate the expression without type constraints to get its natural result type, + then convert that result to a string using the `string()` function. + +This allows any expression result to be embedded in a format string. For example: +- `"Items: {{ [1, 2, 3] }}"` produces `"Items: [1, 2, 3]"` +- `"Frames: {{ list(Param.RangeExpr) }}"` produces `"Frames: [1, 2, 3, 4, 5]"` +- `"Count: {{ len(myList) }}"` produces `"Count: 5"` + +| Type | Description | +|------|-------------| +| `bool` | Boolean values (`True`, `true`, `False`, or `false`) | +| `int` | Integer values | +| `float` | Floating-point values | +| `string` | String values (name matches the job parameter type, not Python `str`) | +| `path` | Filesystem path values | +| `range_expr` | Range expression string conforming to `` grammar | +| `T?` | The type is `T`, but can also be `None`/`null` | +| `?` | The type is like Python `NoneType`, its value can only be `None`/`null` | +| `list[T]` | Ordered list of values of type `T` (see below for constraints on `T`) | +| `list[?]` | Empty list of values `[]`, where we don't know what `T` should be. | +| `S \| T` | The type may be either `S` or `T` | + +The `T` type for a `list[T]` must satisfy: + +1. `T` cannot be `S?`. A `None`/`null` value inside a list literal is an error. +2. `T` can be `list[S]`, but cannot be nested a third time, so `S` cannot be `list[U]`. + +### Implicit Type Coercion + +Implicit non-destructive type coercion is performed where the intent is obvious. The caller +provides a set of target types it expects, and we use this to affect coercion decisions. +When the expression result does not directly match a target type, the following implicit +conversions are attempted: + +- `int` → `float` when the target types do not include `int` +- `path` → `string` when the target types do not include `path` +- `range_expr` → `string` when the target types do not include `range_expr` (produces canonical form like `"1-5"`) +- `range_expr` → `list[int]` when the target types include `list[int]` but not `range_expr` +- `list[T]` → `list[U]` when each element `T` can be coerced to `U` (e.g., `list[path]` → `list[string]`) +- `list[?]` → `list[T]` for any `T` (empty list literal is compatible with any list type) +- Any scalar value when the target types have a single scalar type (without counting `?` or `list[T]` for any `T`). + The value is coerced non-destructively to that type using the non-destructive coercion rules below. + For example, in a format string context where the target type is `string?`, + an `int` result is coerced to `string`. + - `bool`/`int`/`float`/`path` → `string` + - `string` → `path` + - `float`/`string` → `int` (error if value cannot be represented exactly, e.g. `3.75`, `""`, `"nothing"`, `"3.1"`) + - `int`/`string` → `float` (error if string cannot be parsed, e.g. `""`, `"nothing"`) +- `[v1, v2, v3, ...]` any values when the target types have a single `list` type (without counting `list[?]`). + Every value is coerced non-destructively to `T` where that type is `list[T]`. This applies recursively + for nested lists. The non-destructive coercions are the same as defined for scalar values above. + +#### Method Call Coercion Restriction + +When using uniform function call syntax (UFCS) to call a function as a method, implicit type +coercion does **not** apply to the first parameter (the receiver). This ensures type safety +for method-style calls. + +For example, given a function `startswith(string, string) -> bool`: + +```yaml +# Function call - coercion applies to all arguments +startswith(path('/foo/bar'), '/foo') # OK: path coerced to string + +# Method call - no coercion on receiver +path('/foo/bar').startswith('/foo') # ERROR: no startswith(path, string) signature +'/foo/bar'.startswith('/foo') # OK: receiver is already string +``` + +This distinction exists because method syntax implies the receiver has a specific type that +supports the method. Allowing implicit coercion would silently convert the receiver to a +different type, potentially masking type errors and producing unexpected behavior. + +The restriction only applies to the first parameter. Other parameters in a method call +are still subject to normal implicit coercion rules: + +```yaml +# Second parameter can still be coerced +'hello'.replace('l', 'L') # OK: all args are strings +``` + +### List Literal Type Inference + +List literals infer their element type from context and contents. Since there is no `list[Any]` +type, the evaluation must determine a concrete `list[T]`. + +#### With Target Type Context + +When the target type set contains exactly one `list[T]` type (excluding `list[?]`), elements +are coerced non-destructively to `T` as described in [Implicit Type Coercion](#implicit-type-coercion). This applies recursively for +nested list literals. + +#### Without Target Type Context + +When no unambiguous target type is available, the element type is inferred from the values: + +1. **Homogeneous elements**: If all elements have the same type `T`, the result is `list[T]`. + +2. **int/float mix**: If elements are a mix of `int` and `float`, the result is `list[float]`. + Integer values are promoted to float. + +3. **Nested list int/float mix**: If elements are `list[int]` and `list[float]`, the result + is `list[list[float]]`. + +4. **Empty list**: The empty list `[]` evaluates to `list[?]`, which is implicitly convertible + to `list[T]` for any `T`. + +5. **Incompatible types**: If elements have incompatible types (e.g., `int` and `string`, + `bool` and `int`, scalar and `list`), evaluation fails with an error listing the + conflicting types. + +#### Null Values in List Literals + +A `null`/`None` value cannot be an element of a list literal. Including `null` in a list +is always an error: + +```yaml +# Error: null cannot be an element of a list literal +args: "{{ [1, null, 2] }}" +``` + +This restriction exists because: +- Lists have a concrete element type `T`, not `T?` +- The intent of `null` in a list is ambiguous (skip the element? include a null value?) +- Use conditional expressions or list comprehensions to conditionally include elements + +## Expression Parsing and Symbol Collection + +Implementations must provide two operations on expressions: + +1. **Parse and collect symbols** - Parse an expression and return the set of external symbols + it references, without evaluating it. +2. **Evaluate** - Parse and evaluate an expression given a symbol table. + +### Motivation for Symbol Collection + +Symbol collection enables static analysis of data flow through job templates. By knowing which +symbols each expression references, schedulers and tools can: + +- **Validate references** - Verify that all referenced variables exist at submission time, before + any tasks run. +- **Type check expressions** - When symbol types are known, verify that operations are valid for + those types. See [Static Type Checking](#static-type-checking). +- **Analyze data dependencies** - Track how values flow between parameters, environments, steps, + and other template blocks. This enables understanding which outputs from one step become inputs + to another. +- **Optimize data transfer** - When tasks run on different hosts, determine which computed values + must be transferred between hosts based on expression dependencies. +- **Enable incremental updates** - When a parameter changes, identify which expressions and + downstream computations are affected, enabling selective re-evaluation rather than full + recomputation. +- **Support visualization** - Build dependency graphs showing how data flows through a job, + helping users understand and debug complex templates. + +### Symbol Collection + +When parsing an expression, implementations collect all external symbol references. This enables +validation that referenced variables exist before evaluation. The collected symbols: + +- Include variable references like `Param.InputFile` or `Task.Param.Frame` +- Include property access chains like `Param.InputFile.stem` or `Param.File.parent.name` +- Exclude method names from calls (e.g., `Param.Name.upper()` collects `Param.Name`, not `Param.Name.upper`) +- Exclude loop variables defined in list comprehensions (e.g., `[x for x in Param.Items]` collects + `Param.Items`, not `x`) +- Exclude built-in function names like `min`, `max`, `len` + +**Examples:** + +| Expression | Collected Symbols | +|------------|-------------------| +| `Param.InputFile` | `{Param.InputFile}` | +| `Param.Start + Param.End` | `{Param.Start, Param.End}` | +| `Param.File.stem.upper()` | `{Param.File.stem}` | +| `Param.File.parent.parent.name` | `{Param.File.parent.parent.name}` | +| `[x * 2 for x in Param.Values]` | `{Param.Values}` | +| `[x for x in Param.Items if x > Param.Min]` | `{Param.Items, Param.Min}` | +| `min(Param.A, Param.B)` | `{Param.A, Param.B}` | + +### Function Call Collection + +When parsing an expression, implementations also collect all function and method calls. This enables +static analysis to determine which operations are performed, particularly for identifying whether +path mapping functions like `apply_path_mapping()` are called. + +The collected calls include: + +- Function calls like `min(x, y)` → `min` +- Method calls like `s.upper()` → `upper` +- Chained method calls like `s.split(',').join(';')` → `{split, join}` + +**Examples:** + +| Expression | Collected Calls | +|------------|-----------------| +| `Param.A + Param.B` | `{}` | +| `min(Param.A, Param.B)` | `{min}` | +| `Param.Name.upper()` | `{upper}` | +| `Param.File.stem.replace('a', 'b')` | `{replace}` | +| `RawParam.File.apply_path_mapping()` | `{apply_path_mapping}` | +| `Param.Items.split(',').join(';')` | `{split, join}` | +| `[str(x) for x in Param.Values]` | `{str}` | + +### Validation with Symbol Collection + +Template processors use symbol collection to validate expressions at submission time. For each +collected symbol, the validator checks if the symbol or any prefix of it exists in the available +symbol table. This allows property access like `Param.File.stem` to validate successfully when +`Param.File` is defined, even though `Param.File.stem` itself is not a defined variable. + +### Static Type Checking + +Beyond validating that symbols exist, implementations perform static type checking on expressions +at template validation time. Note that the type system is dynamic, but we can catch many +type errors early—before any tasks run—providing faster feedback and clearer error messages +that point to the exact location of the problem. + +**Benefits of Early Type Checking:** + +- **Fail-fast validation** - Type errors are caught at submission time, not when a task fails + hours into a job. This satisfies the "Fail-Fast Errors" technical requirement. +- **Precise error locations** - Error messages include caret pointers showing exactly where the + type mismatch occurs within the expression. +- **Complete coverage** - All expressions are type-checked, including those in host-context + scopes that are not evaluated until task runtime. + +**Type Checking Host-Context Expressions:** + +Some expressions reference symbols that are only available at task runtime, such as +`Task.Param.`, `Session.WorkingDirectory`, or `Task.File.`. These expressions +cannot be evaluated at submission time, but they can still be type-checked. + +The type checker uses a type symbol table that maps symbol names to their declared types. +For host-context expressions: + +1. The validator knows the types of all symbols from their declarations (e.g., a `PATH` parameter + has type `path`, a task parameter with `type: INT` has type `int`). +2. The expression is parsed and type-checked against these declared types. +3. Type errors are reported at submission time, even though the expression won't be evaluated + until a task runs on a worker. + +**Example:** + +```yaml +parameterDefinitions: + - name: Count + type: INT +steps: + - name: Process + script: + actions: + onRun: + command: echo + args: + - "{{ Param.Count.upper() }}" # Type error: 'upper' is a string method, not int +``` + +This expression references `Param.Count` which has type `int`. The method `upper()` is only +defined for strings. At submission time, the type checker reports: + +``` +Method 'upper' not found + Param.Count.upper() + ~~~~~~~~~~~~^~~~~ +``` + +**Host-Context Function Availability:** + +Certain functions are only available in host-context scopes (SESSION and TASK) where they can +access runtime resources. For example, `apply_path_mapping()` requires access to the session's +path mapping rules, which are not available at submission time. + +The type checker uses the appropriate function library based on the expression's scope: +- **Submission context (TEMPLATE scope)**: Default function library +- **Host context (SESSION/TASK scope)**: Extended library including `apply_path_mapping()` + +This allows the type checker to correctly validate that `apply_path_mapping()` is only used +in contexts where it will be available at runtime. + +## Built-in Symbols and Types + +Expressions have access to symbols provided by the runtime context. This section documents the +types of these symbols when used in expressions. + +### Job Parameter Types + +Job parameters defined in `parameterDefinitions` are available via `Param.` and +`RawParam.`. The expression type corresponds to the parameter's declared type: + +| Parameter Type | Expression Type | +|----------------|-----------------| +| `STRING` | `string` | +| `INT` | `int` | +| `FLOAT` | `float` | +| `PATH` | `path` | +| `BOOL` | `bool` | +| `RANGE_EXPR` | `range_expr` | +| `LIST[STRING]` | `list[string]` | +| `LIST[INT]` | `list[int]` | +| `LIST[FLOAT]` | `list[float]` | +| `LIST[PATH]` | `list[path]` | +| `LIST[BOOL]` | `list[bool]` | +| `LIST[LIST[INT]]` | `list[list[int]]` | + +Type names are case-insensitive (e.g., `string`, `String`, `STRING` are equivalent). + +For `PATH` parameters, `Param.` has type `path` with path mapping rules applied, while +`RawParam.` has type `string` containing the original unmapped value. The raw value is +a string because it may be a path for a different operating system that cannot be parsed as +a local path. Similarly for `LIST[PATH]`, `Param.` is `list[path]` while `RawParam.` +is `list[string]`. + +### Task Parameter Types + +Task parameters defined in `taskParameterDefinitions` are available via `Task.Param.` +and `Task.RawParam.`. The expression type corresponds to the parameter's declared type: + +| Task Parameter Type | Expression Type | +|---------------------|-----------------| +| `INT` | `int` | +| `STRING` | `string` | +| `PATH` | `path` | +| `CHUNK[INT]` | `range_expr` | + +Note: `CHUNK[INT]` produces a `range_expr` type, not `list[int]`, enabling efficient +representation of frame ranges. Use `list(Task.Param.Frame)` to convert to a list if needed. + +### Session Symbols + +Session-scoped symbols are available within Environment and Step Script contexts: + +| Symbol | Type | Description | +|--------|------|-------------| +| `Session.WorkingDirectory` | `path` | The session's temporary working directory | +| `Session.PathMappingRulesFile` | `path` | Path to the JSON file containing path mapping rules | +| `Session.HasPathMappingRules` | `bool` | Whether path mapping rules are available | + +### Embedded File Symbols + +Embedded files are available as paths to their written locations: + +| Symbol | Type | Description | +|--------|------|-------------| +| `Task.File.` | `path` | Location of the embedded file within a Step Script | +| `Env.File.` | `path` | Location of the embedded file within an Environment | + +### Type Implications for Path Operations + +Since `Session.WorkingDirectory`, `Task.File.`, and `Env.File.` are `path` typed, +they support path operations: + +```yaml +# Path concatenation with / +mkdir -p {{repr_sh(Session.WorkingDirectory / 'output')}} + +# Path properties +echo "Script: {{Task.File.Run.name}}" +``` + +## Expression Evaluation Algorithm + +### Memory-Bounded Evaluation + +Expression evaluation must operate within bounded memory to support constrained execution +environments and predictable resource usage when evaluating many expressions concurrently +across threads. + +Implementations accept an optional `memory_limit` parameter (default: 10MB recommended). +During evaluation, the evaluator tracks the memory size of live values—incrementing when +values are created, decrementing when intermediate values are consumed by operations. +If current memory exceeds the limit at any point, evaluation fails with an error. + +**Value Size Calculation:** + +The size of a value is `sizeof(ExprValue) + variable_data_size` where `variable_data_size` is: + +| Type | Variable Data Size | +|------|-------------------| +| `null`, `bool`, `int`, `float` | 0 | +| `string`, `path` | length in bytes (UTF-8) | +| `range_expr` | 16 × number of range segments | +| `list[T]` | sum of element sizes | + +**Memory Tracking During Evaluation:** + +When evaluating a binary operation like `left + right`: +1. Evaluate `left` → add `size(left)` to current memory +2. Evaluate `right` → add `size(right)` to current memory +3. Compute `result` +4. Release `left` and `right` → subtract their sizes +5. Add `size(result)` to current memory +6. Check if current memory exceeds limit + +This models actual memory pressure—what is live at any moment—rather than cumulative +allocations. The limit reflects real resource usage during evaluation. + +**Example:** + +For `"a" * 10000000`, the result string would be ~10MB. With a 10MB limit, this fails +at step 5 when the result size is added. + +For `[x for x in range(10000000)]`, the list grows element by element. The limit is +checked as each element is added, failing once the accumulated list size exceeds it. + +### Value Data Structure + +A value during expression evaluation is a discriminated union holding one value of a specific type. +The type is identified by an integer type code enumeration: + +| Type Code | Type | Type Parameter | +|-----------|------|----------------| +| `NULLTYPE` | `?` | - | +| `BOOL` | `bool` | - | +| `INT` | `int` | - | +| `FLOAT` | `float` | - | +| `STRING` | `string` | - | +| `PATH` | `path` | - | +| `RANGE_EXPR` | `range_expr` | - | +| `LIST` | `list[T]` | The element type `T` | +| `ANY` | `any` | - | +| `UNION` | `S \| T` | The member types | + +The `NULLTYPE` type code represents the type of a null value (`?`). Optional types like `T?` +are represented as `UNION` types with `NULLTYPE` as one member (e.g., `int?` = `int | ?`). +The `list[?]` type (empty list) uses `NULLTYPE` for the element type parameter. + +The `ANY` type code represents an unconstrained type during type checking. It matches any +concrete type and is used when type information is unavailable. In union normalization, +`ANY` absorbs all other types (e.g., `int | any` normalizes to `any`). + +The `UNION` type code represents a union of possible types. Unions are normalized: +- Nested unions are flattened: `(int | string) | bool` becomes `int | string | bool` +- Type parameters are sorted alphabetically with `?` at the end +- Duplicate types are removed +- Single-element unions are unwrapped: `int | ` (one element) becomes `int` +- `ANY` absorbs everything: `int | any` becomes `any` + +The `ExprType` structure represents a type: + +``` +ExprType: + type_code: TypeCodeEnum + type_params: list[ExprType] +``` + +The `ExprValue` structure holds a value. The fields shown are the internal representation; +implementations may expose these through accessor methods rather than direct field access: + +```yaml +ExprValue: + type: ExprType + is_null: bool + _bool_value: bool + _int_value: big integer + _float_value: 64-bit IEEE floating point + _string_value: unicode string + _range_expr_value: IntRangeExpr + _list_value: list[ExprValue] +``` + +The `IntRangeExpr` type represents a parsed range expression as a sorted list of integer +ranges. See the [openjd-model IntRangeExpr implementation](https://github.com/OpenJobDescription/openjd-model-for-python/blob/mainline/src/openjd/model/_range_expr.py) +for an example. It must support: +- Parsing from a string, converting to a string +- Iteration over the integer list it represents +- Random-access indexing into the integer list it represents +- Getting its length + +Valid states for an `ExprValue` `ev` based on `ev.type.type_code`: + +| Type | Type Code | `is_null` | Active Field | `type_params` | +|------|-----------|-----------|--------------|---------------| +| `?` | `NULLTYPE` | `true` | - | `[]` | +| `bool` | `BOOL` | `false` | `_bool_value` | `[]` | +| `int` | `INT` | `false` | `_int_value` | `[]` | +| `float` | `FLOAT` | `false` | `_float_value`, `_string_value` | `[]` | +| `string` | `STRING` | `false` | `_string_value` | `[]` | +| `path` | `PATH` | `false` | `_string_value` | `[]` | +| `range_expr` | `RANGE_EXPR` | `false` | `_range_expr_value` | `[]` | +| `list[T]` | `LIST` | `false` | `_list_value` | `[T]` | + +Note: `ANY` and `UNION` are type-level constructs used during type checking. They do not +appear as the type of a concrete `ExprValue` at runtime—values always have a specific +concrete type. + +For `FLOAT`, `_float_value` contains the value used for calculations. The `_string_value` is +either empty (ignored) or contains the original representation before conversion to IEEE +64-bit float, implementing the "Float Value Pass-Through" design choice. + +### Expression AST + +The expression AST uses the same node names as Python's `ast` module, limited to the subset +required by the grammar. Each node type is listed with its fields. This is intended to +support implementation by using a Python AST parser such as [ast.parse](https://docs.python.org/3/library/ast.html#ast.parse) +in Python, the [ruff Python parser](https://github.com/astral-sh/ruff/tree/main/crates/ruff_python_parser) in Rust, +or [dt-python-parser](https://github.com/DTStack/dt-python-parser) in JS. + +`Expr` is the union of all expression node types: +`IfExp | BoolOp | UnaryOp | Compare | BinOp | Subscript | Call | Attribute | Name | Constant | List | ListComp` + +**Expression Nodes:** + +```yaml +IfExp: + test: Expr + body: Expr + orelse: Expr + +BoolOp: + op: BoolOp_Op + values: list[Expr] + +UnaryOp: + op: UnaryOp_Op + operand: Expr + +Compare: + left: Expr + ops: list[Compare_Op] + comparators: list[Expr] + +BinOp: + left: Expr + op: BinOp_Op + right: Expr + +Subscript: + value: Expr + slice: Expr | Slice # Single index or slice expression + +Slice: + lower: Expr? # Start index (None if omitted) + upper: Expr? # Stop index (None if omitted) + step: Expr? # Step value (None if omitted) + +Call: + func: Expr + args: list[Expr] # Python has keywords; we don't support keyword arguments + +Attribute: + value: Expr + attr: string + +Name: + id: string + +Constant: + value: ExprValue # Python uses raw Python values; we use ExprValue + +List: + elts: list[Expr] + +ListComp: + elt: Expr + generator: Comprehension # Python has list[comprehension]; we allow only one + +Comprehension: + target: string # Python allows arbitrary patterns; we only allow a single identifier + iter: Expr + ifs: list[Expr] # Python has is_async; we don't support async +``` + +**Operator Enumerations:** + +``` +BoolOp_Op: And | Or +UnaryOp_Op: Not | UAdd | USub +Compare_Op: Lt | Gt | LtE | GtE | Eq | NotEq +BinOp_Op: Add | Sub | Mult | Div | FloorDiv | Mod | Pow +``` + +### AST Transformation to Uniform Function Calls + +Before evaluation, the AST is transformed to convert operators and method calls into uniform +function call syntax. This simplifies the evaluator by reducing all operations to function calls. + +**Transformation Rules** + +1. **Binary operators** `BinOp(left, op, right)` become `Call(Name("__op__"), [left, right])` + where `__op__` is the operator name: + - `Add` → `__add__`, `Sub` → `__sub__`, `Mult` → `__mul__`, `Div` → `__truediv__` + - `FloorDiv` → `__floordiv__`, `Mod` → `__mod__`, `Pow` → `__pow__` + +2. **Unary operators** `UnaryOp(op, operand)` become `Call(Name("__op__"), [operand])`: + - `UAdd` → `__pos__`, `USub` → `__neg__`, `Not` → `__not__` + +3. **Comparison operators** `Compare(left, [op], [right])` become `Call(Name("__op__"), [left, right])`: + - `Lt` → `__lt__`, `Gt` → `__gt__`, `LtE` → `__le__`, `GtE` → `__ge__` + - `Eq` → `__eq__`, `NotEq` → `__ne__` + +4. **Subscript** `Subscript(value, index)` where `index` is an `Expr` becomes `Call(Name("__getitem__"), [value, index])` + +5. **Subscript with slice** `Subscript(value, Slice(lower, upper, step))` becomes + `Call(Name("__getitem__"), [value, lower_or_none, upper_or_none, step_or_none])` + where omitted bounds are passed as `Constant(None)`. + +6. **Boolean operators** `BoolOp(op, [a, b, ...])` become nested calls: + - `And` → `__and__(a, __and__(b, ...))`, `Or` → `__or__(a, __or__(b, ...))` + +7. **Method calls** `Call(Attribute(value, method), args)` become `Call(Name(method), [value] + args)` + +**Error Message Quality** + +When transforming nodes, implementations should preserve the original AST node or relevant +source information (e.g., attribute name, operator symbol, source location) in the transformed +node. This allows error messages to reference the original syntax rather than internal names. +For example, an error about `p.stem` should mention "property 'stem'" rather than +"`__property_stem__`". + +### Expression Evaluation + +**Overview** + +Expression evaluation is a recursive traversal of the AST that propagates type constraints +downward and returns `ExprValue` results upward. + +1. Start at the root with a target TypeSet from context (e.g., `{INT}` for a required integer + field, `{?, STRING, LIST[STRING]}` for a list item in args) +2. For each node, transform the TypeSet appropriately for child nodes +3. Recursively evaluate children +4. At leaf nodes, look up the value (constant or variable from symbol table) +5. When a node has all child `ExprValue` results, evaluate it: + - If child types are directly compatible, perform the operation + - Otherwise, compute coercible types for each child and resolve the best match +6. Return the final `ExprValue` + +**Target Type Propagation Rules** + +Target types guide type inference and coercion, but should not constrain operand evaluation +for operators with fixed signatures. The rules for propagating target types to child nodes: + +| Node Type | Target Type Propagation | +|-----------|------------------------| +| `IfExp` | `test`: `{BOOL}`, `body`/`orelse`: inherit parent target types | +| `BoolOp` (`and`/`or`) | All operands: `{BOOL}` | +| `Compare` | All operands: `None` (unconstrained) | +| `BinOp` | All operands: `None` (unconstrained) | +| `UnaryOp` | Operand: `None` (unconstrained) | +| `Call` (function) | Arguments: computed from candidate signatures | +| `Call` (method) | Receiver: `None`, other args: computed from signatures | +| `Subscript` | Value: `None`, index/slice: `{INT}` or `{INT?}` | +| `List` | Elements: element type extracted from parent target types | +| `ListComp` | Element expr: element type from parent, iter: `None`, conditions: `{BOOL}` | +| `Attribute`/`Name` | Not propagated (leaf nodes return their stored type) | +| `Constant` | Not propagated (literals have intrinsic types) | + +**Rationale:** Arithmetic operators like `+`, `-`, `*`, `/` have fixed signatures operating on +numeric types. Propagating a `{STRING}` target type to arithmetic operands would incorrectly +constrain the operand evaluation. Instead, operands are evaluated without type constraints, +the operation is performed, and the result is coerced to the target type if needed. + +For example, in `"{{ Param.Count - 1 }}"` where the target type is `{STRING}`: +1. `Param.Count` is evaluated unconstrained → returns `int` +2. `1` is evaluated unconstrained → returns `int` +3. `__sub__(int, int)` is called → returns `int` +4. Result `int` is coerced to `string` for the target context + +**Symbol Table** + +It requires a symbol table mapping names to either child tables or values: + +``` +SymbolTable: dict[string, SymbolTableEntry] +SymbolTableEntry: SymbolTable | ExprValue +``` + +For example, a symbol table with parameters `Param.InputFile: path` and `Param.OutputFile: path`: +```python +{"Param": {"InputFile": ExprValue(PATH, ...), "OutputFile": ExprValue(PATH, ...)}} +``` + +To support scoping for the list comprehension evaluation, recursive evaluation accepts a list +of symbol tables in local to global order. + +**Pseudo-code** + +```python +TypeSet = Optional[set[ExprType]] # None means unconstrained + +def evaluate_expression( + node: Expr, + ts: TypeSet, + symtabs: list[SymbolTable] +) -> ExprValue: + match node: + case IfExp(test, body, orelse): + test_val = evaluate_expression(test, {BOOL}, symtabs) + if test_val.item(): + return evaluate_expression(body, ts, symtabs) + else: + return evaluate_expression(orelse, ts, symtabs) + + case Call(Name(func), args): + # Function call: f(x, y, ...) + # Operators (transformed from BinOp/UnaryOp/Compare) evaluate operands unconstrained + is_operator = func.startswith("__") and func.endswith("__") + + if is_operator: + # Evaluate operands without target type constraints + arg_values = [evaluate_expression(arg, None, symtabs) for arg in args] + else: + # Regular function: find candidates and compute arg typesets + candidates = [sig for sig in FUNCTION_SIGNATURES[func] + if len(sig.param_types) == len(args) + and (ts is None or sig.return_type in ts + or can_coerce(sig.return_type, ts))] + if not candidates: + raise TypeError(f"No matching signature for {func}") + + # Compute TypeSet for each argument position + arg_typesets = [] + for i in range(len(args)): + arg_ts = {sig.param_types[i] for sig in candidates} + arg_typesets.append(arg_ts) + + # Evaluate arguments with computed TypeSets + arg_values = [evaluate_expression(args[i], arg_typesets[i], symtabs) + for i in range(len(args))] + + # Find best matching signature and call (coercion allowed on all args) + return resolve_and_call(func, arg_values, is_method_call=False) + + case Call(Attribute(value, attr), args): + # Method call: x.f(y, ...) -> f(x, y, ...) + # Evaluate receiver and remaining arguments + receiver = evaluate_expression(value, None, symtabs) + all_args = [receiver] + [evaluate_expression(arg, None, symtabs) for arg in args] + + # Find candidate signatures + candidates = [sig for sig in FUNCTION_SIGNATURES[attr] + if len(sig.param_types) == len(all_args)] + if not candidates: + raise TypeError(f"No matching signature for {attr}") + + # Find best matching signature (no coercion on receiver) + return resolve_and_call(attr, candidates, all_args, is_method_call=True) + + case Attribute() | Name(): + value = lookup_variable(node, symtabs) + check_type_compatible(value.type, ts) + return value + + case Constant(value): + check_type_compatible(value.type, ts) + return value + + case List(elts): + elem_ts = extract_element_typeset(ts) + elem_values = [evaluate_expression(e, elem_ts, symtabs) for e in elts] + elem_type = resolve_list_element_type(elem_values) + return ExprValue(LIST[elem_type], coerce_all(elem_values, elem_type)) + + case ListComp(elt, generator): + elem_ts = extract_element_typeset(ts) + iter_val = evaluate_expression(generator.iter, None, symtabs) + if iter_val.type.type_code not in (LIST, RANGE_EXPR): + raise TypeError("List comprehension requires list or range_expr") + + # Get iterable items + if iter_val.type.type_code == LIST: + items = iter_val.to_expr_value_list() + else: # RANGE_EXPR + items = [ExprValue(i) for i in iter_val.item()] + + results = [] + for item in items: + # Create new scope with loop variable + local_symtab = {generator.target: item} + local_symtabs = [local_symtab] + symtabs + + # Check filter conditions + skip = False + for cond in generator.ifs: + cond_val = evaluate_expression(cond, {BOOL}, local_symtabs) + if not cond_val.item(): + skip = True + break + if skip: + continue + + # Evaluate element expression + results.append(evaluate_expression(elt, elem_ts, local_symtabs)) + + elem_type = resolve_list_element_type(results) + return ExprValue(LIST[elem_type], coerce_all(results, elem_type)) + +def lookup_variable(node: Attribute | Name, symtabs: list[SymbolTable]) -> ExprValue: + path = collect_attribute_path(node) # e.g., ["Param", "InputFile", "stem"] + + # Try full path first + value = lookup_path(path, symtabs) + if value is not None: + return value + + # Try all-but-last as variable, last as property + if len(path) > 1: + base_value = lookup_path(path[:-1], symtabs) + if base_value is not None: + prop_name = path[-1] + prop_func = f"__property_{prop_name}__" + return call_function(prop_func, [base_value]) + + raise NameError(f"Undefined variable: {'.'.join(path)}") + +def lookup_path(path: list[str], symtabs: list[SymbolTable]) -> Optional[ExprValue]: + for symtab in symtabs: + result = symtab + for name in path: + if isinstance(result, dict) and name in result: + result = result[name] + else: + result = None + break + if isinstance(result, ExprValue): + return result + return None + +def resolve_and_call( + func: str, + candidates: list[Signature], + arg_values: list[ExprValue], + is_method_call: bool = False +) -> ExprValue: + # Try direct match first + for sig in candidates: + if all(arg_values[i].type == sig.param_types[i] for i in range(len(arg_values))): + return call_function(func, arg_values, sig) + + # Try with coercion + # For method calls, skip coercion on first argument (receiver) + for sig in candidates: + can_match = True + coerced = [] + for i in range(len(arg_values)): + if is_method_call and i == 0: + # Receiver must match exactly for method calls + if arg_values[i].type != sig.param_types[i]: + can_match = False + break + coerced.append(arg_values[i]) + elif can_coerce(arg_values[i].type, sig.param_types[i]): + coerced.append(coerce(arg_values[i], sig.param_types[i])) + else: + can_match = False + break + if can_match: + return call_function(func, coerced, sig) + + raise TypeError(f"No matching signature for {func} with given argument types") + +def extract_element_typeset(ts: TypeSet) -> TypeSet: + if ts is None: + return None + return {t.type_params[0] for t in ts if t.type_code == LIST} + +def can_coerce(from_type: ExprType, to_type: ExprType) -> bool: + if from_type == to_type: + return True + # int -> float + if from_type.type_code == INT and to_type.type_code == FLOAT: + return True + # int, float, bool, path -> string (in string contexts) + if to_type.type_code == STRING and from_type.type_code in (INT, FLOAT, BOOL, PATH): + return True + return False +``` + +### Operators, Built-in Functions, and Property Access + +The operators and built-in functions available in expressions are defined in +[RFC 0006: Expression Function Library](0006-expression-function-library.md). Uniform +function call syntax, and the operator naming scheme documented there enable this. + +### List Comprehensions + +Simple list comprehensions are supported for transforming and filtering lists. Python's nesting within +a list comprehension is not supported. + +``` +[expr for var in list] +[expr for var in list if condition] +``` + +The loop variable (`var`) must start with a lowercase letter or underscore, matching the +`` rule. This ensures it cannot shadow spec-defined symbols like `Param` or `Task`. +A list comprehension binding that shadows an existing binding is an error. + +Examples: +- `[['-e', e] for e in Task.Environment]` transforms `["A=1", "B=2"]` into + `[["-e", "A=1"], ["-e", "B=2"]]`. +- `[x for x in Param.Values if x > 0]` filters to only positive values. + +### Slicing + +Slicing extracts a subset of elements from lists, strings, or range expressions using Python-style slice +notation `[start:stop:step]`. All three components are optional: + +- `start`: Starting index (inclusive), defaults to 0 (or end if step is negative) +- `stop`: Ending index (exclusive), defaults to length (or -length-1 if step is negative) +- `step`: Step between elements, defaults to 1 + +Negative indices count from the end: `-1` is the last element, `-2` is second-to-last, etc. + +Note: The `path` type does not support subscript or slice operations, matching Python's `pathlib.Path` +behavior. Use `p.parts` to get path components as a list, which can then be sliced. + +| Expression | Description | +|------------|-------------| +| `v[1:4]` | Elements at indices 1, 2, 3 | +| `v[:3]` | First 3 elements | +| `v[2:]` | All elements from index 2 to end | +| `v[::2]` | Every other element | +| `v[::-1]` | Reversed | +| `v[-3:]` | Last 3 elements | +| `v[1:-1]` | All except first and last | + +Examples: +- `[1, 2, 3, 4, 5][1:4]` returns `[2, 3, 4]` +- `"hello"[1:4]` returns `"ell"` +- `path("/a/b/c/d").parts[1:]` returns `["a", "b", "c", "d"]` +- `range_expr("1-10")[::2]` returns `[1, 3, 5, 7, 9]` + +### Conditional Expression Semantics + +The conditional expression ` if else `: + +1. Evaluates `` first. The `` value must be a `bool`, there is no "truthy" concept like in Python. +2. If `` is `True`, then return ``. +3. If Otherwise, evaluates and returns `` + +### Error Handling + +Expression evaluation errors result in a job failure with a descriptive error message. +Because expressions can be evaluated at different times, e.g. during submission or +while evaluating the state of a step or task, these errors can happen at various phases +of running a job. Errors include: + +- Type errors (e.g., adding string to int) +- Division by zero +- Index out of bounds +- Unknown function or variable reference +- Syntax errors + +### Backward Compatibility + +Templates not using the `EXPR` extension continue to use the existing simple +value reference syntax. The extension must be explicitly requested: + +```yaml +specificationVersion: 'jobtemplate-2023-09' +extensions: + - EXPR +``` + +### Specification Model Changes + +The EXPR extension introduces changes to the specification model to support expressions +in additional contexts. + +#### ListExpressionString Type + +A new format string type `ListExpressionString` is introduced for fields that accept +expressions evaluating to lists. This type: + +- Accepts a format string containing an expression (e.g., `"{{ [1.0, 2.0, 3.0] }}"`) +- Evaluates to a list of values when the EXPR extension is enabled +- Is used in contexts where a list literal was previously required + +#### Task Parameter Range Field Extensions + +The `range` field for FLOAT, STRING, and PATH task parameter definitions is extended +to accept `ListExpressionString` in addition to list literals: + +| Parameter Type | Original `range` Type | Extended `range` Type | +|---------------|----------------------|----------------------| +| INT | `list[int \| FormatString] \| RangeString` | (unchanged) | +| FLOAT | `list[Decimal \| FormatString]` | `list[Decimal \| FormatString] \| ListExpressionString` | +| STRING | `list[FormatString]` | `list[FormatString] \| ListExpressionString` | +| PATH | `list[FormatString]` | `list[FormatString] \| ListExpressionString` | + +This enables expressions that compute lists dynamically: + +```yaml +parameterDefinitions: + - name: Scale + type: FLOAT + default: "2.5" +steps: + - name: Process + parameterSpace: + taskParameterDefinitions: + - name: Factor + type: FLOAT + # Expression evaluates to [5.0, 3.0] + range: "{{ [Param.Scale * 2, Param.Scale + 0.5] }}" +``` + +When the EXPR extension is enabled, the `ListExpressionString` is evaluated at job +creation time, producing a list that populates the task parameter range. + +#### Let Bindings + +The EXPR extension adds an optional `let` field to `StepTemplate`, `StepScript`, `SimpleAction`, +and `EnvironmentScript` for binding expressions to names. This avoids repeating complex expressions +across multiple fields. + +##### `` + +```yaml +let: [ , ... ] # @optional +``` + +An ordered array of let bindings. Bindings are evaluated in declaration order; later +bindings can reference names from earlier bindings. A binding that shadows a previous +binding in the same `let` block is an error. + +##### `` + +Each binding is a string using Python assignment syntax: + +```bnf + ::= *"="* + ::= [a-z_][A-Za-z0-9_]* + ::= whitespace character: tabs or spaces +``` + +The `` must start with a lowercase letter or underscore. This ensures +user-defined names never conflict with spec-defined symbols (`Param`, `Task`, `Session`, +`Env`, `RawParam`), which always start with an uppercase letter. The same constraint +applies to loop variables in list comprehensions. + +Constraints on ``: +- Minimum length: 1 character +- Maximum length: 512 characters + +The type of the binding is inferred from the expression's result type. + +Examples: +- `x = Param.Value + 1` - `x` has type `int` if `Param.Value` is `int` +- `files = [Param.Dir / f for f in Param.Names]` - `files` has type `list[path]` + +##### Type Checking of Let Bindings + +Let binding expressions are type-checked at template validation time (e.g., during +`decode_job_template`), not deferred to evaluation time. This provides early detection +of type errors with precise error messages. + +Type checking is performed sequentially: each binding's expression is type-checked +against the types of symbols available at that point, and the inferred result type +is then made available for subsequent bindings. This enables type propagation through +chained bindings: + +```yaml +let: + - x = Param.Count # x inferred as int (from INT parameter) + - y = x + 1 # y inferred as int (int + int = int) + - z = string(y) # z inferred as string + - bad = y + "hello" # TYPE ERROR: int + string +``` + +The error message includes the binding context and a caret pointing to the error: + +``` +Cannot use '+' with int and string + bad = y + "hello" + ~~^~~~~~~~~ +``` + +##### StepTemplate Extension + +When `let` appears in a `StepTemplate`, bindings are evaluated once per step. The bound +names are available in `stepEnvironments`, `parameterSpace`, `hostRequirements`, and `script` fields: + +```yaml +steps: + - name: ProcessTiles + let: + - max_u = (proto_udim - 1001) % 10 + - max_v = (proto_udim - 1001) // 10 + parameterSpace: + taskParameterDefinitions: + - name: TileU + type: INT + range: "0-{{ max_u }}" + - name: TileV + type: INT + range: "0-{{ max_v }}" +``` + +##### ScriptTemplate Extension + +When `let` appears in a `ScriptTemplate`, bindings are evaluated once per task (or once +per environment action). The bound names are available in `actions` and can reference +`Task.Param`: + +```yaml +script: + let: + - output_file = Param.OutputDir / Param.Pattern.with_number(Task.Param.Frame) + actions: + onRun: + command: render + args: ["--output", "{{ output_file }}"] +``` + +Environment scripts use the same mechanism: + +```yaml +environments: + - name: Setup + script: + let: + - work_dir = Param.OutputDir / 'work' + actions: + onEnter: + command: mkdir + args: ["-p", "{{ work_dir }}"] +``` + +##### SimpleAction Extension + +When using the `FEATURE_BUNDLE_1` syntax sugar for scripts (`bash`, `python`, `cmd`, `powershell`, +`node`), `let` bindings can be included directly in the ``. The bindings are +evaluated once per task and can reference `Task.Param.*`: + +```yaml +steps: + - name: RenderFrame + parameterSpace: + taskParameterDefinitions: + - name: Frame + type: INT + range: "{{Param.Frames}}" + bash: + let: + - output_file = Param.OutputDir / Param.Pattern.with_number(Task.Param.Frame) + script: | + render --output {{repr_sh(output_file)}} +``` + +This is equivalent to using `let` in the expanded `` form: + +```yaml +steps: + - name: RenderFrame + parameterSpace: + taskParameterDefinitions: + - name: Frame + type: INT + range: "{{Param.Frames}}" + script: + let: + - output_file = Param.OutputDir / Param.Pattern.with_number(Task.Param.Frame) + embeddedFiles: + - name: Script + type: TEXT + data: | + render --output {{repr_sh(output_file)}} + actions: + onRun: + command: bash + args: ["{{Task.File.Script}}"] +``` + +## Design Choice Rationale + +### Python Expression Subset + +The expression syntax is a subset of Python expressions. This choice: + +1. Provides familiar syntax for users +2. Enables implementation using Python's `ast` module for parsing +3. Allows potential implementations in other languages using Python grammar parsers +4. Avoids inventing new syntax that users must learn + +The subset is intentionally limited to prevent: +- Arbitrary code execution +- Side effects (with the exception of `fail()` which terminates evaluation with an error) +- Complex control flow + +### Contextual Keywords + +Keywords are contextual to ensure backward compatibility. A parameter named `if` can still +be accessed as `Param.if` because `if` is only a keyword in operator position, not after `.`. + +### No Assignment or Statements + +The expression language is purely functional with no assignment or statements. This: + +1. Keeps templates declarative +2. Prevents complex logic that belongs in scripts +3. Simplifies implementation and validation + +## Prior Art + +### Jinja2 + +[Jinja2](https://jinja.palletsprojects.com/) is a full-featured templating engine for Python. +Open Job Description's `{{ }}` syntax is inspired by Jinja2. This RFC extends toward Jinja2's +expression capabilities while remaining a strict subset. + +### AWS CloudFormation Intrinsic Functions + +[CloudFormation](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/intrinsic-function-reference.html) +uses intrinsic functions like `!Sub`, `!If`, `!Equals` for template logic. This RFC takes a +more expression-oriented approach rather than function-based. + +### GitHub Actions Expressions + +[GitHub Actions](https://docs.github.com/en/actions/learn-github-actions/expressions) uses +`${{ }}` syntax with expressions supporting operators, functions, and conditionals. This RFC +follows a similar philosophy. + +### Workflow Description Language (WDL) + +[WDL](https://github.com/openwdl/wdl) is a workflow language for bioinformatics. EXPR +shares similar primitive types and math functions, but excludes WDL's file I/O functions and +complex types (`Pair`, `Map`, `Struct`). + +### Common Workflow Language (CWL) + +[CWL](https://www.commonwl.org/) uses simple parameter references (`$(inputs.foo)`) with optional +JavaScript expressions, that it recommends minimizing. EXPR aligns with +this philosophy, defining an expression DSL and leaving complex code to run as tasks. + +### Nextflow + +[Nextflow](https://nextflow.io/docs/latest/) is a Groovy-based workflow language. EXPR +proposes a much more limited expression language, compared to the generality of Groovy. + +### Argo Workflows + +[Argo Workflows](https://argo-workflows.readthedocs.io/) uses a dual-mode expression system with +simple tags and [Expr](https://expr-lang.org/)-powered expressions. EXPR shares this +philosophy but opts to define a DSL tailored to fit the job template specification. + +### 7. Regular Expression Functions with Capture Groups + +The regex API provides four functions that align with Python's `re` module semantics: + +| Function | Signature | Description | +|----------|-----------|-------------| +| `re_match` | `(s: string, pattern: string) -> list[string]?` | Match at START of string | +| `re_search` | `(s: string, pattern: string) -> list[string]?` | Match ANYWHERE in string | +| `re_findall` | `(s: string, pattern: string) -> list[string] \| list[list[string]]` | All non-overlapping matches | +| `re_replace` | `(s: string, pattern: string, repl: string) -> string` | Replace all matches | + +The `re_match` and `re_search` functions return `null` when no match is found, or a list +containing the full match at index 0 followed by captured group values. This design enables: + +1. **Boolean checks**: `re_search(s, pattern) != null` +2. **Group extraction**: `re_search(filename, r"_v(\d+)")[1]` extracts version number +3. **Safe access with defaults**: `(re_search(s, pattern) or ["", "default"])[1]` + +The `list[string]?` return type uses the union type system (`list[string] | ?`) to express +that the result may be null. + +**Examples from VFX workflows:** + +```python +# Extract version number from filename (full match at [0], group at [1]) +re_search("asset_v042_final.abc", r"_v(\d+)") # returns ["_v042", "042"] + +# Match at start only +re_match("v042_final", r"v(\d+)") # returns ["v042", "042"] +re_match("asset_v042", r"v(\d+)") # returns null (not at start) + +# Extract UDIM tile number (access captured group at index 1) +re_search("diffuse.1023.tx", r"\.(10\d{2})\.")[1] # returns "1023" + +# Find all shot numbers +re_findall("shot010_shot020_shot035_comp.nk", r"shot(\d+)") # returns ["010", "020", "035"] +``` + +Combined with `let` bindings, regex extraction becomes practical for complex workflows: + +```yaml +steps: + - name: ProcessTiles + let: + - udim = int(re_search(Param.ProtoTile.stem, r'\.(10\d{2})')[0]) + - max_u = (udim - 1001) % 10 + - max_v = (udim - 1001) // 10 + parameterSpace: + taskParameterDefinitions: + - name: TileU + type: INT + range: "0-{{ max_u }}" +``` + +## Open Design Questions + +### PATH Type Format in Non-Host Contexts + +For PATH and LIST[PATH] job parameters, `Param.*` is only accessible in host contexts (SESSION and TASK scopes) where path mapping can be applied. However, `RawParam.*` is accessible in all contexts including TEMPLATE scope (e.g., parameter space ranges). + +**Resolution:** The `path` type always uses POSIX format (forward slashes) in non-host contexts. This provides: +- Predictable behavior regardless of submission machine OS +- A canonical format that template authors can rely on +- Easier string manipulation (no escaping backslashes) +- Conversion to the host's native format happens at runtime in host contexts + +The evaluator accepts a `path_format` parameter that controls the output format of `path` type values: +- `PathFormat.POSIX` - Always use forward slashes (default for TEMPLATE scope) +- `PathFormat.WINDOWS` - Use backslashes for Windows-style paths +- `None` - Use the system's native format (default for SESSION/TASK scopes) + +UNC paths (e.g., `\\server\share`) are preserved as-is when the path format is WINDOWS. + +## Rejected Ideas + +### Full Jinja2 Support + +Using the complete Jinja2 language was considered but rejected because: + +1. Jinja2 includes control flow (for loops, macros) that would complicate job templates +2. Security concerns with sandboxing arbitrary template logic +3. Implementation complexity across different languages/platforms + +### Custom Expression Language + +Designing a completely new expression syntax was rejected in favor of Python subset because: + +1. Python syntax is widely known +2. Existing parsing tools are available +3. Reduces learning curve for users + +### Implicit Type Coercion + +Automatic type coercion (e.g., `"5" + 3` = `8`) was rejected because: + +1. Can lead to subtle bugs +2. Makes template behavior less predictable +3. Explicit conversion functions (`int()`, `string()`) are clearer + +### Walrus Operator for Variable Binding + +Python's assignment expression operator (`:=`) was considered for binding intermediate values +within expressions: + +```yaml +- "{{ m[1] if (m := re_search(Param.Filename, r'_v(\d+)')) != null else 'v001' }}" +``` + +This was rejected because: + +1. Only helps within a single expression—doesn't solve sharing values across multiple fields +2. The schema-level `let` bindings provide a cleaner, more explicit approach + +## Appendix A: Backward Compatibility Analysis + +This section provides detailed analysis supporting Technical Requirement #1. + +### Current Format String Syntax + +```bnf +{{ }} + ::= + ::= "." | + ::= [A-Za-z_][A-Za-z0-9_]* +``` + +Examples: `{{Param.Name}}`, `{{Task.Param.Frame}}`, `{{Session.WorkingDirectory}}` + +### Grammar Compatibility + +The proposed extended grammar is a strict superset. Every valid expression under the current +grammar remains valid and produces the same result through the parse path: +`` → `` → `` → ... → `` + +### Potential Concerns + +1. **Whitespace handling**: `{{ Param.Name }}` and `{{Param.Name}}` must remain equivalent. + The extended grammar preserves this. + +2. **Error messages**: May differ for invalid input. Acceptable as long as invalid input + is still rejected. + +3. **Reserved words**: Keywords like `if` and `None` could conflict with parameter names. + +### Keyword Conflict Analysis + +| Expression | Current Behavior | Naive Extended Behavior | Breaking? | +|------------|------------------|-------------------------|-----------| +| `{{Param.if}}` | Valid, returns value | Parse error: `if` is keyword | **YES** | +| `{{Param.True}}` | Valid, returns value | Parse error: `True` is keyword | **YES** | + +### Mitigation: Contextual Keywords + +See Appendix C for an implementation of contextual keywords parsing Python +expressions using the `ast.parse` standard function available in Python. + +## Appendix B: Language Syntax Choice + +The expression language syntax must be chosen carefully. Two natural candidates are Python +and ECMAScript (JavaScript), each with distinct tradeoffs. + +### Python Syntax + +**Advantages:** +- Reference implementation of OpenJD is in Python +- Many job template authors use Python for scripting +- Python's `ast` module provides robust parsing +- Familiar to the VFX/animation industry (Python is dominant) + +**Disadvantages:** +- Syntax mismatch with JSON/YAML templates: + - `None` vs `null` + - `True`/`False` vs `true`/`false` + - Single quotes `'string'` common in Python, double quotes `"string"` in JSON +- Keywords are not contextual (see Backward Compatibility Analysis) + +**Example mismatch:** +```yaml +# YAML template with Python expression - mixed conventions +field: "{{ None if Param.Skip else 'value' }}" +default: null # YAML/JSON style outside expression +``` + +### ECMAScript Syntax + +**Advantages:** +- JSON is a subset of ECMAScript - syntactic consistency +- `null`, `true`, `false` match JSON exactly +- Double-quoted strings match JSON convention +- Widely known language + +**Disadvantages:** +- Reference implementation is Python - would need a JS expression parser in Python +- Less familiar to VFX Python developers +- `===` vs `==` semantics could confuse users +- No built-in Python parser (would need `esprima`, `pyjsparser`, or similar) + +**Example consistency:** +```yaml +# YAML template with ECMAScript expression - consistent conventions +field: "{{ null if Param.Skip else 'value' }}" # Hypothetical Python-like syntax with JS literals +default: null +``` + +### Comparison Table + +| Aspect | Python | ECMAScript | +|--------|--------|------------| +| Null value | `None` | `null` | +| Boolean true | `True` | `true` | +| Boolean false | `False` | `false` | +| Logical AND | `and` | `&&` | +| Logical OR | `or` | `\|\|` | +| Logical NOT | `not x` | `!x` | +| Integer division | `//` | `Math.floor(/)` | +| String quotes | `'` or `"` | `'` or `"` | +| Conditional | `x if cond else y` | `cond ? x : y` | +| List literal | `[1, 2, 3]` | `[1, 2, 3]` | +| Attribute access | `obj.attr` | `obj.attr` | +| Parser availability (Python impl) | `ast` (stdlib) | `esprima`, `pyjsparser` | +| Parser availability (Rust impl) | `rustpython-parser` | `swc`, `oxc` | + +### Other Languages Considered + +**Jinja2 Expression Syntax:** +- Already inspired OpenJD's `{{ }}` delimiters +- Subset of Python with some differences +- Would still have `None`/`True`/`False` mismatch with JSON +- No significant advantage over pure Python subset + +**JSON Expression Languages (JSONPath, JMESPath, JSONata):** +- Designed for JSON querying, not general expressions +- Limited arithmetic and conditional support +- Would require learning a new syntax + +**CEL (Common Expression Language):** +- Designed by Google for configuration expressions +- Type-safe, sandboxed by design +- `null`, `true`, `false` match JSON +- Less familiar to users than Python or JS +- Would require embedding a CEL evaluator + +### Hybrid Approach + +A pragmatic option is **Python syntax with JSON-compatible literals**: + +- Use Python's grammar and `ast` module for parsing +- Accept both `None` and `null` as the null value +- Accept both `True`/`False` and `true`/`false` as booleans +- This provides parser reuse while reducing cognitive friction with JSON/YAML + +**Implementation:** After `ast.parse()`, a simple AST transform can normalize: +- `Name(id='null')` → `Constant(value=None)` +- `Name(id='true')` → `Constant(value=True)` +- `Name(id='false')` → `Constant(value=False)` + +### Recommendation + +**Python syntax with JSON-compatible literal aliases** offers the best balance: + +1. Leverages Python's `ast` module (Technical Requirement #3) +2. Familiar to OpenJD's primary user base +3. Reduces friction with JSON/YAML by accepting `null`, `true`, `false` +4. Conditional expression syntax (`x if cond else y`) reads naturally in templates + +The specification should document that `null`/`None`, `true`/`True`, `false`/`False` are +interchangeable within expressions. + +## Appendix C: Context-Sensitive Keyword Parsing in Python + +The expression grammar treats Python keywords (`if`, `else`, `and`, `or`, `not`, `for`, `in`, +`True`, `False`, `None`) as contextual—they are only keywords in operator positions, not as +attribute names after `.`. This allows expressions like `Param.if` to remain valid for backward +compatibility. + +Python's `ast.parse()` does not support contextual keywords, so a workaround is needed. The +approach is to iteratively parse, and when a syntax error occurs immediately after a `.`, +substitute the keyword with a unique identifier, then restore it in the resulting AST. + +### Implementation + +```python +import ast +import secrets +import string +from keyword import kwlist + +class FixupRenamedKeywordsVisitor(ast.NodeTransformer): + """Restores original keyword names in attribute positions after parsing.""" + def __init__(self, keywords_renamed: dict[str, str]): + self._rename = {value: key for key, value in keywords_renamed.items()} + super().__init__() + + def visit_Attribute(self, node: ast.Attribute) -> ast.AST: + value = self.visit(node.value) + attr = self._rename.get(node.attr, node.attr) + return ast.Attribute(value=value, attr=attr, ctx=node.ctx) + +def ast_parse_keyword_context(source: str) -> ast.AST: + """Parse with context-sensitive keywords: Python keywords are allowed after '.'.""" + keywords_renamed: dict[str, str] = {} + sub_chars = string.ascii_letters + string.digits + while True: + try: + ast_node = ast.parse(source, mode="eval") + if keywords_renamed: + ast_node = FixupRenamedKeywordsVisitor(keywords_renamed).visit(ast_node) + return ast_node + except SyntaxError as exc: + # Convert line/offset to absolute position in source (for multi-line support) + abs_offset = None + if exc.lineno is not None and exc.offset is not None: + lines = source.split("\n") + abs_offset = sum(len(lines[i]) + 1 for i in range(exc.lineno - 1)) + exc.offset + + # Check for keyword after '.' at either offset or end_offset + kw_start = None + if abs_offset is not None and abs_offset >= 2 and source[abs_offset - 2] == ".": + kw_start = abs_offset - 1 + else: + # Try end_offset (added in Python 3.10) + end_lineno = getattr(exc, "end_lineno", None) + end_offset = getattr(exc, "end_offset", None) + if end_lineno is not None and end_offset is not None: + lines = source.split("\n") + abs_end = sum(len(lines[i]) + 1 for i in range(end_lineno - 1)) + end_offset + if abs_end >= 1 and source[abs_end - 1] == ".": + kw_start = abs_end + + if kw_start is not None: + kw_end = kw_start + while kw_end < len(source) and (source[kw_end].isalnum() or source[kw_end] == '_'): + kw_end += 1 + keyword = source[kw_start:kw_end] + + if keyword in kwlist: + keyword_sub = keywords_renamed.get(keyword) + if not keyword_sub: + while True: + keyword_sub = secrets.choice(string.ascii_letters) + \ + ''.join(secrets.choice(sub_chars) for _ in range(len(keyword) - 1)) + if keyword_sub not in source: + break + keywords_renamed[keyword] = keyword_sub + source = source[:kw_start] + keyword_sub + source[kw_end:] + continue + raise +``` + +### How It Works + +1. Attempt to parse the expression with `ast.parse(source, mode="eval")`. +2. If a `SyntaxError` occurs, convert the line number and column offset to an absolute + position in the source string. This is necessary for multi-line expressions where + `exc.offset` is relative to the line, not the entire source. +3. Check for a `.` followed by a keyword at two positions: + - `source[abs_offset - 2]`: the character before the error start (checking for `.`) + - `source[abs_end - 1]`: the character at the error span end (checking for `.`) + + Both positions must be checked because Python's parser reports errors differently + depending on context. For `Param.if`, the error points directly at `if`, so the `.` + is at `abs_offset - 2`. For `x if Param.if else y`, the parser sees a malformed + conditional and reports "expected 'else' after 'if' expression" with the error span + ending at the `.` (`abs_end - 1`), not pointing to the keyword itself. +4. If a `.keyword` pattern is found, generate a unique substitute identifier of the same + length and replace it in the source. Preserving length ensures line numbers and column + offsets in the resulting AST match the original source. +5. Retry parsing with the modified source. +6. After successful parsing, use `FixupRenamedKeywordsVisitor` to restore the original + keyword names in `Attribute` nodes. + +### Test Cases + +Recommended test expressions for validating an implementation: + +```python +# Keywords as attribute names (should parse successfully) +"Param.if" +"Param.def" +"Param.else" +"Param.and" +"Param.or" +"Param.not" +"Param.for" +"Param.in" +"Param.True" +"Param.False" +"Param.None" + +# Chained keyword attributes +"Param.Value.if.else.and" + +# Keywords as attributes combined with keyword operators +"Param.if and Param.or" +"x if Param.if else y" +"x if Param.flag else Param.else" +"Param.if if Param.flag else Param.else" +"result if Param.and else default" + +# Keywords in operator positions still work normally +"True if x else False" +"x and y or z" +"not x" +"[i for i in items]" +"x in items" + +# Multi-line expressions with keyword attributes +"""[ + Param.if, + Param.else +]""" +"""( + Param.if + + Param.else +)""" +``` + +## Appendix D: Implicit Line Continuation + +Python requires explicit line continuation for multi-line expressions—either a backslash (`\`) +at the end of each continued line, or enclosing the expression in parentheses, brackets, or +braces. This is inconvenient for expressions embedded in YAML templates, where the expression +may naturally span multiple lines for readability. + +The expression language supports implicit line continuation: expressions can span multiple +lines without any special syntax. + +### Examples + +```yaml +# Multi-line arithmetic without continuation characters +args: + - "{{ Param.Start + + Param.Count * + Param.Step }}" + +# Multi-line conditional +field: "{{ 'high' + if Param.Quality > 80 + else 'low' }}" + +# Multi-line list (works in Python too, but shown for completeness) +values: "{{ [ + Param.A, + Param.B, + Param.C +] }}" +``` + +### Implementation + +When parsing an expression that contains newlines, the parser wraps the expression in +parentheses before passing it to the underlying Python parser: + +```python +def parse_expression(source: str) -> ast.AST: + if "\n" in source: + # Wrap to enable implicit line continuation + wrapped = f"(\n{source}\n)" + ast_node = ast.parse(wrapped, mode="eval") + # Adjust line numbers back by 1 to account for added opening paren line + adjust_line_numbers(ast_node, offset=-1) + return ast_node + else: + return ast.parse(source, mode="eval") +``` + +The wrapping format `(\n{source}\n)` is chosen so that: +- Line numbers in the AST are offset by exactly 1 (easy to adjust back) +- Column offsets remain unchanged +- Error messages can reference the correct line in the original expression + +Single-line expressions are not wrapped, avoiding any impact on error messages for the +common case. + +### Error Reporting + +When an error occurs in a multi-line expression, the error message shows only the relevant +line with a caret pointing to the error location: + +``` +Cannot use '+' operator with int and string + Param.Count + + ~~~~~~~~~~~~^ +``` + +For single-line expressions, the full expression is shown as before. + +## Copyright + +This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive. diff --git a/rfcs/0006-expression-function-library.md b/rfcs/0006-expression-function-library.md new file mode 100644 index 0000000..ee51893 --- /dev/null +++ b/rfcs/0006-expression-function-library.md @@ -0,0 +1,828 @@ +* Feature Name: Expression Function Library +* RFC Tracking Issue: https://github.com/OpenJobDescription/openjd-specifications/issues/112 +* Start Date: 2026-02-02 +* Specification Version: 2023-09 extension EXPR +* Accepted On: (pending) + +## Summary + +This RFC defines the operators and built-in functions for the expression language introduced in +RFC 0005. By separating the function library from the core language specification, we enable +independent evaluation and evolution of these concerns. The library provides arithmetic, string, +list, path, and serialization operations sufficient for common job template use cases. + +## Basic Examples + +### Arithmetic Operations + +```yaml +# Calculate end frame for a chunk +args: + - "--end" + - "{{min(Task.Param.Frame + Param.FramesPerTask, Param.FrameEnd) - 1}}" +``` + +### String Manipulation + +```yaml +# Build output filename +args: + - "{{ Param.InputFile.stem.upper() + '_final' + Param.InputFile.suffix }}" +``` + +### Path Operations + +```yaml +# Construct output path +args: + - "{{ (Param.OutputDir / Param.InputFile.name).with_suffix('.png') }}" +``` + +### Path Manipulation + +Build output paths from input paths using the `/` operator and path functions: + +```yaml +parameterDefinitions: + - name: InputFile + type: PATH + - name: OutputDir + type: PATH +steps: + - name: Convert + script: + actions: + onRun: + command: convert + args: + - "{{Param.InputFile}}" + - "{{ (Param.OutputDir / Param.InputFile.name).with_suffix('_converted.png') }}" + - "--log" + - "{{ Param.OutputDir / Param.InputFile.name + '.log' }}" +``` + +### Shell Quoting + +```yaml +# Safe command construction for wrapper scripts +embeddedFiles: + - name: wrapper + type: TEXT + data: | + #!/bin/bash + exec {{repr_sh(Task.Command)}} +``` + +## Motivation + +RFC 0005 defines the expression language grammar, type system, and evaluation semantics. This RFC +completes the language by specifying the concrete operators and functions available to template +authors. Separating these concerns allows for independent review of these different concerns, +giving each its due attention. + +The function library addresses specific use cases: + +1. **Safe string quoting for scripts** - Embedding parameter values into bash or Python scripts + is error-prone. `echo '{{Param.Input}}'` breaks if Input contains single quotes. + `print(r'{{Param.Input}}')` breaks similarly. Functions like `repr_sh()` and + `repr_py()` enable safe embedding: `echo {{repr_sh(Param.Input)}}` and + `print({{repr_py(Param.Input)}})`. + +2. **Wrapping task runs** - The proposed `onWrapTaskRun` action needs to be able to reproduce + calling the task run in a subprocess environment matching its original definition. It uses + functions for joining lists, `sh`-quoting strings and joining strings. + +### Design Requirement: Function Naming Consistency + +Built-in functions should follow consistent naming conventions that are easy to remember. +For example, quoting functions follow a pattern like `repr_sh`/`repr_py`/`repr_json`. +Mixing styles like `shlex_quote`, `py_repr`, `json_dump` is avoided. + +## Specification + +### Operators + +Operators are documented using Python's operator method names with type signatures. + +#### Arithmetic Operators + +| Signature | Description | +|-----------|-------------| +| `__add__(a: int, b: int) -> int` | `a + b` addition | +| `__add__(a: float, b: float) -> float` | `a + b` addition | +| `__sub__(a: int, b: int) -> int` | `a - b` subtraction | +| `__sub__(a: float, b: float) -> float` | `a - b` subtraction | +| `__mul__(a: int, b: int) -> int` | `a * b` multiplication | +| `__mul__(a: float, b: float) -> float` | `a * b` multiplication | +| `__truediv__(a: int, b: int) -> float` | `a / b` division (see also Path Operators) | +| `__truediv__(a: float, b: float) -> float` | `a / b` division | +| `__floordiv__(a: int, b: int) -> int` | `a // b` integer division | +| `__floordiv__(a: float, b: float) -> int` | `a // b` integer division | +| `__mod__(a: int, b: int) -> int` | `a % b` modulo | +| `__mod__(a: float, b: float) -> float` | `a % b` modulo | +| `__pow__(a: int, b: int) -> float | int` | `a ** b` exponentiation | +| `__pow__(a: float, b: float) -> float` | `a ** b` exponentiation | +| `__neg__(a: int) -> int` | `-a` negation (unary) | +| `__neg__(a: float) -> float` | `-a` negation (unary) | +| `__pos__(a: int) -> int` | `+a` identity (unary) | +| `__pos__(a: float) -> float` | `+a` identity (unary) | + +When mixing int and float operands, the int is promoted to float and the float overload is used. + +For `int ** int`, the result is `int` when the exponent is non-negative, and `float` when the exponent is negative (e.g., `2 ** 3 = 8` but `2 ** -3 = 0.125`). + +#### String Operators + +| Signature | Description | +|-----------|-------------| +| `__add__(a: string, b: string) -> string` | `a + b` concatenation | +| `__add__(a: string, b: range_expr) -> string` | `a + b` concatenation (range_expr converted to canonical string form) | +| `__add__(a: range_expr, b: string) -> string` | `a + b` concatenation (range_expr converted to canonical string form) | +| `__mul__(s: string, n: int) -> string` | `s * n` repetition | +| `__contains__(a: string, b: string) -> bool` | `b in a` substring test | +| `__not_contains__(a: string, b: string) -> bool` | `b not in a` substring test | + +#### List Operators + +| Signature | Description | +|-----------|-------------| +| `__add__(a: list[T1], b: list[T2]) -> list[T3]` | `a + b` concatenation (see type coercion below) | +| `__add__(a: range_expr, b: list[T1]) -> list[T2]` | `a + b` concatenation (range_expr treated as list[int]) | +| `__add__(a: list[T1], b: range_expr) -> list[T2]` | `a + b` concatenation (range_expr treated as list[int]) | +| `__add__(a: range_expr, b: range_expr) -> list[int]` | `a + b` concatenation | +| `__mul__(a: list[T], n: int) -> list[T]` | `a * n` repetition | +| `__contains__(list: list[T], item: T) -> bool` | `item in list` membership test | +| `__not_contains__(list: list[T], item: T) -> bool` | `item not in list` membership test | +| `__contains__(r: range_expr, item: int) -> bool` | `item in r` membership test | +| `__not_contains__(r: range_expr, item: int) -> bool` | `item not in r` membership test | + +**List Concatenation Type Coercion** + +When concatenating lists with different element types, the result type is determined by +finding a common type that both element types can be coerced to: + +- `list[int] + list[int]` → `list[int]` +- `list[float] + list[float]` → `list[float]` +- `list[int] + list[float]` → `list[float]` (int elements coerced to float) +- `list[float] + list[int]` → `list[float]` (int elements coerced to float) +- `list[?] + list[T]` → `list[T]` (empty list takes the other's type) +- `list[T] + list[?]` → `list[T]` (empty list takes the other's type) + +This coercion also applies when concatenating with `range_expr` values, which are treated +as `list[int]` for concatenation purposes: + +- `list[int] + range_expr` → `list[int]` +- `list[float] + range_expr` → `list[float]` (range_expr ints coerced to float) +- `range_expr + list[int]` → `list[int]` +- `range_expr + range_expr` → `list[int]` + +List comprehensions produce lists, so they participate in concatenation naturally: + +```yaml +# Combine explicit list with comprehension result +{{ [0] + [x * 2 for x in range_expr('1-5')] }} # [0, 2, 4, 6, 8, 10] + +# Combine two comprehensions +{{ [x for x in range_expr('1-3')] + [x for x in range_expr('10-12')] }} # [1, 2, 3, 10, 11, 12] +``` + +Concatenation of incompatible list types (e.g., `list[string] + list[int]`) is an error. + +#### Path Operators + +| Signature | Description | +|-----------|-------------| +| `__truediv__(p: path, child: string) -> path` | `p / child` join path components | +| `__truediv__(p: path, child: path) -> path` | `p / child` join path components | +| `__add__(p: path, suffix: string) -> path` | `p + suffix` append string to last component | + +The `/` operator creates child paths by joining components. If the right operand is an +absolute path, it replaces the left operand entirely (matching Python's `pathlib` behavior): + +```yaml +{{ Param.OutputDir / 'renders' / Param.SceneName }} +{{ Param.BaseDir / Param.Override }} # Override can be relative or absolute +``` + +The `+` operator appends a string directly to the path (no separator): + +```yaml +{{ Param.OutputDir / Param.InputFile.stem + '_converted.png' }} +``` + +#### Comparison Operators + +| Signature | Description | +|-----------|-------------| +| `__eq__(a: T1, b: T2) -> bool` | `a == b` equal | +| `__ne__(a: T1, b: T2) -> bool` | `a != b` not equal | +| `__lt__(a: T1, b: T2) -> bool` | `a < b` less than | +| `__gt__(a: T1, b: T2) -> bool` | `a > b` greater than | +| `__le__(a: T1, b: T2) -> bool` | `a <= b` less than or equal | +| `__ge__(a: T1, b: T2) -> bool` | `a >= b` greater than or equal | + +##### Cross-Type Equality Comparison + +Equality (`==`) and inequality (`!=`) operators handle cross-type comparisons as follows: + +- `string` vs `path`: The path is converted to string for comparison +- `int` vs `float`: Numeric comparison (e.g., `5 == 5.0` is `true`) +- `list` vs `range_expr`: The range_expr is expanded and compared element-by-element + (e.g., `[1, 2, 3] == range_expr("1-3")` is `true`) +- `string` vs (`int` | `float`): Always unequal (e.g., `"5" == 5` is `false`) +- `bool` vs any non-`bool`: Always unequal (e.g., `true == 1` is `false`) +- scalar vs `list`: Always unequal (e.g., `1 == [1]` is `false`) +- Other cross-type comparisons: Always unequal + +List equality is recursive: two lists are equal if they have the same length and all +corresponding elements are equal. Cross-type element comparisons follow the same rules, +so `[5] == [5.0]` is `true` and `[[5]] == [[5.0]]` is `true`. + +This differs from Python where `True == 1` and `False == 0`. The stricter behavior prevents +subtle bugs from implicit type coercion in boolean contexts. + +##### Ordering Comparison + +Ordering comparison operators (`<`, `<=`, `>`, `>=`) work on `int`, `float`, `string`, `path`, +`list`, and `bool` types. Comparing different types (except `int`/`float` and `string`/`path`) +is an error. Path comparison is lexicographic on the string representation. String and path can +be compared with each other (path is converted to string). Bool comparison treats `False < True`. + +List ordering uses lexicographic comparison: elements are compared pairwise from the start, and +the first unequal pair determines the result. If all compared elements are equal, the shorter +list is considered less than the longer one. Nested lists are compared recursively. + +#### Logical Operators + +| Signature | Description | +|-----------|-------------| +| `__and__(a: bool, b: bool) -> bool` | `a and b` logical AND (short-circuit) | +| `__or__(a: bool, b: bool) -> bool` | `a or b` logical OR (short-circuit) | +| `__not__(a: bool) -> bool` | `not a` logical NOT | + +Note: Implementations should handle `__and__` and `__or__` directly in the expression evaluator +rather than as function overloads to enable short-circuit evaluation semantics. + +#### Subscript Operator + +| Signature | Description | +|-----------|-------------| +| `__getitem__(list: list[T], index: int) -> T` | `list[index]` access by zero-based index | +| `__getitem__(r: range_expr, index: int) -> int` | `r[index]` access by zero-based index | +| `__getitem__(s: string, index: int) -> string` | `s[index]` access single character by index | + +Negative indices count from the end: `list[-1]` is the last element. Index out of bounds is an error. + +#### Slice Operator + +Slicing uses Python semantics with `[start:stop:step]` syntax. All bounds are optional. + +| Signature | Description | +|-----------|-------------| +| `__getitem__(list: list[T], start: int?, stop: int?, step: int?) -> list[T]` | `list[start:stop:step]` slice | +| `__getitem__(r: range_expr, start: int?, stop: int?, step: int?) -> list[int]` | `r[start:stop:step]` slice | +| `__getitem__(s: string, start: int?, stop: int?, step: int?) -> string` | `s[start:stop:step]` slice | + +Note: The `path` type does not support subscript or slice operations, matching Python's `pathlib.Path` +behavior. Use `p.parts` to get path components as a list, which can then be sliced. + +Slice semantics follow Python: +- `start` defaults to 0 (or end if step < 0) +- `stop` defaults to length (or before start if step < 0) +- `step` defaults to 1; step of 0 is an error +- Negative indices count from end +- Out-of-bounds indices are clamped to valid range (no error) + +Examples: +- `[1, 2, 3, 4, 5][1:4]` → `[2, 3, 4]` +- `[1, 2, 3, 4, 5][::-1]` → `[5, 4, 3, 2, 1]` +- `"hello"[1:4]` → `"ell"` +- `path("/a/b/c/d").parts[1:]` → `["a", "b", "c", "d"]` (slice the parts list, not the path) + +### Built-in Functions + +#### General Functions + +| Signature | Description | +|-----------|-------------| +| `len(list: list[T]) -> int` | Length of list | +| `len(s: string) -> int` | Length of string (number of unicode codepoints) | +| `len(r: range_expr) -> int` | Number of values in range expression | +| `bool(value: bool) -> bool` | Pass-through | +| `bool(value: ?) -> bool` | Returns `false` | +| `bool(value: int) -> bool` | `0` is `false`, all others `true` | +| `bool(value: float) -> bool` | `0.0` is `false`, all others `true` | +| `bool(value: string) -> bool` | See string-to-bool conversion below | + +Calling `bool()` on `path` or `list[T]` values is an error. This prevents accidental implicit +coercion to bool in conditional contexts. Implementations must raise a clear error message such as +"Cannot convert path to bool" or "Cannot convert list to bool". +| `string(value: bool \| int \| float \| string \| path \| range_expr \| ?) -> string` | Convert to string (`?` returns `"null"`) | +| `string(value: list[T]) -> string` | Convert list to JSON string representation | +| `int(value: int \| float \| string) -> int` | Convert to integer | +| `float(value: int \| float \| string) -> float` | Convert to float | +| `list(value: range_expr) -> list[int]` | Convert range expression to list | +| `range_expr(s: string) -> range_expr` | Parse string as range expression (e.g., `"1-10"`, `"1,3,5-7"`) | +| `range_expr(l: list[int]) -> range_expr` | Convert integer list to range expression | + +**Note about bool conversion from string**: The following case-insensitive string values are +accepted: `"1"`, `"true"`, `"on"`, `"yes"` become `true`; `"0"`, `"false"`, `"off"`, `"no"` +become `false`. All other string values are rejected with an error. + +**Note about int and float conversion**: If the value cannot be nondestructively converted, +it's an error. E.g. `int(3.75)` is an error, the functions `floor`, `ceil`, and `round` are for +this case. + +**Note about range_expr**: Parsing an empty string with `range_expr("")` is an error, and +`range_expr([])` (empty list) is also an error. Range expressions must contain at least one value. + +#### Validation Functions + +| Signature | Description | +|-----------|-------------| +| `fail(message: string) -> noreturn` | Fail with error message | + +The `fail` function immediately terminates expression evaluation with an error, communicating +the provided message to the user. This is the only function in the expression language with +a side effect (failing the operation). + +The `fail` function is useful for validation and providing clear error messages: + +```python +# Validate parameter value +Param.Count if Param.Count > 0 else fail("Count must be positive") + +# With short-circuit evaluation - fail() only called if condition is false +Param.Mode in ["fast", "slow"] or fail("Mode must be 'fast' or 'slow'") + +# Validate file extension +Param.InputFile.suffix == ".exr" or fail("Input must be an EXR file") +``` + +The return type `noreturn` indicates that `fail()` never returns a value—it always raises an error. +In union types, `noreturn` collapses to nothing (`T | noreturn` simplifies to `T`), which means +expressions using `fail()` for validation have precise types: + +```python +# Type is float, not float? +frame_rate = Param.FrameRate if Param.FrameRate > 0 else fail("must be positive") +``` + +#### Math Functions + +| Signature | Description | +|-----------|-------------| +| `abs(x: T) -> T` | Absolute value (`T` in `int`, `float`) | +| `min(a: T, b: T) -> T` | Minimum of two values (`T` in `int`, `float`) | +| `min(a: T, b: T, c: T) -> T` | Minimum of three values (`T` in `int`, `float`) | +| `min(values: list[T]) -> T` | Minimum of list (`T` in `int`, `float`); error if empty | +| `min(values: list[?]) -> noreturn` | Error: "min() requires a non-empty list" | +| `min(r: range_expr) -> int` | Minimum value in range expression; error if empty | +| `max(a: T, b: T) -> T` | Maximum of two values (`T` in `int`, `float`) | +| `max(a: T, b: T, c: T) -> T` | Maximum of three values (`T` in `int`, `float`) | +| `max(values: list[T]) -> T` | Maximum of list (`T` in `int`, `float`); error if empty | +| `max(values: list[?]) -> noreturn` | Error: "max() requires a non-empty list" | +| `max(r: range_expr) -> int` | Maximum value in range expression; error if empty | +| `sum(values: list[?]) -> int` | Sum of empty list, returns `0` | +| `sum(values: list[int]) -> int` | Sum of integer list | +| `sum(values: list[float]) -> float` | Sum of float list | +| `sum(r: range_expr) -> int` | Sum of all values in range expression | +| `floor(x: int) -> int` | Floor of integer (identity) | +| `floor(x: float) -> int` | Largest integer less than or equal to x | +| `ceil(x: int) -> int` | Ceiling of integer (identity) | +| `ceil(x: float) -> int` | Smallest integer greater than or equal to x | +| `round(x: float) -> int` | Round to nearest integer, tie rounds to even | +| `round(x: T, ndigits: int) -> T` | Round to number of decimals, tie rounds to even (`T` in `int`, `float`) | + +**Special note about `round`**: The rounded value keeps trailing zero decimals just like input +parameters, so `round(3.5, 2)` is `3.50` if you convert it to a string. + +#### List Functions + +| Signature | Description | +|-----------|-------------| +| `range(stop: int) -> list[int]` | Integers from 0 to stop-1 | +| `range(start: int, stop: int) -> list[int]` | Integers from start to stop-1 | +| `range(start: int, stop: int, step: int) -> list[int]` | Integers from start to stop-1 with step | +| `flatten(lists: list[list[T]]) -> list[T]` | Flatten nested lists | +| `flatten(values: list[T]) -> list[T]` | Identity for already-flat lists | +| `sorted(values: list[T]) -> list[T]` | Return new list with elements sorted in ascending order | +| `reversed(values: list[T]) -> list[T]` | Return new list with elements in reverse order | +| `any(values: list[?]) -> bool` | False (empty list) | +| `any(values: list[bool]) -> bool` | True if any element is true | +| `all(values: list[?]) -> bool` | True (empty list) | +| `all(values: list[bool]) -> bool` | True if all elements are true | + +Examples: +- `range(5)` returns `[0, 1, 2, 3, 4]` +- `range(1, 5)` returns `[1, 2, 3, 4]` +- `range(0, 10, 2)` returns `[0, 2, 4, 6, 8]` +- `range(5, 0, -1)` returns `[5, 4, 3, 2, 1]` +- `flatten([[1, 2], [3]])` returns `[1, 2, 3]` +- `sorted([3, 1, 2])` returns `[1, 2, 3]` +- `sorted(["b", "a", "c"])` returns `["a", "b", "c"]` +- `reversed([1, 2, 3])` returns `[3, 2, 1]` + +The `list[?]` overloads handle empty lists, matching Python semantics. + +#### String Functions + +| Signature | Description | +|-----------|-------------| +| `upper(s: string) -> string` | Convert to uppercase | +| `lower(s: string) -> string` | Convert to lowercase | +| `capitalize(s: string) -> string` | Capitalize first character, lowercase rest | +| `title(s: string) -> string` | Capitalize first character of each word | +| `strip(s: string) -> string` | Remove leading/trailing whitespace | +| `lstrip(s: string) -> string` | Remove leading whitespace | +| `rstrip(s: string) -> string` | Remove trailing whitespace | +| `removeprefix(s: string, prefix: string) -> string` | Remove prefix if present, otherwise return unchanged | +| `removesuffix(s: string, suffix: string) -> string` | Remove suffix if present, otherwise return unchanged | +| `startswith(s: string, prefix: string) -> bool` | Test if string starts with prefix | +| `endswith(s: string, suffix: string) -> bool` | Test if string ends with suffix | +| `count(s: string, sub: string) -> int` | Count non-overlapping occurrences of substring | +| `find(s: string, sub: string) -> int` | Return lowest index of substring, or -1 if not found | +| `replace(s: string, old: string, new: string) -> string` | Replace all occurrences of old with new | +| `split(s: string, sep: string) -> list[string]` | Split string by separator | +| `join(items: list[?], sep: string) -> string` | Join empty list, returns `""` | +| `join(items: list[string], sep: string) -> string` | Join list elements with separator | +| `join(items: list[path], sep: string) -> string` | Join path list elements with separator | +| `ljust(s: string, width: int) -> string` | Left-justify, pad with spaces to width | +| `rjust(s: string, width: int) -> string` | Right-justify, pad with spaces to width | +| `center(s: string, width: int) -> string` | Center, pad with spaces to width | +| `zfill(s: string, width: int) -> string` | Pad with leading zeros to width; a leading sign (`+`/`-`) is preserved before the padding | +| `zfill(n: int, width: int) -> string` | Convert int to string, pad with leading zeros; negative integers preserve the sign before padding | + +Examples: +- `split("a,b,c", ",")` and `"a,b,c".split(",")` return `["a", "b", "c"]` +- `join(["a", "b", "c"], ",")` and `["a", "b", "c"].join(",")` return `"a,b,c"` +- `zfill(42, 5)` and `(42).zfill(5)` return `"00042"` +- `zfill(-1, 3)` returns `"-01"` (sign preserved, zeros pad after sign) +- `zfill("-10", 4)` returns `"-010"` +- `"frame_".rjust(10) + zfill(Task.Param.Frame, 4)` returns `" frame_0001"` + +Note: Method calls on integer and float literals require parentheses around the literal +(e.g., `(42).zfill(5)` not `42.zfill(5)`) because the Python grammar parses `42.` as the +start of a float literal. + +Note: The `join` function intentionally differs from Python's `str.join()`. In Python, +`join` is a string method (`",".join(list)`), but OpenJD uses `list.join(sep)` instead. +This design enables natural method chaining like `items.split(';').join(',')` and matches +the convention used by JavaScript and Ruby. + +#### Regular Expression Functions + +| Signature | Description | +|-----------|-------------| +| `re_match(s: string, pattern: string) -> list[string]?` | Match at START of string, return captured groups or null | +| `re_search(s: string, pattern: string) -> list[string]?` | Match ANYWHERE in string, return captured groups or null | +| `re_findall(s: string, pattern: string) -> list[string] \| list[list[string]]` | Find all non-overlapping matches; returns full matches if no groups, list of captured group values (not full matches) if one group, list of group lists if multiple groups | +| `re_replace(s: string, pattern: string, repl: string) -> string` | Replace all regex matches with replacement | +| `re_escape(s: string) -> string` | Escape regex metacharacters for literal matching | + +The regex syntax is the intersection of Python's `re` module and Rust's `regex` crate, +ensuring cross-platform compatibility. Supported features: +- Character classes: `[abc]`, `[^abc]`, `[a-z]`, `\d`, `\w`, `\s` (and negations) +- Anchors: `^`, `$`, `\b` +- Quantifiers: `*`, `+`, `?`, `{n}`, `{n,m}`, and non-greedy variants +- Groups: `(...)`, `(?:...)` (non-capturing) +- Alternation: `|` + +Not supported (Python `re` features not in Rust `regex`): +- Backreferences (`\1`, `\2`, etc.) +- Lookahead (`(?=...)`, `(?!...)`) +- Lookbehind (`(?<=...)`, `(? string` | Shell-escape a string for POSIX shells | +| `repr_sh(args: list[string]) -> string` | Join list into space-separated shell-escaped strings | +| `repr_cmd(s: string) -> string` | Escape a string for Windows CMD | +| `repr_cmd(args: list[string]) -> string` | Join list into space-separated CMD-escaped strings | +| `repr_pwsh(s: string) -> string` | Escape a string for PowerShell | +| `repr_pwsh(n: int) -> string` | Integer literal for PowerShell | +| `repr_pwsh(f: float) -> string` | Float literal for PowerShell | +| `repr_pwsh(b: bool) -> string` | Boolean literal (`$true`/`$false`) for PowerShell | +| `repr_pwsh(p: path) -> string` | Escape a path for PowerShell | +| `repr_pwsh(r: range_expr) -> string` | String representation of range (e.g., `'1-10'`) | +| `repr_pwsh(args: list[T]) -> string` | PowerShell array literal `@(...)` | +| `repr_py(value: ?) -> string` | Returns `"None"` | +| `repr_py(r: range_expr) -> string` | String representation (e.g., `'1-10'`) | +| `repr_py(p: path) -> string` | Python string repr of path's string representation | +| `repr_py(value: T) -> string` | Convert to Python representation (`T` in `bool`, `int`, `float`, `string`, `list`) | +| `repr_json(value: ?) -> string` | Returns `"null"` | +| `repr_json(r: range_expr) -> string` | String representation (e.g., `"1-10"`) | +| `repr_json(value: T) -> string` | Convert to JSON representation (`T` in `bool`, `int`, `float`, `string`, `list`) | + +`repr_py` follows the behavior of Python's [repr](https://docs.python.org/3/library/functions.html#repr). + +`repr_sh` follows the behavior of Python's +[shlex.quote](https://docs.python.org/3/library/shlex.html#shlex.quote) and +[shlex.join](https://docs.python.org/3/library/shlex.html#shlex.join). + +Example: `repr_sh(["echo", "hello world"])` returns `"echo 'hello world'"`. + +When a `path` value is passed to `repr_sh`, it is implicitly converted to its string +representation before quoting. Similarly, `list[path]` is treated as `list[string]`. +This allows natural usage like `repr_sh(Param.OutputDir)` without explicit conversion. + +`repr_cmd` produces Windows CMD-safe strings suitable for use in `.bat` files: + +- Strings containing special characters (`& | < > ^ " ( ) % !` or whitespace) are wrapped in double quotes. +- Inside double quotes, `^` and `"` are escaped with a caret prefix, and `%` is doubled to `%%` (required for `.bat` file contexts). Other special characters are literal within quotes. +- Simple strings without special characters are returned unquoted. +- `repr_cmd("hello")` returns `hello`. +- `repr_cmd("a & b")` returns `"a & b"` (`&` is literal inside quotes). +- `repr_cmd("a ^ b")` returns `"a ^^ b"` (`^` escaped inside quotes). +- `repr_cmd("100%")` returns `"100%%"` (`%` doubled for `.bat` files). +- `repr_cmd('say "hi"')` returns `"say ^"hi^""`. + +Example: `repr_cmd(["echo", "hello & world"])` returns `echo "hello & world"`. + +Like `repr_sh`, `path` values passed to `repr_cmd` are implicitly converted to string before escaping. + +To safely set a CMD environment variable with a path that may contain special characters, +concatenate the variable name with the path value inside `repr_cmd`: + +```yaml +# Safe: OUTPUT_DIR is within the quotes and special characters in path are escaped +set {{repr_cmd('OUTPUT_DIR=' + Param.OutputDirectory)}} +``` + +`repr_pwsh` produces PowerShell literals with proper escaping: + +- **Strings and paths**: Wrapped in single quotes with embedded single quotes doubled. + `repr_pwsh("it's")` returns `'it''s'`. +- **Integers and floats**: Passed through as-is. `repr_pwsh(42)` returns `42`. +- **Booleans**: Converted to PowerShell boolean literals. `repr_pwsh(true)` returns `$true`. +- **Range expressions**: String representation of the range. `repr_pwsh(Param.Frames)` where + Frames is `1-10` returns `'1-10'`. Use `repr_pwsh(list(r))` to get an expanded array. +- **Lists**: Converted to PowerShell array syntax. `repr_pwsh(["a", "b"])` returns `@('a', 'b')`. + +Example usage in a batch script: + +```yaml +embeddedFiles: + - name: render + type: TEXT + data: | + @echo off + powershell -Command "& { $frames = {{repr_pwsh(list(Task.Param.Frame))}}; ... }" +``` + +### Path Type Properties and Functions + +The `path` type represents filesystem paths and provides properties and functions inspired by +Python's `pathlib.PurePath`. Job parameters of type `PATH` evaluate to the `path` type. + +#### Path Properties + +Properties are accessed using dot notation via UFCS (see RFC 0005). + +| Signature | Description | +|-----------|-------------| +| `__property_name__(p: path) -> string` | `p.name` final path component (filename with extension) | +| `__property_stem__(p: path) -> string` | `p.stem` final component without the last suffix | +| `__property_suffix__(p: path) -> string` | `p.suffix` last file extension including dot, or empty string | +| `__property_suffixes__(p: path) -> list[string]` | `p.suffixes` list of file extensions (e.g., `['.tar', '.gz']`) | +| `__property_parent__(p: path) -> path` | `p.parent` parent directory path | +| `__property_parts__(p: path) -> list[string]` | `p.parts` path components as a list | + +These properties match Python's `pathlib.PurePath` behavior exactly. + +Examples: + +```yaml +# Given Param.InputFile = "/projects/shot01/render.exr" +{{ Param.InputFile.name }} # "render.exr" +{{ Param.InputFile.stem }} # "render" +{{ Param.InputFile.suffix }} # ".exr" +{{ Param.InputFile.parent }} # path("/projects/shot01") + +# Given Param.Archive = "/data/backup.tar.gz" +{{ Param.Archive.suffix }} # ".gz" +{{ Param.Archive.suffixes }} # [".tar", ".gz"] +{{ Param.Archive.stem }} # "backup.tar" + +# To get all extensions combined or the bare stem: +{{ Param.Archive.suffixes.join("") }} # ".tar.gz" +{{ Param.Archive.name.removesuffix(Param.Archive.suffixes.join("")) }} # "backup" +``` + +#### Path Functions + +| Signature | Description | +|-----------|-------------| +| `path(s: string) -> path` | Convert string to path | +| `path(parts: list[string]) -> path` | Construct path from components (like `Path(*parts)` in Python) | +| `with_name(p: path, name: string) -> path` | `p.with_name(name)` return path with the filename changed | +| `with_stem(p: path, stem: string) -> path` | `p.with_stem(stem)` return path with the stem changed | +| `with_suffix(p: path, suffix: string) -> path` | `p.with_suffix(suffix)` return path with the suffix changed | +| `with_number(p: path, num: int) -> path` | `p.with_number(num)` return path with the frame number replaced (see formats below) | +| `with_number(s: string, num: int) -> string` | Return string with the frame number replaced (same formats as path version) | +| `as_posix(p: path) -> string` | `p.as_posix()` return string with forward slashes | +| `apply_path_mapping(s: string) -> path` | `s.apply_path_mapping()` apply session path mapping rules (host context only) | + +The `path(list[string])` overload enables reconstructing a path from its parts, supporting +patterns like `path(p.parts) == p` for roundtrip operations. + +The `apply_path_mapping` function applies the session's path mapping rules to a path string and +returns a `path` value. This is the same transformation that occurs when accessing `Param.` +for PATH-type job parameters. + +This function is only available in `@fmtstring[host]` contexts (evaluated at runtime on the +worker host) where path mapping rules are available. Using it in submission-time contexts is +an error. + +**Why `string -> path` instead of `path -> path`?** + +The input is `string` rather than `path` because path mapping often involves cross-platform +scenarios where the source path originates from a different operating system than the worker. +For example, a job submitted from Windows with path `C:\projects\shot01\render.exr` may run +on a Linux worker where that path should map to `/mnt/studio/shot01/render.exr`. The source +path string may not be valid path syntax on the worker's OS, so it must remain a string until +path mapping transforms it into a valid local path. + +This is why `RawParam.` for PATH parameters returns `string` (the original submitted +value, which may be foreign path syntax), while `Param.` returns `path` (after mapping +has been applied, yielding a valid local path). + +Examples using UFCS method syntax: + +```yaml +# Given Param.InputFile = "/projects/shot01/render.exr" +{{ Param.InputFile.with_name('output.png') }} # path("/projects/shot01/output.png") +{{ Param.InputFile.with_stem('final') }} # path("/projects/shot01/final.exr") +{{ Param.InputFile.with_suffix('.png') }} # path("/projects/shot01/render.png") +{{ Param.InputFile.with_suffix('') }} # path("/projects/shot01/render") + +# Convert Windows path to POSIX for shell scripts +{{ Param.OutputDir.as_posix() }} # "C:/renders/project" (from "C:\renders\project") + +# Apply path mapping to a modified path string +# Given RawParam.InputFile = "C:\studio\project\scene.ma" (submitted from Windows) +# and path mapping rule "C:\studio" -> "/mnt/studio" on Linux worker +{{ RawParam.InputFile.replace('scene.ma', 'output').apply_path_mapping() }} # path("/mnt/studio/project/output") +``` + +##### Frame Number Substitution with `with_number` + +The `with_number` function replaces frame number placeholders in path filenames. It recognizes +several common formats used by rendering applications: + +| Format | Example Input | `with_number(72)` Output | +|--------|---------------|--------------------------| +| Digits | `file_003.exr` | `file_072.exr` | +| Printf `%d` | `file_%d.exr` | `file_72.exr` | +| Printf `%0Nd` | `file_%04d.exr` | `file_0072.exr` | +| Hash padding | `file_####.exr` | `file_0072.exr` | +| Hash padding | `file_######.exr` | `file_000072.exr` | + +The function searches the filename stem from the end for these patterns and replaces the last match found. +This preserves shot numbers or other numeric prefixes (e.g., `shot01_####.exr` replaces only the `####`). +For digit sequences and hash patterns, the output is zero-padded to match the original width. +For printf-style patterns, the format specifier determines the padding. +If the number exceeds the padding width, the full number is used without truncation +(e.g., `file_###.exr` with `with_number(10000)` produces `file_10000.exr`). +If no pattern is found, `_NNNN` (4-digit zero-padded) is appended to the stem. + +For negative numbers, the sign is included in the output. With digit and hash formats, the sign +precedes the zero-padded digits and counts toward the width (e.g., `file_003.exr` with +`with_number(-1)` produces `file_-01.exr`). With printf formats, standard printf sign handling +applies. + +```yaml +# Frame number substitution examples +{{ path("/renders/shot_003.exr").with_number(72) }} # path("/renders/shot_072.exr") +{{ path("/renders/shot_%04d.exr").with_number(72) }} # path("/renders/shot_0072.exr") +{{ path("/renders/shot_%d.exr").with_number(72) }} # path("/renders/shot_72.exr") +{{ path("/renders/shot_####.exr").with_number(72) }} # path("/renders/shot_0072.exr") +{{ path("/renders/shot_######.exr").with_number(72) }} # path("/renders/shot_000072.exr") + +# Typical usage: construct output path for current frame +{{ Param.OutputPattern.with_number(Task.Param.Frame) }} +``` + +## Design Choices + +### Function Naming Conventions + +Built-in functions follow consistent naming conventions: +- Lowercase names matching Python conventions (`upper`, `lower`, `strip`) +- Serialization functions use `repr_` prefix (`repr_sh`, `repr_py`, `repr_json`) +- Path functions match Python's `pathlib` API (`with_suffix`, `with_stem`, `as_posix`) + +This consistency makes the library easier to learn and remember. + +### PATH Parameter Type Semantics + +For job parameters of type `PATH`: +- `RawParam.` returns `string` — the original submitted value, which may use path syntax + from a different operating system +- `Param.` returns `path` — the value after path mapping has been applied, which is + valid path syntax for the worker's operating system + +This distinction exists because jobs may be submitted from one OS (e.g., Windows) and run on +workers with a different OS (e.g., Linux). The raw value preserves the original syntax for +string manipulation, while the mapped value provides a proper `path` type for path operations. + +### Minimal Function Set + +The function library is intentionally minimal, covering common use cases. Additional functions +can be proposed in future RFCs as needs are identified. + +## Prior Art + +### Python pathlib + +The path type properties and functions are directly inspired by Python's +[pathlib.PurePath](https://docs.python.org/3/library/pathlib.html#pure-paths). This provides +a well-tested API design for path manipulation. + +### Python shlex + +The `repr_sh` function follows Python's +[shlex.quote](https://docs.python.org/3/library/shlex.html#shlex.quote) semantics for safe +shell escaping. + +### Standard Library Math Functions + +The math functions (`abs`, `min`, `max`, `floor`, `ceil`, `round`) follow conventions common +across programming languages, making them immediately familiar to users. + +## Rejected Ideas + +### Format String Functions + +A `format` function similar to Python's `str.format` was considered but rejected: +- The expression language already provides string interpolation +- Would duplicate functionality +- Complex format specifications add implementation burden + +### Additional Math Functions + +Trigonometric functions (`sin`, `cos`, `tan`) and logarithms were considered but rejected: +- Rare use cases in job templates +- Can be computed in task scripts if needed +- Keeps the function library focused + +## Copyright + +This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive. diff --git a/rfcs/0007-extend-parameter-types.md b/rfcs/0007-extend-parameter-types.md new file mode 100644 index 0000000..3efed26 --- /dev/null +++ b/rfcs/0007-extend-parameter-types.md @@ -0,0 +1,480 @@ +* Feature Name: Extended Parameter Types +* RFC Tracking Issue: https://github.com/OpenJobDescription/openjd-specifications/issues/112 +* Start Date: 2026-02-02 +* Specification Version: 2023-09 extension EXPR +* Accepted On: (pending) + +## Summary + +This RFC extends the job parameter type system with boolean and list types, and makes type names +case-insensitive. These additions enable template authors to express common patterns more naturally +and align type syntax with surrounding YAML/JSON conventions. + +## Basic Examples + +### Boolean Parameter + +```yaml +parameterDefinitions: + - name: UseGpu + type: bool + default: false +steps: + - name: Render + script: + actions: + onRun: + command: render + args: + - "{{ '--gpu' if Param.UseGpu else null }}" + hostRequirements: + amounts: + - name: amount.worker.gpu + min: "{{ 1 if Param.UseGpu else null }}" +``` + +When `UseGpu` is `true`, the step requires at least one GPU and passes the `--gpu` flag. +When `UseGpu` is `false`, the `null` value causes that array element to be omitted entirely, +and the GPU requirement is set to 0. + +### List Parameter + +```yaml +parameterDefinitions: + - name: Cameras + type: list[string] + default: ["main", "closeup"] +steps: + - name: Render + parameterSpace: + taskParameterDefinitions: + - name: Camera + type: string + range: "{{Param.Cameras}}" +``` + +### Case-Insensitive Types + +```yaml +parameterDefinitions: + - name: FrameStart + type: int # lowercase now valid + - name: OutputDir + type: Path # mixed case now valid +``` + +## Motivation + +The current parameter type system has limitations: + +1. **No boolean type** - Users must use `STRING` with `allowedValues: ["true", "false"]` to + represent boolean values, losing semantic clarity. + +2. **No list types** - Providing a dynamic list of items (cameras, render layers, etc.) as a + job parameter is not possible. Users must hardcode lists in templates. + +3. **Case-sensitive type names** - The uppercase-only requirement (`INT`, `STRING`, `PATH`) + doesn't match conventions in YAML/JSON contexts where lowercase is common. + +## Specification + +### Case-Insensitive Type Names + +Job parameter and task parameter type names become case-insensitive. All type names, +including compound types like `LIST[T]`, are matched without regard to case. For example, +the following are all equivalent: + +- `INT`, `Int`, `int` +- `FLOAT`, `Float`, `float` +- `STRING`, `String`, `string` +- `PATH`, `Path`, `path` +- `BOOL`, `Bool`, `bool` +- `RANGE_EXPR`, `Range_Expr`, `range_expr` +- `LIST[STRING]`, `List[String]`, `list[string]` +- `LIST[INT]`, `List[Int]`, `list[int]` +- `LIST[LIST[INT]]`, `List[List[Int]]`, `list[list[int]]` + +This allows template authors to match the conventions of their environment. + +### New Job Parameter Types + +#### `` + +Defines a job parameter that accepts a boolean value. + +```yaml +name: +type: "BOOL" +description: # @optional +default: # @optional +userInterface: # @optional + control: enum("CHECK_BOX", "HIDDEN") + label: # @optional + groupLabel: # @optional +``` + +Accepted values are: +- JSON/YAML boolean literals: `true`, `false` +- Integer or float `1` or `1.0` (true), `0` or `0.0` (false) +- Case-insensitive strings representing true: `"true"`, `"yes"`, `"on"`, `"1"` +- Case-insensitive strings representing false: `"false"`, `"no"`, `"off"`, `"0"` + +The value is referenced in format strings as: +- `Param.` - Returns a bool type value + +Note: Unlike other parameter types, `BOOL` does not support `allowedValues` because restricting +to only `true` or only `false` does not provide meaningful value. + +#### `` + +Defines a job parameter that accepts a list of string values. + +```yaml +name: +type: "LIST[STRING]" +description: # @optional +default: [ , ... ] # @optional +minLength: # @optional +maxLength: # @optional +item: # @optional + allowedValues: [ , ... ] # @optional + minLength: # @optional + maxLength: # @optional +userInterface: # @optional + control: enum("LINE_EDIT_LIST", "HIDDEN") + label: # @optional + groupLabel: # @optional +``` + +Where *minLength*/*maxLength* constrain the number of items in the list, and +*item.allowedValues*, *item.minLength*/*item.maxLength* constrain each string item. + +The value is referenced in format strings as: +- `Param.` - Returns a list[string] type value +- `Param.[i]` - Returns the i-th element as string +- `len(Param.)` - Returns the count of elements + +#### `` + +Defines a job parameter that accepts a list of path values. + +```yaml +name: +type: "LIST[PATH]" +description: # @optional +objectType: enum("FILE", "DIRECTORY") # @optional +dataFlow: enum("IN", "OUT", "INOUT", "NONE") # @optional +default: [ , ... ] # @optional +minLength: # @optional +maxLength: # @optional +item: # @optional + allowedValues: [ , ... ] # @optional + minLength: # @optional + maxLength: # @optional +userInterface: # @optional + control: enum("CHOOSE_INPUT_FILE_LIST", "CHOOSE_OUTPUT_FILE_LIST", "CHOOSE_DIRECTORY_LIST", "HIDDEN") + label: # @optional + groupLabel: # @optional + fileFilters: [ , ... ] # @optional + fileFilterDefault: # @optional +``` + +Where: + +1. *objectType* — The type of object the paths represent; either FILE or DIRECTORY. Default is DIRECTORY. +2. *dataFlow* — Whether the objects the paths represent serve as input, output or both for the Job. Default is NONE. +3. *minLength*/*maxLength* — Constrain the number of paths in the list. +4. *item.allowedValues*, *item.minLength*/*item.maxLength* — Constrain each path string. +5. *userInterface.control* — The user interface control to use. The default depends on *objectType* and *dataFlow*: + - If *objectType* is FILE and *dataFlow* is "OUT", default is "CHOOSE_OUTPUT_FILE_LIST" + - If *objectType* is FILE otherwise, default is "CHOOSE_INPUT_FILE_LIST" + - If *objectType* is DIRECTORY, default is "CHOOSE_DIRECTORY_LIST" +6. *fileFilters* — File filters for the file choice dialog (only for CHOOSE_INPUT_FILE_LIST/CHOOSE_OUTPUT_FILE_LIST). +7. *fileFilterDefault* — Default file filter for the file choice dialog. + +The value is referenced in format strings as: +- `Param.` - Returns a list[path] type value with path mapping applied +- `RawParam.` - Returns a list[string] type value without path mapping +- `Param.[i]` - Returns the i-th element as path +- `len(Param.)` - Returns the count of elements + +#### `` + +Defines a job parameter that accepts a list of integer values. + +```yaml +name: +type: "LIST[INT]" +description: # @optional +default: [ , ... ] # @optional +minLength: # @optional +maxLength: # @optional +item: # @optional + allowedValues: [ , ... ] # @optional + minValue: # @optional + maxValue: # @optional +userInterface: # @optional + control: enum("SPIN_BOX_LIST", "HIDDEN") + label: # @optional + groupLabel: # @optional + singleStepDelta: # @optional +``` + +Where *minLength*/*maxLength* constrain the number of items in the list, and +*item.allowedValues*, *item.minValue*/*item.maxValue* constrain each integer item. + +The value is referenced in format strings as: +- `Param.` - Returns a list[int] type value +- `Param.[i]` - Returns the i-th element as int +- `len(Param.)` - Returns the count of elements + +#### `` + +Defines a job parameter that accepts a list of floating-point values. + +```yaml +name: +type: "LIST[FLOAT]" +description: # @optional +default: [ , ... ] # @optional +minLength: # @optional +maxLength: # @optional +item: # @optional + allowedValues: [ , ... ] # @optional + minValue: # @optional + maxValue: # @optional +userInterface: # @optional + control: enum("SPIN_BOX_LIST", "HIDDEN") + label: # @optional + groupLabel: # @optional + decimals: # @optional + singleStepDelta: # @optional +``` + +Where *minLength*/*maxLength* constrain the number of items in the list, and +*item.allowedValues*, *item.minValue*/*item.maxValue* constrain each float item. + +The value is referenced in format strings as: +- `Param.` - Returns a list[float] type value +- `Param.[i]` - Returns the i-th element as float +- `len(Param.)` - Returns the count of elements + +#### `` + +Defines a job parameter that accepts a list of boolean values. + +```yaml +name: +type: "LIST[BOOL]" +description: # @optional +default: [ , ... ] # @optional +minLength: # @optional +maxLength: # @optional +userInterface: # @optional + control: enum("CHECK_BOX_LIST", "HIDDEN") + label: # @optional + groupLabel: # @optional +``` + +Where *minLength*/*maxLength* constrain the number of items in the list. + +Each list item accepts the same values as ``: +- JSON/YAML boolean literals: `true`, `false` +- Integer or float `1` or `1.0` (true), `0` or `0.0` (false) +- Case-insensitive strings representing true: `"true"`, `"yes"`, `"on"`, `"1"` +- Case-insensitive strings representing false: `"false"`, `"no"`, `"off"`, `"0"` + +The value is referenced in format strings as: +- `Param.` - Returns a list[bool] type value +- `Param.[i]` - Returns the i-th element as bool +- `len(Param.)` - Returns the count of elements + +#### `` + +Defines a job parameter that accepts a range expression string conforming to the +`` grammar from the specification. Currently, job templates use string +parameters for frame ranges, but this doesn't clearly represent the parameter's intent. +By defining a specific `RANGE_EXPR` type, tools that parse job templates can understand +that a parameter specifically accepts range expressions, enabling better validation, +UI controls, and documentation. + +```yaml +name: +type: "RANGE_EXPR" +description: # @optional +default: # @optional, must be valid +minLength: # @optional +maxLength: # @optional +userInterface: # @optional + control: enum("LINE_EDIT", "HIDDEN") + label: # @optional + groupLabel: # @optional +``` + +Where: + +1. *minLength* — Minimum string length of the range expression. Must be >= 1 if provided. +2. *maxLength* — Maximum string length of the range expression. Must be >= minLength if both provided. Default is 1024. +3. *userInterface.control* — The user interface control to use when editing this parameter. + The default, if not provided, is "LINE_EDIT". + - "LINE_EDIT" — A single-line text input for entering range expressions. + - "HIDDEN" — This hides the parameter from the user interface. + +The value must conform to the `` grammar: + +```bnf + ::= | , + ::= ** | ** | ** + ::= *-* + ::= : +``` + +Examples: `"1-100"`, `"1-100:10"`, `"1,3,5,7"`, `"1-10,20-30:2"` + +The value is referenced in format strings as: +- `Param.` - Returns a `range_expr` type value +- `RawParam.` - Returns a `range_expr` type value (identical to `Param.`) +- `list(Param.)` - Returns a `list[int]` with the expanded values + +This type is particularly useful for task parameter ranges: + +```yaml +parameterDefinitions: + - name: FrameRange + type: RANGE_EXPR + default: "1-100" +steps: + - name: Render + parameterSpace: + taskParameterDefinitions: + - name: Frame + type: INT + range: "{{Param.FrameRange}}" +``` + +#### `` + +Defines a job parameter that accepts a nested list of integer values. This enables use cases +like representing graph adjacency lists for task-task dependencies +(see [Discussion #82](https://github.com/OpenJobDescription/openjd-specifications/discussions/82)). +We are not defining a user interface control for this parameter type, as the identified +use case is for programmatically providing the adjacency list. + +```yaml +name: +type: "LIST[LIST[INT]]" +description: # @optional +default: [ [ , ... ], ... ] # @optional +minLength: # @optional +maxLength: # @optional +item: # @optional + minLength: # @optional + maxLength: # @optional + item: # @optional + allowedValues: [ , ... ] # @optional + minValue: # @optional + maxValue: # @optional +userInterface: # @optional + control: enum("HIDDEN") + label: # @optional + groupLabel: # @optional +``` + +Where *minLength*/*maxLength* constrain the number of inner lists, +*item.minLength*/*item.maxLength* constrain the size of each inner list, and +*item.item.allowedValues*, *item.item.minValue*/*item.item.maxValue* constrain each integer. + +The value is referenced in format strings as: +- `Param.` - Returns a list[list[int]] type value +- `Param.[i]` - Returns the i-th element as list[int] +- `Param.[i][j]` - Returns the j-th element of the i-th list as int +- `len(Param.)` - Returns the count of outer list elements +- `len(Param.[i])` - Returns a count of inner list elements + +## Design Choice Rationale + +### Case Insensitivity + +Making type names case-insensitive reduces friction for template authors who work in +environments where lowercase is conventional. It also aligns better with the surrounding +YAML syntax. + +### Nested Item Constraints + +List parameter constraints use a nested `item:` structure that mirrors the type nesting. +This allows each level to use the same property names as the corresponding scalar type +(`minLength`/`maxLength` for strings, `minValue`/`maxValue` for numbers), and scales +naturally to nested list types like `LIST[LIST[INT]]`. + +### List Type Constraints + +List types are limited to prevent excessive complexity: +- `list[T]` where `T` is a scalar type +- `list[list[T]]` for one level of nesting (no deeper) + +### Boolean Type + +A dedicated boolean type provides clearer semantics than string parameters with allowed +values. It enables proper UI controls (checkboxes) and type-safe expression evaluation. + +## Prior Art + +### JSON Schema + +JSON Schema supports boolean and array types natively. This RFC aligns OpenJD's type system +more closely with JSON's native types. + +### Other Workflow Languages + +Most workflow languages (WDL, CWL, Nextflow) support boolean and array/list parameter types +as fundamental building blocks. + +## Rejected Ideas + +### Flat Item Constraint Properties + +An earlier design used flat property names with prefixes to distinguish list-level and +item-level constraints: + +```yaml +minLength: 1 # list size +maxLength: 10 +minItemLength: 1 # item string length (for STRING/PATH) +maxItemLength: 100 +minItemValue: 0 # item value (for INT/FLOAT) +maxItemValue: 100 +``` + +For nested types like `LIST[LIST[INT]]`, this required increasingly awkward names like +`minItemIntValue`. The nested `item:` structure was chosen instead because it mirrors +the type nesting, reuses the same property names at each level, and scales cleanly to +any nesting depth. + +### Map/Dictionary Types + +A `map[K, V]` type was considered but rejected as too complex for initial implementation. +Most use cases can be handled with parallel lists or structured strings. + +### Deeply Nested Lists + +Supporting `list[list[list[T]]]` or deeper nesting was rejected to keep the type system +tractable and avoid complex validation logic. + +## Open Questions + +### Optional Parameter Types + +Should we extend job parameter types to support optional variants? This would use syntax like +`INT?`, `STRING?`, `FLOAT?`, etc. The specification would accept both `INT` and `INT?` for +`JobIntParameterDef`, with behavior changing based on whether the type is optional: + +- Clients could submit `null` as the job parameter value to indicate no value was provided +- Templates could use `Param.Value != null` to check if a value was supplied +- This would enable patterns like `['--quality', Param.Quality] if Param.Quality != null else null` + +This would provide a cleaner alternative to using sentinel values (like `0` or `-1`) to indicate +"no value" for numeric parameters. + +## Copyright + +This document is placed in the public domain or under the CC0-1.0-Universal license, whichever is more permissive. diff --git a/samples/expr-test-job.yaml b/samples/expr-test-job.yaml new file mode 100644 index 0000000..9ed4b71 --- /dev/null +++ b/samples/expr-test-job.yaml @@ -0,0 +1,42 @@ +specificationVersion: 'jobtemplate-2023-09' +extensions: + - EXPR +name: Expression Test Job +parameterDefinitions: + - name: InputFile + type: PATH + default: input/scene.exr + - name: OutputDir + type: PATH + default: output + - name: FrameStart + type: INT + default: 1 + - name: FrameEnd + type: INT + default: 10 + - name: Quality + type: STRING + allowedValues: ["draft", "final"] + default: draft +steps: + - name: Render + parameterSpace: + taskParameterDefinitions: + - name: Frame + type: INT + range: "{{Param.FrameStart}}-{{Param.FrameEnd}}" + script: + actions: + onRun: + command: echo + args: + # Basic arithmetic + - "Frame {{Task.Param.Frame}} of {{Param.FrameEnd - Param.FrameStart + 1}}" + # Path properties + - "Input stem: {{Param.InputFile.stem}}" + - "Input suffix: {{Param.InputFile.suffix}}" + # Path construction + - "Output: {{Param.OutputDir / Param.InputFile.stem + '_' + string(Task.Param.Frame) + '.exr'}}" + # Conditional expression + - "Samples: {{ 64 if Param.Quality == 'final' else 16 }}" diff --git a/samples/v2023-09/job_templates/blender-ffmpeg-expr.yaml b/samples/v2023-09/job_templates/blender-ffmpeg-expr.yaml new file mode 100644 index 0000000..d92265c --- /dev/null +++ b/samples/v2023-09/job_templates/blender-ffmpeg-expr.yaml @@ -0,0 +1,133 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# +# ---- +# Demonstrates +# ---- +# This demonstrates a Job that will render a series of animation frames +# from the Blender CLI for a given scene, using the EXPR extension for +# arithmetic and path expressions. +# +# Key EXPR features demonstrated: +# - Arithmetic: {{Param.EndFrame - Param.StartFrame + 1}} for total frame count +# - Path properties: {{Param.BlenderFile.stem}} for deriving output names +# - Path construction: {{Param.OutputDir / Param.BlenderFile.stem + '_####'}} +# +# You can see a summary of what this template will do by running: +# +# openjd summary blender-ffmpeg-expr.yaml +# +# To Run with default parameters use: +# +# openjd run blender-ffmpeg-expr.yaml --step CreateVideoFromRender --run-dependencies +# +# ---- +# Requirements (tested with Blender 4.0.2 and ffmpeg 6.1.1) +# ---- +# - bash shell +# - Blender +# - ffmpeg + +specificationVersion: 'jobtemplate-2023-09' +extensions: +- EXPR +name: '{{Param.JobName}}' +description: | + Render a blender animation using EXPR extension for arithmetic and path expressions. +parameterDefinitions: +- name: JobName + type: STRING + userInterface: + control: LINE_EDIT + label: Job Name + default: Blender Scene Renderer (EXPR) +- name: StartFrame + type: INT + default: 1 +- name: EndFrame + type: INT + default: 100 +- name: BlenderFile + type: PATH + objectType: FILE + dataFlow: IN + userInterface: + control: CHOOSE_INPUT_FILE + label: Blender File + default: "./scene/blender_scene.blend" + description: Choose the Blender scene you want to render. +- name: OutputDir + type: PATH + objectType: DIRECTORY + dataFlow: OUT + userInterface: + control: CHOOSE_DIRECTORY + label: Output Directory + default: "./output" + description: Choose the render output directory. +- name: Format + type: STRING + userInterface: + control: DROPDOWN_LIST + label: Output File Format + description: Choose the file format to render as. + default: PNG + allowedValues: [TGA, RAWTGA, JPEG, IRIS, IRIZ, PNG, HDR, TIFF, OPEN_EXR, OPEN_EXR_MULTILAYER, CINEON, DPX, DDS, JP2, WEBP] +steps: +- name: RenderScene + parameterSpace: + taskParameterDefinitions: + - name: Frame + type: INT + range: "{{Param.StartFrame}}-{{Param.EndFrame}}" + script: + actions: + onRun: + command: bash + args: ['{{ Task.File.run }}'] + embeddedFiles: + - name: run + type: TEXT + data: | + #!/bin/env bash + + set -xeuo pipefail + + mkdir -p '{{Param.OutputDir}}' + + echo "Rendering frame {{Task.Param.Frame}} of {{Param.EndFrame - Param.StartFrame + 1}} total" + echo "Scene: {{Param.BlenderFile.name}} (stem: {{Param.BlenderFile.stem}})" + + # Use path expressions to derive output pattern from input filename + blender --background '{{Param.BlenderFile}}' \ + --render-output '{{Param.OutputDir / Param.BlenderFile.stem + "_####"}}' \ + --render-format '{{Param.Format}}' \ + --use-extension 1 \ + --render-frame {{Task.Param.Frame}} + +- name: CreateVideoFromRender + dependencies: + - dependsOn: RenderScene + script: + actions: + onRun: + command: bash + args: [ "{{Task.File.Encode}}"] + embeddedFiles: + - name: Encode + type: TEXT + runnable: True + data: | + #!/bin/env bash + + set -xeuo pipefail + + echo "Encoding {{Param.EndFrame - Param.StartFrame + 1}} frames to video" + + # Output video named after the scene file + ffmpeg -y -r 10 -start_number {{Param.StartFrame}} \ + -i '{{Param.OutputDir / Param.BlenderFile.stem + "_%04d." + Param.Format}}' \ + -pix_fmt yuv420p \ + -vf "scale=in_color_matrix=bt709:out_color_matrix=bt709" \ + -frames:v 300 -c:v libx264 -preset fast \ + -color_range tv -colorspace bt709 -color_primaries bt709 -color_trc iec61966-2-1 \ + -movflags faststart '{{Param.OutputDir / Param.BlenderFile.stem + ".mp4"}}' diff --git a/wiki/2023-09-Template-Schemas.md b/wiki/2023-09-Template-Schemas.md index 18eb697..8c59bb1 100644 --- a/wiki/2023-09-Template-Schemas.md +++ b/wiki/2023-09-Template-Schemas.md @@ -49,7 +49,8 @@ Where: 3. *extensions* — If provided, is a non-empty list of extensions to the schema. Introduced in [RFC 0002](https://github.com/OpenJobDescription/openjd-specifications/blob/mainline/rfcs/0002-model-extensions.md). * Extensions available for specification version 2023-09: [TASK_CHUNKING](https://github.com/OpenJobDescription/openjd-specifications/blob/mainline/rfcs/0001-task-chunking.md), [REDACTED_ENV_VARS](https://github.com/OpenJobDescription/openjd-specifications/blob/mainline/rfcs/0003-redacted-env-vars.md), - [FEATURE_BUNDLE_1](https://github.com/OpenJobDescription/openjd-specifications/blob/mainline/rfcs/0004-enhanced-limits-and-capabilities.md) + [FEATURE_BUNDLE_1](https://github.com/OpenJobDescription/openjd-specifications/blob/mainline/rfcs/0004-enhanced-limits-and-capabilities.md), + [EXPR](https://github.com/OpenJobDescription/openjd-specifications/blob/mainline/rfcs/0005-expression-language.md) 4. *name* — The name to give to a Job that is created from the template. See: [<JobName>](#111-jobname). 5. *description* — A description to apply to all Jobs that are created from the template. It has no functional purpose, but may appear in UI elements. See: [<Description>](#72-description). @@ -129,9 +130,23 @@ valid -- for example, the value of the `default` property must adhere to the con ```bnf ::= | | - | + | | + | # @extension EXPR + | # @extension EXPR + | # @extension EXPR + | # @extension EXPR + | # @extension EXPR + | # @extension EXPR + | # @extension EXPR + # @extension EXPR ``` +When the `EXPR` extension is enabled, job parameter and task parameter type names become +case-insensitive. For example, `INT`, `Int`, and `int` are all equivalent, as are +`LIST[STRING]`, `List[String]`, and `list[string]`. See +[RFC 0007](https://github.com/OpenJobDescription/openjd-specifications/blob/mainline/rfcs/0007-extend-parameter-types.md) +for the design rationale. + ### 2.1. `` Defines a job parameter that allows input of a single string value to a Job Template. @@ -171,24 +186,26 @@ Where: 7. *userInterface* — User interface properties for this parameter. This metadata defines how a user interface element should be constructed to allow a user to input a value for the parameter. 1. *control* — The user interface control to use when editing this parameter. - The default, if not provided, is "LINE_EDIT" when *allowedValues* is not provided, “DROPDOWN_LIST” when it is. - 1. “LINE_EDIT“ — This is a freeform string line edit control. Cannot be used when *allowedValues* is provided. - 2. “MULTILINE_EDIT” — This is a freeform string multi-line edit control. It uses a fixed width font, intended for + The default, if not provided, is "LINE_EDIT" when *allowedValues* is not provided, "DROPDOWN_LIST" when it is. + 1. "LINE_EDIT" — This is a freeform string line edit control. Cannot be used when *allowedValues* is provided. + 2. "MULTILINE_EDIT" — This is a freeform string multi-line edit control. It uses a fixed width font, intended for editing script code. The vertical size of this control is set to grow to fit the available space. Cannot be used when *allowedValues* is provided. - 3. “DROPDOWN_LIST” — This is a dropdown list, for selecting from a fixed set of values. It requires that + 3. "DROPDOWN_LIST" — This is a dropdown list, for selecting from a fixed set of values. It requires that *allowedValues* is also provided. - 4. “CHECK_BOX” — This is a checkbox control. It requires that *allowedValues* is also provided, and contains two + 4. "CHECK_BOX" — This is a checkbox control. It requires that *allowedValues* is also provided, and contains two values, case-insensitive, one representing true and another representing false. - * Valid pairs are [“true”, “false”], [“yes”, “no”], [“on”, “off”], and [“1”, “0”]. - 5. “HIDDEN” — This hides the parameter from the user interface. - 2. *label* — The user interface label to use when displaying the parameter’s edit control. If not provided, the + * Valid pairs are ["true", "false"], ["yes", "no"], ["on", "off"], and ["1", "0"]. + 5. "HIDDEN" — This hides the parameter from the user interface. + 2. *label* — The user interface label to use when displaying the parameter's edit control. If not provided, the implementation should default to using the parameter *name*. See: [<UserInterfaceLabelStringValue>](#26-userinterfacelabelstringvalue). 3. *groupLabel* — Parameters with the same *groupLabel* value should be placed together in a grouping control with the value of *groupLabel* as its label. See: [<UserInterfaceLabelStringValue>](#26-userinterfacelabelstringvalue). +With the EXPR extension, the value of a Job Parameter of this type is referenced in format strings as `Param.`, returning a `string` type value. + ### 2.2. `` Defines a job parameter that allows input to a Job Template of a single string value that represents a file or directory @@ -239,25 +256,27 @@ Where: *objectType*, *dataFlow*, and *allowedValues*. If *objectType* is FILE, then if *dataFlow* is "OUT", it is "CHOOSE_OUTPUT_FILE", otherwise it is "CHOOSE_INPUT_FILE". If *objectType* is not "FILE", then it is "CHOOSE_DIRECTORY". If *allowedValues* is provided, then the default is instead "DROPDOWN_LIST". - 1. “CHOOSE_INPUT_FILE“ — This is a combination of a line edit and a button that uses the system’s input file + 1. "CHOOSE_INPUT_FILE" — This is a combination of a line edit and a button that uses the system's input file dialog. Cannot be used when *allowedValues* is provided. - 2. “CHOOSE_OUTPUT_FILE” — This is a combination of a line edit and a button that uses the system’s output/save + 2. "CHOOSE_OUTPUT_FILE" — This is a combination of a line edit and a button that uses the system's output/save file dialog. Cannot be used when *allowedValues* is provided. - 3. “CHOOSE_DIRECTORY” — This is a combination of a line edit and a button that uses the system’s directory dialog. + 3. "CHOOSE_DIRECTORY" — This is a combination of a line edit and a button that uses the system's directory dialog. Cannot be used when *allowedValues* is provided. - 4. “DROPDOWN_LIST” — This is a dropdown list, for selecting from a fixed set of values. It requires that + 4. "DROPDOWN_LIST" — This is a dropdown list, for selecting from a fixed set of values. It requires that *allowedValues* is also provided. - 5. “HIDDEN” — This hides the parameter from the user interface. - 2. *label* — The user interface label to use when displaying the parameter’s edit control. If not provided, the + 5. "HIDDEN" — This hides the parameter from the user interface. + 2. *label* — The user interface label to use when displaying the parameter's edit control. If not provided, the implementation should default to using the parameter *name*. See: [<UserInterfaceLabelStringValue>](#26-userinterfacelabelstringvalue). 3. *groupLabel* — Parameters with the same *groupLabel* value should be placed together in a grouping control with the value of *groupLabel* as its label. See: [<UserInterfaceLabelStringValue>](#26-userinterfacelabelstringvalue). - 4. fileFilters — Can be provided when the *uiControl* is “CHOOSE_INPUT_FILE” or “CHOOSE_OUTPUT_FILE”. Defines the file + 4. fileFilters — Can be provided when the *uiControl* is "CHOOSE_INPUT_FILE" or "CHOOSE_OUTPUT_FILE". Defines the file filters that are shown in the file choice dialog. Maximum of 20 filters. - 5. fileFilterDefault — Can be provided when the *uiControl* is “CHOOSE_INPUT_FILE” or “CHOOSE_OUTPUT_FILE”. The - default file filter that’s shown in the file choice dialog. + 5. fileFilterDefault — Can be provided when the *uiControl* is "CHOOSE_INPUT_FILE" or "CHOOSE_OUTPUT_FILE". The + default file filter that's shown in the file choice dialog. + +With the EXPR extension, the value of a Job Parameter of this type is referenced in format strings as `Param.`, returning a `path` type value. ### 2.3 `` @@ -300,12 +319,12 @@ Where `` is a string whose value is the string representation of an i greater than this. 7. *userInterface — User interface properties for this parameter* 1. *control* — The user interface control to use when editing this parameter. The default, if not provided, is - “SPIN_BOX” when *allowedValues* is not provided, “DROPDOWN_LIST” when it is. - 1. “SPIN_BOX“ — This is an integer editing control. Cannot be used when *allowedValues* is provided. - 2. “DROPDOWN_LIST” — This is a dropdown list, for selecting from a fixed set of values. It requires that + "SPIN_BOX" when *allowedValues* is not provided, "DROPDOWN_LIST" when it is. + 1. "SPIN_BOX" — This is an integer editing control. Cannot be used when *allowedValues* is provided. + 2. "DROPDOWN_LIST" — This is a dropdown list, for selecting from a fixed set of values. It requires that *allowedValues* is provided. - 3. “HIDDEN” — This hides the parameter from the user interface. - 2. *label* — The user interface label to use when displaying the parameter’s edit control. If not provided, the + 3. "HIDDEN" — This hides the parameter from the user interface. + 2. *label* — The user interface label to use when displaying the parameter's edit control. If not provided, the implementation should default to using the parameter *name*. See: [<UserInterfaceLabelStringValue>](#26-userinterfacelabelstringvalue). 3. *groupLabel* — Parameters with the same *groupLabel* value should be placed together in a grouping control with the @@ -314,6 +333,8 @@ Where `` is a string whose value is the string representation of an i 4. *singleStepDelta* — How much the value changes for a single step modification, such as selecting an up or down arrow in the user interface control. +With the EXPR extension, the value of a Job Parameter of this type is referenced in format strings as `Param.`, returning an `int` type value. + ### 2.4. `` Defines a job parameter that allows input to a Job Template of a single floating point or integer value. @@ -356,12 +377,12 @@ base-10, and: greater than this. 7. *userInterface — User interface properties for this parameter* 1. *control* — The user interface control to use when editing this parameter. The default, if not provided, is - “SPIN_BOX” when *allowedValues* is not provided, “DROPDOWN_LIST” when it is. - 1. “SPIN_BOX“ — This is a floating point editing control. Cannot be used when *allowedValues* is provided. - 2. “DROPDOWN_LIST” — This is a dropdown list, for selecting from a fixed set of values. It requires that + "SPIN_BOX" when *allowedValues* is not provided, "DROPDOWN_LIST" when it is. + 1. "SPIN_BOX" — This is a floating point editing control. Cannot be used when *allowedValues* is provided. + 2. "DROPDOWN_LIST" — This is a dropdown list, for selecting from a fixed set of values. It requires that *allowedValues* is provided. - 3. “HIDDEN” — This hides the parameter from the user interface. - 2. *label* — The user interface label to use when displaying the parameter’s edit control. If not provided, the + 3. "HIDDEN" — This hides the parameter from the user interface. + 2. *label* — The user interface label to use when displaying the parameter's edit control. If not provided, the implementation should default to using the parameter *name*. See: [<UserInterfaceLabelStringValue>](#26-userinterfacelabelstringvalue). 3. *groupLabel* — Parameters with the same *groupLabel* value should be placed together in a grouping control with the @@ -373,6 +394,8 @@ base-10, and: arrow in the user interface control. If *decimals* is provided, this is an absolute value, otherwise it is the fraction of the current value to use as an adaptive step. +With the EXPR extension, the value of a Job Parameter of this type is referenced in format strings as `Param.`, returning a `float` type value. + ### 2.5. `` A string value subject to the following constraints: @@ -392,7 +415,7 @@ A string value subject to the following constraints: ### 2.7. `` Represents one named file type for an input or output file choice dialog. For example: -`{“label”: “Image Files”, “patterns”: [“*.png”, “*.jpg”, “*.exr”]}` or `{“label”: “All Files”, “patterns”: [“*”]}`. +`{"label": "Image Files", "patterns": ["*.png", "*.jpg", "*.exr"]}` or `{"label": "All Files", "patterns": ["*"]}`. A `` is the object: @@ -405,16 +428,393 @@ patterns: [ , ... ] A string value subject to the following constraints: -1. Allowable values: “*”, “*.*”, and “*.[:file-extension-chars:]+”. The characters that :file-extension-chars: can take on +1. Allowable values: "*", "*.*", and "*.[:file-extension-chars:]+". The characters that :file-extension-chars: can take on are any unicode character except 1. The Cc unicode character category. - 2. Path separators “\” and “/”. - 3. Wildcard characters “*”, “?”, “[”, “]”. - 4. Characters commonly disallowed in paths: “#”, “%”, “&”, “{”, “}”, “<”, “>”, “$”, “!”, “‘”, “\"", ":", "@", "`", "|", + 2. Path separators "\" and "/". + 3. Wildcard characters "*", "?", "[", "]". + 4. Characters commonly disallowed in paths: "#", "%", "&", "{", "}", "<", ">", "$", "!", "'", "\"", ":", "@", "`", "|", and "=". 2. Minimum length: 1 character. 3. Maximum length: 20 characters. +### 2.9. `` `@extension EXPR` + +Defines a job parameter that accepts a boolean value. + +```yaml +name: +type: "BOOL" +description: # @optional +default: # @optional +userInterface: # @optional + control: enum("CHECK_BOX", "HIDDEN") + label: # @optional + groupLabel: # @optional +``` + +Accepted values are: +- JSON/YAML boolean literals: `true`, `false` +- Integer or float `1` or `1.0` (true), `0` or `0.0` (false) +- Case-insensitive strings representing true: `"true"`, `"yes"`, `"on"`, `"1"` +- Case-insensitive strings representing false: `"false"`, `"no"`, `"off"`, `"0"` + +Where: + +1. *name* — The name by which the parameter is referenced. See: [<Identifier>](#71-identifier). +2. *description* — A description to apply to the parameter. It has no functional purpose, but may appear in UI elements. + See: [<Description>](#72-description). +3. *default* — Default value to use for the parameter if the submission does not include a value for it. +4. *userInterface* — User interface properties for this parameter. + 1. *control* — The user interface control to use when editing this parameter. The default, if not provided, is "CHECK_BOX". + 1. "CHECK_BOX" — A checkbox control for boolean input. + 2. "HIDDEN" — This hides the parameter from the user interface. + 2. *label* — The user interface label to use when displaying the parameter's edit control. If not provided, the + implementation should default to using the parameter *name*. + See: [<UserInterfaceLabelStringValue>](#26-userinterfacelabelstringvalue). + 3. *groupLabel* — Parameters with the same *groupLabel* value should be placed together in a grouping control with the + value of *groupLabel* as its label. + See: [<UserInterfaceLabelStringValue>](#26-userinterfacelabelstringvalue). + +The value of a Job Parameter of this type is referenced in format strings as `Param.`, returning a `bool` type value. + +Note: Unlike other parameter types, `BOOL` does not support `allowedValues` because restricting +to only `true` or only `false` does not provide meaningful value. + +### 2.10. `` `@extension EXPR` + +Defines a job parameter that accepts a range expression string conforming to the +[``](#34111-intrangeexpr) grammar. + +```yaml +name: +type: "RANGE_EXPR" +description: # @optional +default: # @optional, must be valid +minLength: # @optional +maxLength: # @optional +userInterface: # @optional + control: enum("LINE_EDIT", "HIDDEN") + label: # @optional + groupLabel: # @optional +``` + +Where: + +1. *name* — The name by which the parameter is referenced. See: [<Identifier>](#71-identifier). +2. *description* — A description to apply to the parameter. It has no functional purpose, but may appear in UI elements. + See: [<Description>](#72-description). +3. *default* — Default value to use for the parameter if the submission does not include a value for it. + Must be a valid ``. +4. *minLength* — Minimum string length of the range expression. Must be >= 1 if provided. +5. *maxLength* — Maximum string length of the range expression. Default is 1024. +6. *userInterface* — User interface properties for this parameter. + 1. *control* — The user interface control to use when editing this parameter. The default, if not provided, is "LINE_EDIT". + 1. "LINE_EDIT" — A single-line text input for entering range expressions. + 2. "HIDDEN" — This hides the parameter from the user interface. + 2. *label* — The user interface label to use when displaying the parameter's edit control. If not provided, the + implementation should default to using the parameter *name*. + See: [<UserInterfaceLabelStringValue>](#26-userinterfacelabelstringvalue). + 3. *groupLabel* — Parameters with the same *groupLabel* value should be placed together in a grouping control with the + value of *groupLabel* as its label. + See: [<UserInterfaceLabelStringValue>](#26-userinterfacelabelstringvalue). + +The value of a Job Parameter of this type is referenced in format strings as: + +1. `Param.` — Returns a `range_expr` type value. +2. `RawParam.` — Returns a `range_expr` type value (identical to `Param.`). +3. `list(Param.)` — Returns a `list[int]` with the expanded values. + +### 2.11. `` `@extension EXPR` + +Defines a job parameter that accepts a list of string values. + +```yaml +name: +type: "LIST[STRING]" +description: # @optional +default: [ , ... ] # @optional +minLength: # @optional +maxLength: # @optional +item: # @optional + allowedValues: [ , ... ] # @optional + minLength: # @optional + maxLength: # @optional +userInterface: # @optional + control: enum("LINE_EDIT_LIST", "HIDDEN") + label: # @optional + groupLabel: # @optional +``` + +Where: + +1. *name* — The name by which the parameter is referenced. See: [<Identifier>](#71-identifier). +2. *description* — A description to apply to the parameter. It has no functional purpose, but may appear in UI elements. + See: [<Description>](#72-description). +3. *default* — Default value to use for the parameter if the submission does not include a value for it. +4. *minLength*/*maxLength* — Constrain the number of items in the list. +5. *item* — Constraints for each item in the list. + 1. *allowedValues* — An array of the values that each item is allowed to be. + 2. *minLength*/*maxLength* — Constrain the string length of each item. +6. *userInterface* — User interface properties for this parameter. + 1. *control* — The user interface control to use when editing this parameter. The default, if not provided, is "LINE_EDIT_LIST". + 1. "LINE_EDIT_LIST" — A list of line edit controls. + 2. "HIDDEN" — This hides the parameter from the user interface. + 2. *label* — See: [<UserInterfaceLabelStringValue>](#26-userinterfacelabelstringvalue). + 3. *groupLabel* — See: [<UserInterfaceLabelStringValue>](#26-userinterfacelabelstringvalue). + +The value of a Job Parameter of this type is referenced in format strings as: + +1. `Param.` — Returns a `list[string]` type value. +2. `Param.[i]` — Returns the i-th element as `string`. +3. `len(Param.)` — Returns the count of elements. + +### 2.12. `` `@extension EXPR` + +Defines a job parameter that accepts a list of path values. + +```yaml +name: +type: "LIST[PATH]" +description: # @optional +objectType: enum("FILE", "DIRECTORY") # @optional +dataFlow: enum("IN", "OUT", "INOUT", "NONE") # @optional +default: [ , ... ] # @optional +minLength: # @optional +maxLength: # @optional +item: # @optional + allowedValues: [ , ... ] # @optional + minLength: # @optional + maxLength: # @optional +userInterface: # @optional + control: enum("CHOOSE_INPUT_FILE_LIST", "CHOOSE_OUTPUT_FILE_LIST", + "CHOOSE_DIRECTORY_LIST", "HIDDEN") + label: # @optional + groupLabel: # @optional + fileFilters: [ , ... ] # @optional + fileFilterDefault: # @optional +``` + +Where: + +1. *name* — The name by which the parameter is referenced. See: [<Identifier>](#71-identifier). +2. *description* — A description to apply to the parameter. It has no functional purpose, but may appear in UI elements. + See: [<Description>](#72-description). +3. *objectType* — The type of object the paths represent; either FILE or DIRECTORY. Default is DIRECTORY. +4. *dataFlow* — Whether the objects the paths represent serve as input, output or both for the Job. Default is NONE. +5. *default* — Default value to use for the parameter if the submission does not include a value for it. +6. *minLength*/*maxLength* — Constrain the number of paths in the list. +7. *item* — Constraints for each item in the list. + 1. *allowedValues* — An array of the values that each item is allowed to be. + 2. *minLength*/*maxLength* — Constrain the string length of each path. +8. *userInterface* — User interface properties for this parameter. + 1. *control* — The user interface control to use. The default depends on *objectType* and *dataFlow*: + If *objectType* is FILE and *dataFlow* is "OUT", default is "CHOOSE_OUTPUT_FILE_LIST". + If *objectType* is FILE otherwise, default is "CHOOSE_INPUT_FILE_LIST". + If *objectType* is DIRECTORY, default is "CHOOSE_DIRECTORY_LIST". + 1. "CHOOSE_INPUT_FILE_LIST" — A list of input file choice controls. + 2. "CHOOSE_OUTPUT_FILE_LIST" — A list of output file choice controls. + 3. "CHOOSE_DIRECTORY_LIST" — A list of directory choice controls. + 4. "HIDDEN" — This hides the parameter from the user interface. + 2. *label* — See: [<UserInterfaceLabelStringValue>](#26-userinterfacelabelstringvalue). + 3. *groupLabel* — See: [<UserInterfaceLabelStringValue>](#26-userinterfacelabelstringvalue). + 4. *fileFilters* — File filters for the file choice dialog (only for CHOOSE_INPUT_FILE_LIST/CHOOSE_OUTPUT_FILE_LIST). + 5. *fileFilterDefault* — Default file filter for the file choice dialog. + +The value of a Job Parameter of this type is referenced in format strings as: + +1. `Param.` — Returns a `list[path]` type value with path mapping applied. +2. `RawParam.` — Returns a `list[string]` type value without path mapping. +3. `Param.[i]` — Returns the i-th element as `path`. +4. `len(Param.)` — Returns the count of elements. + +### 2.13. `` `@extension EXPR` + +Defines a job parameter that accepts a list of integer values. + +```yaml +name: +type: "LIST[INT]" +description: # @optional +default: [ , ... ] # @optional +minLength: # @optional +maxLength: # @optional +item: # @optional + allowedValues: [ , ... ] # @optional + minValue: # @optional + maxValue: # @optional +userInterface: # @optional + control: enum("SPIN_BOX_LIST", "HIDDEN") + label: # @optional + groupLabel: # @optional + singleStepDelta: # @optional +``` + +Where: + +1. *name* — The name by which the parameter is referenced. See: [<Identifier>](#71-identifier). +2. *description* — A description to apply to the parameter. It has no functional purpose, but may appear in UI elements. + See: [<Description>](#72-description). +3. *default* — Default value to use for the parameter if the submission does not include a value for it. +4. *minLength*/*maxLength* — Constrain the number of items in the list. +5. *item* — Constraints for each item in the list. + 1. *allowedValues* — An array of the values that each item is allowed to be. + 2. *minValue*/*maxValue* — Constrain the value of each integer item. +6. *userInterface* — User interface properties for this parameter. + 1. *control* — The user interface control to use when editing this parameter. The default, if not provided, is "SPIN_BOX_LIST". + 1. "SPIN_BOX_LIST" — A list of integer editing controls. + 2. "HIDDEN" — This hides the parameter from the user interface. + 2. *label* — See: [<UserInterfaceLabelStringValue>](#26-userinterfacelabelstringvalue). + 3. *groupLabel* — See: [<UserInterfaceLabelStringValue>](#26-userinterfacelabelstringvalue). + 4. *singleStepDelta* — How much the value changes for a single step modification. + +The value of a Job Parameter of this type is referenced in format strings as: + +1. `Param.` — Returns a `list[int]` type value. +2. `Param.[i]` — Returns the i-th element as `int`. +3. `len(Param.)` — Returns the count of elements. + +### 2.14. `` `@extension EXPR` + +Defines a job parameter that accepts a list of floating-point values. + +```yaml +name: +type: "LIST[FLOAT]" +description: # @optional +default: [ , ... ] # @optional +minLength: # @optional +maxLength: # @optional +item: # @optional + allowedValues: [ , ... ] # @optional + minValue: # @optional + maxValue: # @optional +userInterface: # @optional + control: enum("SPIN_BOX_LIST", "HIDDEN") + label: # @optional + groupLabel: # @optional + decimals: # @optional + singleStepDelta: # @optional +``` + +Where: + +1. *name* — The name by which the parameter is referenced. See: [<Identifier>](#71-identifier). +2. *description* — A description to apply to the parameter. It has no functional purpose, but may appear in UI elements. + See: [<Description>](#72-description). +3. *default* — Default value to use for the parameter if the submission does not include a value for it. +4. *minLength*/*maxLength* — Constrain the number of items in the list. +5. *item* — Constraints for each item in the list. + 1. *allowedValues* — An array of the values that each item is allowed to be. + 2. *minValue*/*maxValue* — Constrain the value of each float item. +6. *userInterface* — User interface properties for this parameter. + 1. *control* — The user interface control to use when editing this parameter. The default, if not provided, is "SPIN_BOX_LIST". + 1. "SPIN_BOX_LIST" — A list of floating point editing controls. + 2. "HIDDEN" — This hides the parameter from the user interface. + 2. *label* — See: [<UserInterfaceLabelStringValue>](#26-userinterfacelabelstringvalue). + 3. *groupLabel* — See: [<UserInterfaceLabelStringValue>](#26-userinterfacelabelstringvalue). + 4. *decimals* — The number of places editable after the decimal point. + 5. *singleStepDelta* — How much the value changes for a single step modification. + +The value of a Job Parameter of this type is referenced in format strings as: + +1. `Param.` — Returns a `list[float]` type value. +2. `Param.[i]` — Returns the i-th element as `float`. +3. `len(Param.)` — Returns the count of elements. + +### 2.15. `` `@extension EXPR` + +Defines a job parameter that accepts a list of boolean values. + +```yaml +name: +type: "LIST[BOOL]" +description: # @optional +default: [ , ... ] # @optional +minLength: # @optional +maxLength: # @optional +userInterface: # @optional + control: enum("CHECK_BOX_LIST", "HIDDEN") + label: # @optional + groupLabel: # @optional +``` + +Each list item accepts the same values as [``](#29-jobboolparameterdefinition): +- JSON/YAML boolean literals: `true`, `false` +- Integer or float `1` or `1.0` (true), `0` or `0.0` (false) +- Case-insensitive strings representing true: `"true"`, `"yes"`, `"on"`, `"1"` +- Case-insensitive strings representing false: `"false"`, `"no"`, `"off"`, `"0"` + +Where: + +1. *name* — The name by which the parameter is referenced. See: [<Identifier>](#71-identifier). +2. *description* — A description to apply to the parameter. It has no functional purpose, but may appear in UI elements. + See: [<Description>](#72-description). +3. *default* — Default value to use for the parameter if the submission does not include a value for it. +4. *minLength*/*maxLength* — Constrain the number of items in the list. +5. *userInterface* — User interface properties for this parameter. + 1. *control* — The user interface control to use when editing this parameter. The default, if not provided, is "CHECK_BOX_LIST". + 1. "CHECK_BOX_LIST" — A list of checkbox controls. + 2. "HIDDEN" — This hides the parameter from the user interface. + 2. *label* — See: [<UserInterfaceLabelStringValue>](#26-userinterfacelabelstringvalue). + 3. *groupLabel* — See: [<UserInterfaceLabelStringValue>](#26-userinterfacelabelstringvalue). + +The value of a Job Parameter of this type is referenced in format strings as: + +1. `Param.` — Returns a `list[bool]` type value. +2. `Param.[i]` — Returns the i-th element as `bool`. +3. `len(Param.)` — Returns the count of elements. + +### 2.16. `` `@extension EXPR` + +Defines a job parameter that accepts a nested list of integer values. This enables use cases +like representing graph adjacency lists for task-task dependencies. + +```yaml +name: +type: "LIST[LIST[INT]]" +description: # @optional +default: [ [ , ... ], ... ] # @optional +minLength: # @optional +maxLength: # @optional +item: # @optional + minLength: # @optional + maxLength: # @optional + item: # @optional + allowedValues: [ , ... ] # @optional + minValue: # @optional + maxValue: # @optional +userInterface: # @optional + control: enum("HIDDEN") + label: # @optional + groupLabel: # @optional +``` + +Where: + +1. *name* — The name by which the parameter is referenced. See: [<Identifier>](#71-identifier). +2. *description* — A description to apply to the parameter. It has no functional purpose, but may appear in UI elements. + See: [<Description>](#72-description). +3. *default* — Default value to use for the parameter if the submission does not include a value for it. +4. *minLength*/*maxLength* — Constrain the number of inner lists. +5. *item* — Constraints for each inner list. + 1. *minLength*/*maxLength* — Constrain the size of each inner list. + 2. *item* — Constraints for each integer in the inner list. + 1. *allowedValues* — An array of the values that each integer is allowed to be. + 2. *minValue*/*maxValue* — Constrain the value of each integer. +6. *userInterface* — User interface properties for this parameter. + 1. *control* — The only supported control is "HIDDEN", which hides the parameter from the user interface. + 2. *label* — See: [<UserInterfaceLabelStringValue>](#26-userinterfacelabelstringvalue). + 3. *groupLabel* — See: [<UserInterfaceLabelStringValue>](#26-userinterfacelabelstringvalue). + +The value of a Job Parameter of this type is referenced in format strings as: + +1. `Param.` — Returns a `list[list[int]]` type value. +2. `Param.[i]` — Returns the i-th element as `list[int]`. +3. `Param.[i][j]` — Returns the j-th element of the i-th list as `int`. +4. `len(Param.)` — Returns the count of outer list elements. +5. `len(Param.[i])` — Returns the count of inner list elements. + ## 3. `` A `` defines a single Step in the Job; the action(s) that it takes, its dependencies, the parameter space @@ -425,6 +825,7 @@ A `` is the object: ```yaml name: description: # @optional +let: # @optional @extension EXPR dependencies: [ , ... ] # @optional stepEnvironments: [ , ... ] # @optional hostRequirements: # @optional @@ -438,12 +839,14 @@ Where: the names of all other Steps in the same Job Template. See: [<StepName>](#31-stepname). 2. *description* — A description to apply to the step. It has no functional purpose, but may appear in UI elements. See: [<Description>](#72-description). -3. *dependencies* — A list of the dependencies of this Step. These dependencies must be resolved before the Tasks of the +3. *let* — An ordered list of expression bindings evaluated once per step. Bound names are available in + *stepEnvironments*, *hostRequirements*, *parameterSpace*, and *script* fields. See: [<LetBindings>](#36-letbindings). +4. *dependencies* — A list of the dependencies of this Step. These dependencies must be resolved before the Tasks of the Step may be scheduled. See: [<StepDependency>](#32-stepdependency) * Minimum number of elements: If provided, then this list must contain at least one element. * Maximum number of elements: There is no maximum defined, though implementations may choose to constrain the number of dependencies. -4. *stepEnvironments* — An ordered list of the environments that are required to run Tasks in this Step. +5. *stepEnvironments* — An ordered list of the environments that are required to run Tasks in this Step. These are entered in the order provided at the start of every Session for Tasks in the Step, and exited in the reverse order at the end of those Sessions. See: [<Environment>](#4-environment). @@ -451,12 +854,12 @@ Where: 1. No two Environments in this list may have the same value for the `name` property. 2. The Environments defined in this list must not have the same `name` as a Job Environment defined in the same Job Template. -5. *hostRequirements* — Describes the requirements on Worker host's capabilities that must be satisfied for the Task(s) of +6. *hostRequirements* — Describes the requirements on Worker host's capabilities that must be satisfied for the Task(s) of the Step to be scheduled to the host. See: [<HostRequirements>](#33-hostrequirements). -6. *parameterSpace* — Defines the parameterization of the Step's action; the available parameters, the values that they +7. *parameterSpace* — Defines the parameterization of the Step's action; the available parameters, the values that they take on, and how those parameters' values are combined to produce the Tasks of the Step. Absent this property the Step is run a single time. -7. *script* — The action that is taken by this Step's Tasks when they are run on a Worker host. +8. *script* — The action that is taken by this Step's Tasks when they are run on a Worker host. ### 3.1. `` @@ -490,7 +893,7 @@ capabilities that must be satisfied for the Task(s) of the Step to be scheduled Each requirement corresponds to an attribute of a host or render manager that must be satisfied to allow the Step to be scheduled to the host. Some examples of concrete attributes include processor architecture (x86_64, arm64, etc), the number of CPU cores, the amount of system memory, or available floating licenses for an application. We also allow for -user-defined whose meaning is defined by the customer; a “SoftwareConfig” requirement whose values could be “Option1” or “Option2”, +user-defined whose meaning is defined by the customer; a "SoftwareConfig" requirement whose values could be "Option1" or "Option2", for example. There are two types of requirements: attribute and amount. @@ -520,8 +923,8 @@ With the constraints: Amount requirements are the mechanism for defining a quantity of something that the Worker host or render manager needs to have for a Step to run. They represent quantifiable things that need to be reserved to do the work — vCPUs, memory, licenses, etc. They are always non-negative floating point valued, and a Step can require a certain amount of that capability to be able to run — -“at least 4 CPU cores” for example. Further, a quantity of each amount required are logically allocated to a Session while that -session is running on a host. A Step requiring, say, “at least 4 CPU cores”, might result in a Session with 4 CPU cores allocated +"at least 4 CPU cores" for example. Further, a quantity of each amount required are logically allocated to a Session while that +session is running on a host. A Step requiring, say, "at least 4 CPU cores", might result in a Session with 4 CPU cores allocated to it being created on a host. Those cores are reserved for that Session while that Session is running on the host; effectively making the number of available cores on the host 4 less for scheduling purposes during the duration of the Session. Logically allocating amounts to Sessions is the key mechanism by which system resources can be optimally utilized through bin packing @@ -823,7 +1226,7 @@ using the following names: 1. `Task.Param.` — the value of the parameter with relevant path mapping rules applied to it; and 2. `Task.RawParam.` — the value of the parameter as it was defined, with no path mapping rules applied. -##### 3.4.1.5. `` `# @extension TASK_CHUNKING` +##### 3.4.1.5. `` `@extension TASK_CHUNKING` An integer valued task parameter, processed as chunks instead of as individual elements. At most one `CHUNK[INT]` parameter can be specified in a step parameter space. When forming @@ -937,6 +1340,7 @@ The Script of a Step defines the properties of the action that the Step runs on A `` is the object: ```yaml +let: # @optional @extension EXPR actions: # @incompatible python | bash | cmd | powershell | node embeddedFiles: [ , ... ] # @optional @incompatible python | bash | cmd | powershell | node python | bash | cmd | powershell | node: # @optional @fmtstring[host] @incompatible command embeddedFiles @extension FEATURE_BUNDLE_1 @@ -944,19 +1348,22 @@ python | bash | cmd | powershell | node: # @optional @fmtstring[h Where: -1. *actions* — The Actions that are run by Tasks of the Step. -2. *embeddedFiles* — Files embedded into the Step that are materialized to a Session's working directory as the Step's +1. *let* — An ordered list of expression bindings evaluated once per task. Bound names are available in *actions* + and *embeddedFiles* fields, and can reference `Task.Param.*` values. See: [<LetBindings>](#36-letbindings). +2. *actions* — The Actions that are run by Tasks of the Step. +3. *embeddedFiles* — Files embedded into the Step that are materialized to a Session's working directory as the Step's Task is running within the Session. See: [<EmbeddedFile>](#6-embeddedfile). 1. Minimum number of items: If defined, then there must be at least one element in this list. 2. Maximum number of items: There is no limit on the number of elements in this list. -3. *bash | cmd | node | powershell | python* - Syntactic sugar for scripts, +4. *bash | cmd | node | powershell | python* - Syntactic sugar for scripts, removes some commonly needed boilerplate for the given script interpreter. The format string scopes available to format strings within a `` are: 1. `Param.*` and `RawParam.*` — Values of Job Parameters. -2. `Session.*` — Values such as the Session’s working directory. +2. `Session.*` — Values such as the Session's working directory. 3. `Task.*` — Values of embedded file locations defined within the ``, and Task Parameters. +4. Names bound by `let` in the enclosing `` and ``. Available with the `EXPR` extension. #### 3.5.1. `` @@ -970,6 +1377,78 @@ Where: 1. *onRun* — The action that is run when a Task for the Step is run on a host. See: [<Action>](#5-action). +### 3.6. `` + +Available when using the `EXPR` extension. + +A `` is an ordered array of `` strings: + +```yaml +let: + - + - + ... +``` + +Bindings are evaluated in declaration order. Later bindings can reference names from earlier bindings in the same +`let` block. A binding that shadows a previous binding in the same `let` block or any enclosing scope is an error. +For example, a `let` binding in a `` cannot shadow a binding from the enclosing ``'s `let` +block, and a `let` binding in a ``'s *stepEnvironments* cannot shadow a binding from that step's `let` block. + +Constraints: +1. Minimum number of items: If defined, then there must be at least one element in this list. +2. Maximum number of items: 50. + +#### 3.6.1. `` + +A `` is a string containing a Python assignment expression: + +```bnf + ::= *"="* + ::= [a-z_][A-Za-z0-9_]* + ::= (any valid expression as defined by the EXPR extension) + ::= whitespace character: tabs or spaces +``` + +Where: + +1. The `` must start with a lowercase letter or underscore. This ensures user-defined names never + conflict with spec-defined symbols (`Param`, `Task`, `Session`, `Env`, `RawParam`), which always start with an + uppercase letter. +2. Minimum length of ``: 1 character. +3. Maximum length of ``: 512 characters. +4. The `` is evaluated according to the rules defined by the `EXPR` extension. See: + [Expression Language Specification](2026-02-Expression-Language). +5. The type of the binding is the natural result type of the expression. + +Examples: + +```yaml +let: + - frame_count = Param.EndFrame - Param.StartFrame + 1 + - output_dir = Param.OutputRoot / Param.ProjectName + - files = [Param.InputDir / f for f in Param.FileNames] +``` + +#### 3.6.2. Let Binding Scope Summary + +The following table summarizes where `let` bindings can appear, what symbols they can reference, +and where the bound names are available: + +| Location | Can Reference | Bound Names Available In | +|----------|---------------|--------------------------| +| `.let` | `Param.*`, `RawParam.*`, earlier bindings in same `let` | *stepEnvironments*, *hostRequirements*, *parameterSpace*, *script* (including nested `.let` or `.let`) | +| `.let` | `Param.*`, `RawParam.*`, `Task.Param.*`, `Task.RawParam.*`, `Session.*`, step-level bindings, earlier bindings in same `let` | *actions*, *embeddedFiles* | +| `.let` | `Param.*`, `RawParam.*`, `Task.Param.*`, `Task.RawParam.*`, `Session.*`, step-level bindings, earlier bindings in same `let` | *script*, *args* | +| `.let` | `Param.*`, `RawParam.*`, `Session.*`, `Env.File.*`, earlier bindings in same `let` | *actions*, *embeddedFiles* | + +Note: `Task.Param.*` and `Task.RawParam.*` are only available in `` and `` contexts because task parameter +values are not known until task execution time. + +Note: `Env.File.*` symbols in `.let` refer to embedded files defined in the same ``. +The file path is determined before evaluation, so `let` bindings can reference the path where the file will be written, +even though the file content (which may also contain format strings) is evaluated separately. + ## 4. `` The Environment is a mechanism provided in this specification to enable users to amortize expensive, or time-consuming, @@ -1000,9 +1479,10 @@ Where: The format string scopes available to format strings within an Environment are: 1. `Param.*` and `RawParam.*` — Values of Job Parameters. -2. `Session.*` — Values such as the Session’s working directory. +2. `Session.*` — Values such as the Session's working directory. 3. `Env.*` — Scope of the environment entity itself. Values such as the embedded files defined within the Environment entity. +4. Names bound by `let` in the enclosing ``. Available with the `EXPR` extension. Implementations of this specfication must watch STDOUT when running the `onEnter` action for any line matching: @@ -1032,12 +1512,15 @@ A string value subject to the following constraints: An `` is the object: ```yaml +let: # @optional @extension EXPR actions: embeddedFiles: [ , ... ] # @optional ``` -1. *actions* — The actions to run at different stages of the Environment’s lifecycle. -2. *embeddedFiles* — Files embedded into the Environment that are materialized to a Session's working directory as the +1. *let* — An ordered list of expression bindings evaluated once when the environment is entered. Bound names are + available in *actions* and *embeddedFiles* fields. See: [<LetBindings>](#36-letbindings). +2. *actions* — The actions to run at different stages of the Environment's lifecycle. +3. *embeddedFiles* — Files embedded into the Environment that are materialized to a Session's working directory as the Environment is running within the Session. See: [<EmbeddedFile>](#6-embeddedfile). 1. Minimum number of items: If defined, then there must be at least one element in this list. 2. Maximum number of items: There is no limit on the number of elements in this list. @@ -1227,7 +1710,7 @@ Implementation notes: A step or environment script can have data attached to it via this mechanism. The embedded data is made available to the environment/task action(s) as a file within the Session working directory while being run on a host. This file is -written prior to every one of the corresponding actions each time that they are run. The materialized files’ permissions +written prior to every one of the corresponding actions each time that they are run. The materialized files' permissions are read-only by only the user under which the task will be run on the worker host. ```bnf @@ -1237,7 +1720,7 @@ are read-only by only the user under which the task will be run on the worker ho ### 6.1. `` Embedding of a plain text file into the template. The *data* provided in the file is written as a plain text file. This -file is written prior to every one of the script’s actions each time that they are run. +file is written prior to every one of the script's actions each time that they are run. ```yaml name: @@ -1329,6 +1812,11 @@ Format String `"The value of Job Parameter 'Name' is: {{ Param.Name }}"` and a v for the symbol `Param.Name` of "Bob", the resulting resolved string is `"The value of Job Parameter 'Name' is: Bob"`. +When the `EXPR` extension is enabled, the `` grammar is extended to support +the full expression language including arithmetic, conditionals, function calls, list operations, +and more. See the [Expression Language](2026-02-Expression-Language#11-extended-format-string-grammar) +specification for the extended grammar, type system, and evaluation semantics. + #### 7.3.1. Value References |**Value**|**Description**|**Scope**| @@ -1340,8 +1828,9 @@ for the symbol `Param.Name` of "Bob", the resulting resolved string is |`Task.File.`|The filesystem location to which the Task Embedded File with key `` has been written.| Available within the Step Script Actions and Embedded Files.| |`Env.File.`|The filesystem location to which the Environment Attachment with key `` has been written.|Available within the Environment Script Actions and Embedded Files.| |`Session.WorkingDirectory`|The agent is expected to create a local temporary scratch directory for the duration of a Session. This builtin provides the location of that temporary directory. This is the working directory that the Worker Agent uses when running the task.|This is available within all Environment Script Actions & Embedded Files, and all Step Script Actions and Embedded Files.| -|`Session.HasPathMappingRules`|This value can be used to determine whether path mapping rules are available to the Session. It is string valued, with values "true" or "false". "true" means that the path mapping JSON contains path mapping rules. "false" means that the contents of the path mapping JSON are the empty object.|This is available within all Environment Script Actions & Embedded Files, and all Step Script Actions and Embedded Files.| +|`Session.HasPathMappingRules`|This value can be used to determine whether path mapping rules are available to the Session. Without the `EXPR` extension, it is string valued, with values "true" or "false". With the `EXPR` extension enabled, it is a `bool` type. "true"/`True` means that the path mapping JSON contains path mapping rules. "false"/`False` means that the contents of the path mapping JSON are the empty object.|This is available within all Environment Script Actions & Embedded Files, and all Step Script Actions and Embedded Files.| |`Session.PathMappingRulesFile`|This is a string whose value is the location of a JSON file on the worker node's local disk that contains the path mapping rule substitutions for the Session.|This is available within all Environment Script Actions & Embedded Files, and all Step Script Actions and Embedded Files.| +|`` (let binding)|Names bound by `let` in ``, ``, ``, or ``. Names must start with a lowercase letter or underscore (see ``). The type is determined by the expression. Available with the `EXPR` extension.|See [Let Binding Scope Summary](#362-let-binding-scope-summary) for detailed scoping rules.| ## 8. `` @@ -1364,24 +1853,31 @@ expected to be available in the runtime environment. steps: - name: BashStep bash: + let: # optional, requires EXPR extension + - output_file = Param.OutputDir / Param.Pattern.with_number(Task.Param.Frame) args: ["--additional-argument"] # optional script: | # bash code here + echo Output: {{repr_sh(output_file)}} ### syntax sugar equivalent to: steps: - name: BashStep - actions: + script: + let: + - output_file = Param.OutputDir / Param.Pattern.with_number(Task.Param.Frame) + actions: onRun: - command: bash - args: ["{{Task.File.}}", "--additional-argument"] - embeddedFiles: + command: bash + args: ["{{Task.File.}}", "--additional-argument"] + embeddedFiles: - name: filename: ".sh" type: TEXT - data: - # bash code here + data: | + # bash code here + echo Output: {{repr_sh(output_file)}} ``` * *cmd* - Implicitly creates a Batch embedded file, @@ -1503,6 +1999,7 @@ expected to be available in the runtime environment. A `` is the object: ```yaml +let: # @optional @extension EXPR script: # @fmtstring[host] args: [ , ... ] # @optional @fmtstring[host] timeout: | # @optional @fmtstring @@ -1512,11 +2009,14 @@ cancelation: # @optional Where `` is a string whose value is the string representation of a positive integer value in base-10, and: -1. *script* — The [Data String](#data-string) that will be written to the script +1. *let* — An ordered list of expression bindings evaluated once per task. Bound names are available in *script* + and *args* fields, and can reference `Task.Param.*` values. See: [<LetBindings>](#36-letbindings). + Requires both the `FEATURE_BUNDLE_1` and `EXPR` extensions. +2. *script* — The [Data String](#data-string) that will be written to the script exactly as it appears. -2. *args* — An array of [Format Strings](#73-format-strings) that will be passed +3. *args* — An array of [Format Strings](#73-format-strings) that will be passed as arguments to the **command** when the command is run on the host. -3. *timeout* — The positive number of seconds that the command is given to +4. *timeout* — The positive number of seconds that the command is given to successfully run to completion. A command that does not return before the timeout is canceled and is treated as a failed run. @@ -1533,7 +2033,7 @@ positive integer value in base-10, and: default expectation should be that OpenJobDescription sessions are able to end and cleanup within a bound duration of time. -4. *cancelation* — If defined, provides details regarding how this action should +5. *cancelation* — If defined, provides details regarding how this action should be canceled. If not provided, then it is treated as though provided with ``. The host uses the return code of the interpreter run to determine success or @@ -1549,10 +2049,10 @@ non-zero exit code indicates failure. A timeout also indicates failure. ## 10. License -Copyright ©2023 Amazon.com Inc. or Affiliates (“Amazon”). +Copyright ©2023 Amazon.com Inc. or Affiliates ("Amazon"). This Agreement sets forth the terms under which Amazon is making the Open Job Description -Specification (“the Specification”) available to you. +Specification ("the Specification") available to you. ### 10.1. Copyrights @@ -1565,7 +2065,7 @@ you a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer implementations of the Specification that implement and are compliant with all relevant portions of the -Specification (“Compliant Implementations”). Notwithstanding the foregoing, no +Specification ("Compliant Implementations"). Notwithstanding the foregoing, no patent license is granted to any technologies that may be necessary to make or use any product or portion thereof that complies with the Specification but are not themselves expressly set forth in the Specification. diff --git a/wiki/2026-02-Expression-Language.md b/wiki/2026-02-Expression-Language.md new file mode 100644 index 0000000..26c3f5f --- /dev/null +++ b/wiki/2026-02-Expression-Language.md @@ -0,0 +1,1532 @@ +# Expression Language [Extension: EXPR] + +This document contains the formal specification for the Open Job Description Expression Language, +available to templates as the `EXPR` extension to the [2023-09 Template Schemas](2023-09-Template-Schemas). + +The expression language extends the [Format Strings](2023-09-Template-Schemas#73-format-strings) defined in the +Template Schemas with a domain-specific expression language that provides arithmetic, conditional logic, +string and path manipulation, list operations, and more. It is defined as a subset of the +[Python](https://www.python.org/) expression grammar with compatibility extensions for JSON/YAML contexts. + +This specification is organized into two parts: + +1. **Language** — The grammar, type system, and evaluation semantics. +2. **Function Library** — The operators and built-in functions. + +Reading the broad overviews in [How Jobs Are Constructed](How-Jobs-Are-Constructed) and [How Jobs Are Run](How-Jobs-Are-Run) +provides a good starting introduction and broader context prior to reading this specification. + +## Motivation + +Open Job Description templates need a flexible way to customize job structure and express glue +transformations between different interfaces. The current template substitution syntax is limited to +direct value references, which creates friction for common use cases: + +1. **Arithmetic on job parameters** — Users frequently need to derive values from job parameters, + for example computing the end of a frame range from a start value and a count. Currently this + requires external scripting or embedding calculations in shell scripts. + +2. **Conditional logic** — Selecting different values based on parameter settings requires + workarounds like implementing a wrapper script whose sole purpose is to act as glue between + the job parameter interface and a command. + +3. **Conditional omission of fields/elements** — There is no way to conditionally omit an + optional field or array element. Users must either pass empty strings (which may not be + valid for the target command) or maintain multiple template variants. + +4. **Inter-dependent job parameter defaults** — Computing default values for one parameter + based on another requires expression evaluation in job submission UIs across multiple + platforms (web, desktop, CLI). + +## Design Constraints + +The expression language is designed for evaluation within the constrained context of a template. +Schedulers must understand job structure without running tasks to determine it, so they need the +ability to evaluate expressions in an isolated, secure, and bounded context. These constraints +shape the language: + +1. **No filesystem, network, or environment variable access** — Expressions have no access to the + filesystem, network, or environment variables. The evaluation context is fully defined by the + template's parameters and runtime context variables. There are no functions for these purposes, + and none will be added. This ensures that a scheduler can evaluate expressions without reference + to the environments that tasks run in, and that expressions do not depend on any outside state. + +2. **Memory-bounded evaluation** — Expression evaluation must operate within bounded memory. + Implementations accept a configurable memory limit (default: 10MB recommended) and track the + memory size of live values during evaluation. If current memory exceeds the limit, evaluation + fails with an error. This supports implementations that allocate fixed resources to evaluate + expressions, and prevents unbounded resource consumption from expressions like `"a" * 10000000` + or large list comprehensions. + +3. **Deterministic evaluation** — Expressions must evaluate deterministically with no side + effects. The same inputs must always produce the same outputs. + +4. **No user-defined functions** — All functions, operators, and type properties are defined by + the specification. There is no mechanism for users to define custom functions or extend the + language within templates. This ensures templates are portable and evaluation is bounded and + predictable. + +5. **Backward compatibility** — All existing valid templates must continue to work identically + with no changes. The extended expression syntax is only enabled when the `EXPR` extension is + explicitly requested. + +6. **Fail-fast errors** — Invalid expressions must be rejected at template validation/submission + time, not at task runtime. + +7. **Reuse existing Python parsers** — The language is specified as a subset of Python expression + syntax so that implementations can rely on existing Python parsers rather than writing custom + parsers. Python implementations can use the [`ast`](https://docs.python.org/3/library/ast.html) + standard library module, Rust implementations can use the + [ruff Python parser](https://github.com/astral-sh/ruff/tree/main/crates/ruff_python_parser), + and JavaScript implementations can use + [dt-python-parser](https://github.com/DTStack/dt-python-parser). + +## Notations + +* `@fmtstring` - The value of the annotated property is a Format String. See [Format Strings](2023-09-Template-Schemas#73-format-strings). + * `@fmtstring[host]` - The value is evaluated at runtime on the worker host. +* `@optional` - The annotated property is optional. + +## Related RFCs + +* [RFC 0005: Expression Language](https://github.com/OpenJobDescription/openjd-specifications/blob/mainline/rfcs/0005-expression-language.md) +* [RFC 0006: Expression Function Library](https://github.com/OpenJobDescription/openjd-specifications/blob/mainline/rfcs/0006-expression-function-library.md) + +## Enabling the Extension + +The `EXPR` extension is enabled by including it in the `extensions` list of a Job Template: + +```yaml +specificationVersion: 'jobtemplate-2023-09' +extensions: + - EXPR +name: My Job +``` + +When `EXPR` is enabled: + +1. Format strings accept the extended expression grammar defined in this document. +2. Extended job parameter types become available. See the + [Template Schemas](2023-09-Template-Schemas#2-jobparameterdefinition) for the full definitions. +3. The `ArgString` type is amended to allow CR (U+000D), LF (U+000A), and TAB (U+0009) characters + to support multi-line expressions in YAML literal block scalars. + +Templates not using the `EXPR` extension continue to use the existing simple value reference syntax +defined in the [Template Schemas](2023-09-Template-Schemas#73-format-strings). All existing valid templates +continue to work identically with no changes. + +--- + +## Recommended Library Interface + +This section describes a recommended programming interface for implementations of the expression +language. Implementations should follow these patterns to the extent that the implementation +language supports them. The reference implementation is in the `openjd.expr` namespace of the +[openjd-model](https://github.com/OpenJobDescription/openjd-model-for-python) Python package. + +### Types + +#### ExprType + +Represents a type in the expression language type system. + +``` +class ExprType: + type_code: TypeCode # The base type (BOOL, INT, FLOAT, STRING, PATH, LIST, etc.) + type_params: list[ExprType] # Type parameters (e.g., element type for LIST) +``` + +Construction: +- `ExprType(type_code, type_params=None)` — Create a type with the given code and optional parameters. +- `ExprType(type_string)` — Create a type from a string like `"int"`, `"list[string]"`, `"int?"`, `"int | string"`. + +Type constants as class attributes: +- `ExprType.BOOL`, `ExprType.INT`, `ExprType.FLOAT`, `ExprType.STRING`, `ExprType.PATH` +- `ExprType.RANGE_EXPR`, `ExprType.NULLTYPE`, `ExprType.NORETURN` +- `ExprType.LIST_INT`, `ExprType.LIST_FLOAT`, `ExprType.LIST_STRING`, `ExprType.LIST_PATH`, `ExprType.LIST_BOOL` +- `ExprType.LIST_LIST_INT`, `ExprType.EMPTY_LIST` + +**Type Codes:** + +| Type Code | Type | Description | +|-----------|------|-------------| +| `NULLTYPE` | `?` | The null type | +| `BOOL` | `bool` | Boolean | +| `INT` | `int` | Integer | +| `FLOAT` | `float` | Floating-point | +| `STRING` | `string` | String | +| `PATH` | `path` | Filesystem path | +| `RANGE_EXPR` | `range_expr` | Range expression | +| `LIST` | `list[T]` | List with element type in `type_params[0]` | +| `ANY` | `any` | Unconstrained type (matches anything) | +| `UNION` | `S \| T` | Union of types in `type_params` | +| `NORETURN` | `noreturn` | Bottom type for functions that never return | + +**Union Type Normalization:** + +Union types are automatically normalized when constructed: +- Nested unions are flattened: `(int | string) | bool` becomes `int | string | bool` +- Type parameters are sorted alphabetically with `?` at the end +- Duplicate types are removed +- Single-element unions are unwrapped: a union with one member becomes that member +- `ANY` absorbs everything: `int | any` becomes `any` +- `NORETURN` collapses to nothing: `int | noreturn` becomes `int` + +Optional types like `int?` are represented as unions: `int?` = `int | ?` (union of `int` and `NULLTYPE`). + +The `noreturn` type is used for functions like `fail()` that never return. Because it collapses +in unions, expressions like `x if cond else fail("error")` have type `x`, not `x?`. + +#### ExprValue + +Holds a typed value during expression evaluation. + +``` +class ExprValue: + type: ExprType # The value's type + is_null: bool # True if this is a null value +``` + +Note: `ANY` and `UNION` are type-level constructs used during type checking. They do not +appear as the type of a concrete `ExprValue` at runtime—values always have a specific +concrete type. + +**Construction** — From Python values with automatic type inference: + +```python +ExprValue(42) # int +ExprValue(3.14) # float +ExprValue("hello") # string +ExprValue(True) # bool +ExprValue(None) # null +ExprValue([1, 2, 3]) # list[int] +ExprValue(PurePath("/tmp/file")) # path +ExprValue(Decimal("3.140")) # float (preserves decimal string) +``` + +**Construction** — With explicit type coercion: + +```python +ExprValue("42", type="int") # coerce string to int +ExprValue("3.14", type="float") # coerce string to float +ExprValue("true", type="bool") # coerce string to bool +ExprValue("/tmp", type="path") # string as path +ExprValue("1-10", type="range_expr") # string as range_expr +ExprValue([1, 2], type="list[int]") # explicit list type +``` + +The `type` parameter accepts either an `ExprType` instance or a type string. + +**Class methods**: + +- `ExprValue.null()` — Create a null value. +- `ExprValue.from_float(value, original_str=None)` — Create a float, optionally preserving + the original string representation for pass-through. + +**Value extraction**: + +- `item()` — Extract the native Python value: + - `bool` for BOOL + - `int` for INT + - `float` for FLOAT + - `str` for STRING + - `PurePath` for PATH + - `IntRangeExpr` for RANGE_EXPR + - `list` for LIST (recursively extracts items) + - `None` for null values + +- `to_string()` — Convert to string representation. Preserves original decimal representation + for floats that were constructed with an original string representation. Lists are converted + to JSON. + +- `to_expr_value_list()` — Return list contents as `list[ExprValue]`. Raises `TypeError` + if the value is not a list type. + +**Representation**: + +```python +repr(ExprValue(42)) # ExprValue(42) +repr(ExprValue(3.14)) # ExprValue(3.14) +repr(ExprValue("hello")) # ExprValue('hello') +repr(ExprValue([1, 2, 3])) # ExprValue([1, 2, 3], type='list[int]') +repr(ExprValue(Decimal("3.140"))) # ExprValue('3.140', type='float') +repr(ExprValue(None)) # ExprValue(None) +``` + +The repr is designed to be copy-pasteable for reconstruction. + +### Symbol Table + +#### SymbolTable + +Maps names to values or nested symbol tables for expression evaluation. + +``` +class SymbolTable: + def __init__(self, source: dict | SymbolTable = None) + def __getitem__(self, name: str) -> SymbolTable | ExprValue + def __setitem__(self, name: str, value: Any) + def __contains__(self, name: str) -> bool + def get(self, name: str) -> SymbolTable | ExprValue | None +``` + +Supports dotted key paths for convenient construction: +```python +values = SymbolTable({ + "Param.Frame": 42, + "Param.Name": "test", + "Task.File": Path("/render/output.exr") +}) +``` + +Native Python values are automatically converted to `ExprValue`. + +#### TypeSymbolTable + +Maps names to types or nested type symbol tables for static type checking. + +``` +class TypeSymbolTable: + def __init__(self, source: dict | TypeSymbolTable = None) + def __getitem__(self, name: str) -> TypeSymbolTable | ExprType + def __setitem__(self, name: str, value: Any) + def __contains__(self, name: str) -> bool + def get(self, name: str) -> TypeSymbolTable | ExprType | None +``` + +Supports dotted key paths for convenient construction: +```python +types = TypeSymbolTable({ + "Param.Frame": INT, + "Param.Name": STRING, + "Task.File": PATH +}) +``` + +Used with `parse_expression` to perform static type checking without evaluation. + +### Parsing and Evaluation + +#### parse_expression + +Parse an expression string, optionally performing static type checking. + +``` +def parse_expression( + expr: str, + *, + types: TypeSymbolTable = None, + library: FunctionLibrary = None +) -> ParsedExpression +``` + +- **expr**: The expression string to parse. +- **types**: Optional type symbol table for static type checking. If provided, validates + all operations, function calls, and property accesses against declared types. +- **library**: Function library for type lookup when `types` is provided. Uses default + library if not specified. Use `library.with_host_context()` for host-context expressions. +- **Returns**: A `ParsedExpression` object with `result_type` populated if type checking was performed. +- **Raises**: `ExpressionError` if the expression has a syntax error or type error. + +#### ParsedExpression + +A parsed expression that can be evaluated and inspected. + +``` +class ParsedExpression: + expr: str # The original expression string + accessed_symbols: set[str] # Set of symbol names referenced + called_functions: set[str] # Set of function/method names called (e.g., {"upper", "len"}) + result_type: ExprType | None # Result type if type checking was performed (may be `any` or union of types) + peak_memory_usage: int | None # Peak memory usage in bytes during last evaluate() call + + def evaluate( + self, + *, + values: SymbolTable = None, + library: FunctionLibrary = None, + target_type: ExprType = None, + path_format: PathFormat = None + ) -> ExprValue +``` + +The `accessed_symbols` set contains the external symbols referenced by the expression. The +format depends on whether type checking was performed: + +- **Without type checking** (`types=None`): Full dotted paths including properties + (e.g., `Param.File.stem` collects `{"Param.File.stem"}`). +- **With type checking** (`types` provided): Only symbol table keys, not property paths + (e.g., `Param.File.stem` collects `{"Param.File"}` if `Param.File` is in the type table). + +The `called_functions` set enables static analysis to identify which functions are used, +such as detecting calls to `apply_path_mapping()`. + +When `types` is provided to `parse_expression`, `result_type` contains the inferred +result type for the expression. For expressions with multiple possible result types +(e.g., conditional expressions like `x if cond else y`), the result is a union type. + +The `peak_memory_usage` attribute is set after each `evaluate()` call and contains the +peak memory usage in bytes during that evaluation. + +#### evaluate_expression + +Parse and evaluate an expression in one step. + +``` +def evaluate_expression( + expr: str, + *, + values: SymbolTable = None, + library: FunctionLibrary = None, + target_type: ExprType = None, + memory_limit: int = None, + path_format: PathFormat = None +) -> ExprValue +``` + +- **expr**: The expression string to evaluate. +- **values**: Symbol table with variable bindings. +- **library**: Function library (uses default if not provided). +- **target_type**: Optional expected result type for coercion (can be `any` or union of types). +- **memory_limit**: Maximum memory (bytes) for intermediate values. Default: 10MB. +- **path_format**: Output format for `path` type values. See [Path Format](#path-format) below. +- **Returns**: The result `ExprValue`. +- **Raises**: `ExpressionError` if the expression is invalid or evaluation fails. + +Peak memory usage during evaluation is available via `ParsedExpression.peak_memory_usage` +after calling `evaluate()`. + +#### Path Format + +The `path_format` parameter controls the behavior of the `path` type during evaluation: + +- **`PathFormat.POSIX`**: The `path` type behaves like Python's `PurePosixPath` class. + Use this for TEMPLATE scope contexts to ensure consistent behavior regardless of + the submission machine's OS. +- **`PathFormat.WINDOWS`**: The `path` type behaves like Python's `PureWindowsPath` class. +- **`None`** (default): The `path` type behaves like Python's `PurePath` class, + using the system's native path semantics. This is appropriate for host contexts + (SESSION and TASK scopes) where paths should match the worker's OS. + +### Function Library + +#### FunctionLibrary + +Registry of functions and operators available in expressions. + +``` +class FunctionLibrary: + def register(self, name, param_types, return_type, impl) + def get_signatures(self, name: str) -> list[FunctionSignature] + def get_call_return_types(self, name: str, arg_types: list[set[ExprType]]) -> set[ExprType] + def get_property_type(self, base_type: ExprType, property_name: str) -> ExprType | None + def with_host_context(self, *, path_mapping_rules=None) -> FunctionLibrary +``` + +- `get_default_library()` — Returns the standard function library with all built-in functions. +- `get_call_return_types(name, arg_types)` — Returns possible return types for a function call + given argument type sets. Used for static type checking. +- `get_property_type(base_type, property_name)` — Returns the type of a property access, or None + if the property doesn't exist for the base type. Used for static type checking. +- `with_host_context()` — Returns a copy of the library with host-only functions like + `apply_path_mapping()` enabled. Used for type checking and evaluation in host contexts. + +#### FunctionSignature + +A function signature with parameter types and return type. + +``` +class FunctionSignature: + param_types: list[ExprType] + return_type: ExprType + impl: Callable[..., ExprValue] +``` + +### Errors + +#### ExpressionError + +Base exception for expression parsing and evaluation errors. Includes formatted error messages +with source location and caret pointers when available. + +#### ExprTypeError + +Subclass of `ExpressionError` for type-related errors during evaluation. + +### Path Mapping + +#### PathMappingRule + +A rule for mapping paths from one location to another, used by `apply_path_mapping()`. + +``` +class PathMappingRule: + source_path_format: PathFormat # POSIX or WINDOWS + source_path: PurePath # The source path prefix to match + destination_path: PurePath # The destination path prefix to substitute + + @staticmethod + def from_dict(rule: dict[str, str]) -> PathMappingRule + def to_dict(self) -> dict[str, str] + def apply(self, *, path: str) -> tuple[bool, str] +``` + +#### PathFormat + +Enumeration of path formats: `POSIX` or `WINDOWS`. + +--- + +## 1. Language + +### 1.1. Extended Format String Grammar + +When the `EXPR` extension is enabled, the grammar for `` within +[Format Strings](2023-09-Template-Schemas#73-format-strings) is extended from: + +```bnf + ::= + ::= + ::= "." | +``` + +To: + +```bnf + ::= + ::= ("if" "else" )? + ::= ("or" )* + ::= ("and" )* + ::= "not" | + ::= (("<" | ">" | "<=" | ">=" | "==" | "!=") )* + ::= (("+" | "-") )* + ::= (("*" | "/" | "//" | "%") )* + ::= ("-" | "+") | + ::= ("**" )? + ::= ( | )* + ::= "[" "]" + ::= | + ::= ? ":" ? (":" ?)? + ::= "(" ? ")" + ::= ("," )* + ::= | | | + | "(" ")" + ::= + ::= "." | +``` + +#### 1.1.1. Literals + +```bnf + ::= | | + | | + ::= | | | + ::= [0-9] ("_"? [0-9])* + ::= "0" ("x" | "X") "_"? [0-9a-fA-F] ("_"? [0-9a-fA-F])* + ::= "0" ("o" | "O") "_"? [0-7] ("_"? [0-7])* + ::= "0" ("b" | "B") "_"? [01] ("_"? [01])* + ::= | + ::= ? "." [0-9] ("_"? [0-9])* ? + | "." ? + ::= + ::= ("e" | "E") ("+" | "-")? [0-9] ("_"? [0-9])* + ::= ? ( | ) + ::= "r" | "R" + ::= "'" * "'" | '"' * '"' + ::= "'''" * "'''" | '"""' * '"""' + ::= | any character except "\" or newline or the quote + ::= | any character except "\" + ::= "\" any character + ::= "True" | "False" + ::= "None" +``` + +#### 1.1.2. List Expressions + +```bnf + ::= "[" ( ("," )* ","?)? "]" + ::= "[" "for" "in" + ("if" )? "]" +``` + +#### 1.1.3. Contextual Keywords + +Keywords (`if`, `else`, `and`, `or`, `not`, `for`, `in`, `True`, `False`, `None`) are contextual. +They are only recognized as keywords in their syntactic positions, not as attribute names +following `.` in a ``. This ensures backward compatibility so that expressions like +`Param.if` or `Param.True` remain valid. + +#### 1.1.4. JSON/YAML-Compatible Literals + +The following aliases are accepted to reduce friction with the surrounding JSON/YAML template syntax: + +|**Alias**|**Equivalent**| +|---|---| +|`null`|`None`| +|`true`|`True`| +|`false`|`False`| + +#### 1.1.5. String Literal Formats + +The grammar supports Python's string literal formats: + +| Format | Example | Description | +|--------|---------|-------------| +| Single-quoted | `'hello'` | String with single quotes | +| Double-quoted | `"hello"` | String with double quotes | +| Triple single-quoted | `'''hello'''` | Multi-line string with single quotes | +| Triple double-quoted | `"""hello"""` | Multi-line string with double quotes | +| Raw single-quoted | `r'hello\n'` | Raw string (backslashes are literal) | +| Raw double-quoted | `r"hello\n"` | Raw string (backslashes are literal) | +| Raw triple-quoted | `r'''hello'''` or `r"""hello"""` | Raw multi-line string | + +All Python escape sequences are supported in non-raw strings: + +| Escape | Meaning | +|--------|---------| +| `\\` | Backslash | +| `\'` | Single quote | +| `\"` | Double quote | +| `\n` | Newline | +| `\r` | Carriage return | +| `\t` | Tab | +| `\xhh` | Character with hex value hh | +| `\uhhhh` | Unicode character with 16-bit hex value | +| `\Uhhhhhhhh` | Unicode character with 32-bit hex value | +| `\N{name}` | Unicode character by name | + +In raw strings (prefixed with `r` or `R`), backslashes are treated as literal characters +and escape sequences are not processed. This is useful for regular expressions and +Windows-style paths. + +#### 1.1.6. Numeric Literal Formats + +| Format | Example | Value | Description | +|--------|---------|-------|-------------| +| Decimal | `42` | 42 | Standard decimal integer | +| Hexadecimal | `0x2A` or `0X2a` | 42 | Base-16 with `0x` prefix | +| Octal | `0o52` or `0O52` | 42 | Base-8 with `0o` prefix | +| Binary | `0b101010` or `0B101010` | 42 | Base-2 with `0b` prefix | +| Underscore separator | `1_000_000` | 1000000 | Underscores for readability | +| Decimal float | `3.14` | 3.14 | Standard decimal float | +| Scientific notation | `1.5e-3` or `1.5E-3` | 0.0015 | Exponential notation | +| Integer exponent | `1e10` | 10000000000.0 | Integer with exponent (produces float) | + +Underscores can appear between digits in any numeric literal for readability (e.g., `0xFF_FF`, +`0b1010_1010`, `1_000.000_001`). They cannot appear at the start or end of a number, or +adjacent to the decimal point or exponent marker. + +Leading zeros on decimal integers are not permitted (e.g., `007` or `0123` are syntax errors). +Use the `0o` prefix for octal integers (e.g., `0o7` or `0o123`). The literal `0` and `00` are +valid as they unambiguously represent zero. + +#### 1.1.7. Implicit Line Continuation + +Expressions can span multiple lines without any special continuation syntax. + +```yaml +args: + - |- + {{ [ + Param.OutputDir / Param.FilePattern.with_number(frame) + for frame in Task.Param.Frame + ] }} +``` + +### 1.2. Type System + +#### 1.2.1. Expression Types + +| Type | Description | +|------|-------------| +| `bool` | Boolean values (`True`, `true`, `False`, or `false`) | +| `int` | Integer values | +| `float` | Floating-point values (64-bit IEEE) | +| `string` | String values | +| `path` | Filesystem path values | +| `range_expr` | Range expression string conforming to the [``](2023-09-Template-Schemas#34111-intrangeexpr) grammar | +| `T?` | Optional type: the value is `T` or `None`/`null` | +| `?` | The null type; its value can only be `None`/`null` | +| `list[T]` | Ordered list of values of type `T` | +| `list[?]` | Empty list `[]`, compatible with any `list[T]` | +| `S \| T` | Union type: the value may be either `S` or `T` | + +The `path` type can have either POSIX or Windows path semantics depending on the evaluation +context. This affects path separator handling (`/` vs `\`) and case sensitivity. In TEMPLATE +scope contexts, POSIX semantics are used for consistency. In host contexts (SESSION and TASK +scopes), the semantics match the host's operating system. + +Constraints on `T` in `list[T]`: + +1. `T` cannot be `S?`. A `None`/`null` value inside a list literal is an error. +2. `T` can be `list[S]`, but cannot be nested a third time (`S` cannot be `list[U]`). + +#### 1.2.2. Built-in Symbol Types + +Expressions have access to symbols provided by the runtime context. The expression type of each +symbol corresponds to its declared type. + +##### Job Parameter Types + +Job parameters defined in `parameterDefinitions` are available via `Param.` and +`RawParam.`: + +| Parameter Type | `Param.` Type | `RawParam.` Type | +|----------------|---------------------|------------------------| +| `STRING` | `string` | `string` | +| `INT` | `int` | `int` | +| `FLOAT` | `float` | `float` | +| `PATH` | `path` | `string` | +| `BOOL` | `bool` | `bool` | +| `RANGE_EXPR` | `range_expr` | `range_expr` | +| `LIST[STRING]` | `list[string]` | `list[string]` | +| `LIST[INT]` | `list[int]` | `list[int]` | +| `LIST[FLOAT]` | `list[float]` | `list[float]` | +| `LIST[PATH]` | `list[path]` | `list[string]` | +| `LIST[BOOL]` | `list[bool]` | `list[bool]` | +| `LIST[LIST[INT]]` | `list[list[int]]` | `list[list[int]]` | + +The `BOOL`, `RANGE_EXPR`, and `LIST[*]` parameter types are defined in the +[Template Schemas](2023-09-Template-Schemas#2-jobparameterdefinition) as part of the `EXPR` extension. + +For `PATH` parameters, `Param.` has type `path` with path mapping rules applied, while +`RawParam.` has type `string` containing the original unmapped value. The raw value is +a string because it may be a path for a different operating system that cannot be parsed as +a local path. Similarly for `LIST[PATH]`, `Param.` is `list[path]` while `RawParam.` +is `list[string]`. + +##### Task Parameter Types + +Task parameters defined in `taskParameterDefinitions` are available via `Task.Param.` +and `Task.RawParam.`: + +| Task Parameter Type | Expression Type | +|---------------------|-----------------| +| `INT` | `int` | +| `FLOAT` | `float` | +| `STRING` | `string` | +| `PATH` | `path` | +| `CHUNK[INT]` | `range_expr` | + +Note: `CHUNK[INT]` produces a `range_expr` type, not `list[int]`, enabling efficient +representation of frame ranges. Use `list(Task.Param.Frame)` to convert to a list if needed. + +##### Session Symbols + +| Symbol | Type | Description | +|--------|------|-------------| +| `Session.WorkingDirectory` | `path` | The session's temporary working directory | +| `Session.PathMappingRulesFile` | `path` | Path to the JSON file containing path mapping rules | +| `Session.HasPathMappingRules` | `bool` | Whether path mapping rules are available | + +##### Embedded File Symbols + +| Symbol | Type | Description | +|--------|------|-------------| +| `Task.File.` | `path` | Location of the embedded file within a Step Script | +| `Env.File.` | `path` | Location of the embedded file within an Environment | + +#### 1.2.3. Implicit Type Coercion + +As a glue expression language intended for convenience, implicit non-destructive type +coercion is performed where the intent is obvious. The following implicit conversions are supported: + +- `int` → `float` when the target types do not include `int` +- `path` → `string` when the target types do not include `path` +- `range_expr` → `string` when the target types do not include `range_expr` (produces canonical form like `"1-5"`) +- `range_expr` → `list[int]` when the target types include `list[int]` but not `range_expr` +- `list[T]` → `list[U]` when each element `T` can be coerced to `U` (e.g., `list[path]` → `list[string]`) +- `list[?]` → `list[T]` for any `T` (empty list literal is compatible with any list type) +- Any scalar value when the target types have a single scalar type. The value is coerced + non-destructively to that type: + - `bool`/`int`/`float`/`path` → `string` + - `string` → `path` + - `float`/`string` → `int` (error if value cannot be represented exactly, e.g. `3.75`, `""`, `"3.1"`) + - `int`/`string` → `float` (error if string cannot be parsed, e.g. `""`, `"nothing"`) +- `[v1, v2, ...]` any values when the target types have a single `list` type. Every value is + coerced non-destructively to `T` where that type is `list[T]`. This applies recursively for nested lists. + +#### 1.2.4. Method Call Coercion Restriction + +This specification uses uniform function call syntax (UFCS) to support method calls on types (see +[section 1.3.3](#133-uniform-function-call-syntax)). When calling a function as a method, +implicit type coercion does not apply to the first parameter (the receiver). This ensures type +safety for method-style calls. + +```yaml +# Function call - coercion applies to all arguments +startswith(path('/foo/bar'), '/foo') # OK: path coerced to string + +# Method call - no coercion on receiver +path('/foo/bar').startswith('/foo') # ERROR: no startswith(path, string) signature +'/foo/bar'.startswith('/foo') # OK: receiver is already string +``` + +Other parameters in a method call are still subject to normal implicit coercion rules. + +#### 1.2.5. Cross-Type Equality Comparison + +Equality (`==`) and inequality (`!=`) operators handle cross-type comparisons as follows: + +- `string` vs `path`: The path is converted to string for comparison +- `int` vs `float`: Numeric comparison (e.g., `5 == 5.0` is `true`) +- `list` vs `range_expr`: The range_expr is expanded and compared element-by-element +- `string` vs (`int` | `float`): Always unequal (e.g., `"5" == 5` is `false`) +- `bool` vs any non-`bool`: Always unequal (e.g., `true == 1` is `false`) +- scalar vs `list`: Always unequal (e.g., `1 == [1]` is `false`) +- Other cross-type comparisons: Always unequal + +List equality is recursive: two lists are equal if they have the same length and all +corresponding elements are equal. + +List ordering (`<`, `<=`, `>`, `>=`) uses lexicographic comparison: elements are compared +pairwise from the start, and the first unequal pair determines the result. If all compared +elements are equal, the shorter list is considered less than the longer one. + +#### 1.2.6. List Literal Type Inference + +List literals infer their element type from context and contents. + +With a target type context containing exactly one `list[T]` type, elements are coerced +non-destructively to `T`. + +Without a target type context: + +1. If all elements have the same type `T`, the result is `list[T]`. +2. If elements are a mix of `int` and `float`, the result is `list[float]`. +3. If elements are a mix of `path` and `string`, the result is `list[string]`. +4. The empty list `[]` evaluates to `list[?]`, which is implicitly convertible to `list[T]` for any `T`. +5. If elements have incompatible types (e.g., `int` and `string`), evaluation fails with an error. + +A `null`/`None` value cannot be an element of a list literal. Including `null` in a list is always an error. + +### 1.3. Evaluation Semantics + +#### 1.3.1. Target Type Evaluation + +Expressions are evaluated with respect to a target type that represents the type(s) expected +by the calling context. The target type guides implicit type coercion (see +[section 1.2.3](#123-implicit-type-coercion)) and list literal type inference (see +[section 1.2.6](#126-list-literal-type-inference)). When the target type is `T`, the expression +must produce a value of type `T` or a type that can be implicitly coerced to `T`. When the +target type is `T?`, the expression may also produce `None`/`null`. + +The expression language does not define how target types are determined — that is the +responsibility of the embedding context. [Section 1.3.2](#132-evaluation-within-template-schemas) +defines the target type rules for the [Template Schemas](2023-09-Template-Schemas). + +#### 1.3.2. Evaluation Within Template Schemas + +When expressions are used within the [Template Schemas](2023-09-Template-Schemas), the target +type is determined by the schema context in which the expression appears: + +- For a required field of type `T`, the target type is `T`. +- For an optional field of type `T`, the target type is `T?`. If the expression evaluates + to `None`/`null`, the field is omitted as if it were not specified. +- For list items (e.g., in `args`), the target type is `T? | list[T]` where `T` is + the item type. This enables three behaviors: + 1. If the result is a value of type `T`, it is added as a single item. + 2. If the result is `None`/`null`, the item is skipped and the list is one shorter. + 3. If the result is a `list[T]`, the list is flattened inline. + + For example: + + ```yaml + args: + - "--input" + - "{{Param.InputFile}}" + - "{{ '--verbose' if Param.Verbose else null }}" # Dropped when false + - "{{ ['--quality', Param.Quality] if Param.Quality > 0 else null }}" # Flattened or dropped + ``` + +When a format string is exactly `"{{}}"` with no surrounding text, the target type is +inherited from the field context — the format string is transparent and the expression can +produce any type the field accepts. If the expression result does not match the target type, +non-destructive coercion is attempted (see [section 1.2.3](#123-implicit-type-coercion)). +If coercion fails, evaluation produces an error. + +When a format string contains text surrounding the expression (e.g., `"The {{}} value."`), +the overall result is a string. Each interpolated expression is evaluated without type +constraints to obtain its natural result, then converted to a string. A `None`/`null` result +is treated as the empty string. This allows any expression result to be embedded in a format +string: + +- `"Items: {{ [1, 2, 3] }}"` produces `"Items: [1, 2, 3]"` +- `"Count: {{ len(myList) }}"` produces `"Count: 5"` + +#### 1.3.3. Uniform Function Call Syntax + +Functions and properties can be accessed using method syntax: + +- For any function `f(x, ...)` where `x` has type `T`, the expression `x.f(...)` is + equivalent to `f(x, ...)`. +- All operators are defined by functions like `__add__` for the `+` operator, using the + same double-underscore names as Python. +- Properties like `x.p` are defined using the naming convention `__property_p__`. + +This enables chaining like `Param.Name.upper().strip()` instead of `strip(upper(Param.Name))`, +and allows properties like `Param.File.stem` to be defined uniformly alongside functions. + +Note: The `__*__` names are specification conventions and are not directly callable. + +#### 1.3.4. Float Value Pass-Through + +When a float value is only copied without modification, the original string representation +is preserved in output string interpolation. When an operation is performed on a float value, +it becomes a 64-bit IEEE floating point number, and string interpolation uses the shortest +decimal string representation. + +For example, if a job submission provides the value `"3.500"` to a float parameter: +- `{{Param.V}}` outputs `"3.500"` (original form preserved) +- `{{Param.V + 1}}` outputs `"4.5"` (shortest representation after computation) + +#### 1.3.5. Conditional Expression Semantics + +The conditional expression ` if else `: + +1. Evaluates `` first. The `` must be a `bool`; there is no "truthy" concept. +2. If `` is `True`, evaluates and returns ``. +3. Otherwise, evaluates and returns ``. + +#### 1.3.6. Chained Comparisons + +Like Python, chained comparisons are supported. The expression `1 < 2 < 3` is equivalent +to `1 < 2 and 2 < 3`, with each intermediate value evaluated only once. + +#### 1.3.7. List Comprehensions + +Simple list comprehensions are supported for transforming and filtering lists: + +``` +[expr for var in iterable] +[expr for var in iterable if condition] +``` + +The loop variable (`var`) must be a ``: it must start with a lowercase letter +or underscore, followed by alphanumeric characters or underscores. This ensures loop variables +cannot shadow spec-defined symbols like `Param` or `Task`. A loop variable that shadows an +existing binding is an error. + +Python's nested comprehension syntax is not supported. + +Examples: +- `[['-e', e] for e in Task.Environment]` transforms `["A=1", "B=2"]` into + `[["-e", "A=1"], ["-e", "B=2"]]`. +- `flatten([['-e', e] for e in Task.Environment])` transforms `["A=1", "B=2"]` into + `["-e", "A=1", "-e", "B=2"]`, suitable for command `args`. +- `[x for x in Param.Values if x > 0]` filters to only positive values. + +#### 1.3.8. Slicing + +Slicing extracts a subset of elements from lists, strings, or range expressions using Python-style +slice notation `[start:stop:step]`. All three components are optional: + +- `start`: Starting index (inclusive), defaults to 0 (or end if step is negative) +- `stop`: Ending index (exclusive), defaults to length (or -length-1 if step is negative) +- `step`: Step between elements, defaults to 1 + +Negative indices count from the end: `-1` is the last element, `-2` is second-to-last, etc. + +| Expression | Description | +|------------|-------------| +| `v[1:4]` | Elements at indices 1, 2, 3 | +| `v[:3]` | First 3 elements | +| `v[2:]` | All elements from index 2 to end | +| `v[::2]` | Every other element | +| `v[::-1]` | Reversed | +| `v[-3:]` | Last 3 elements | + +Note: The `path` type does not support subscript or slice operations. Use `p.parts` to get +path components as a list, which can then be sliced. + +#### 1.3.9. Memory-Bounded Evaluation + +As described in [Design Constraints](#design-constraints), expression evaluation must operate within +bounded memory. Implementations accept an optional `memory_limit` parameter (default: 10MB recommended). +During evaluation, the evaluator tracks the memory size of live values — incrementing when values are +created, decrementing when intermediate values are consumed. If current memory exceeds the limit, +evaluation fails with an error. + +Value size calculations are implementation-dependent, based on the storage representation +the implementation uses for each type. + +#### 1.3.10. Error Handling + +Expression evaluation errors result in a job failure with a descriptive error message. Errors include: + +- Type errors (e.g., adding string to int) +- Division by zero +- Index out of bounds +- Unknown function or variable reference +- Syntax errors +- Memory limit exceeded + +#### 1.3.11. Task Parameter Range Field Extensions + +When the `EXPR` extension is enabled, the `range` field for task parameter +definitions is extended to accept a `` in addition to list literals. +For INT, the `RangeString` is also extended — since it is a format string, it can now contain +an expression that evaluates to either a range expression string or a `list[int]`: + +| Parameter Type | Original `range` Type | Extended `range` Type | +|---------------|----------------------|----------------------| +| INT | `list[int \| FormatString] \| RangeString` | (unchanged, but see `RangeString` note below) | +| FLOAT | `list[Decimal \| FormatString]` | `list[Decimal \| FormatString] \| ListExpressionString` | +| STRING | `list[FormatString]` | `list[FormatString] \| ListExpressionString` | +| PATH | `list[FormatString]` | `list[FormatString] \| ListExpressionString` | + +Where `RangeString` is a format string that, with EXPR enabled, can contain an expression +evaluating to a `range_expr` or `list[int]` in addition to the original `` grammar. + +A `` is a format string containing an expression that evaluates to a list: + +```yaml +steps: + - name: Process + parameterSpace: + taskParameterDefinitions: + - name: Factor + type: FLOAT + range: "{{ [Param.Scale * 2, Param.Scale + 0.5] }}" +``` + +--- + +## 2. Function Library + +All operators and built-in functions are specified using type signatures. Operators use Python's +double-underscore naming convention (e.g., `__add__` for `+`). Through uniform function call syntax +(see [section 1.3.3](#133-uniform-function-call-syntax)), any function `f(x, ...)` can also be called +as `x.f(...)`, and properties `x.p` are defined as `__property_p__(x)`. + +### 2.1. Operators + +#### 2.1.1. Arithmetic Operators + +| Signature | Description | +|-----------|-------------| +| `__add__(a: int, b: int) -> int` | `a + b` addition | +| `__add__(a: float, b: float) -> float` | `a + b` addition | +| `__sub__(a: int, b: int) -> int` | `a - b` subtraction | +| `__sub__(a: float, b: float) -> float` | `a - b` subtraction | +| `__mul__(a: int, b: int) -> int` | `a * b` multiplication | +| `__mul__(a: float, b: float) -> float` | `a * b` multiplication | +| `__truediv__(a: int, b: int) -> float` | `a / b` division (see also [Path Operators](#215-path-operators)) | +| `__truediv__(a: float, b: float) -> float` | `a / b` division | +| `__floordiv__(a: int, b: int) -> int` | `a // b` integer division | +| `__floordiv__(a: float, b: float) -> int` | `a // b` integer division | +| `__mod__(a: int, b: int) -> int` | `a % b` modulo | +| `__mod__(a: float, b: float) -> float` | `a % b` modulo | +| `__pow__(a: int, b: int) -> float | int` | `a ** b` exponentiation | +| `__pow__(a: float, b: float) -> float` | `a ** b` exponentiation | +| `__neg__(a: int) -> int` | `-a` negation (unary) | +| `__neg__(a: float) -> float` | `-a` negation (unary) | +| `__pos__(a: int) -> int` | `+a` identity (unary) | +| `__pos__(a: float) -> float` | `+a` identity (unary) | + +When mixing int and float operands, the int is promoted to float and the float overload is used. + +For `int ** int`, the result is `int` when the exponent is non-negative, and `float` when the exponent is negative (e.g., `2 ** 3 = 8` but `2 ** -3 = 0.125`). + +#### 2.1.2. String Operators + +| Signature | Description | +|-----------|-------------| +| `__add__(a: string, b: string) -> string` | `a + b` concatenation | +| `__add__(a: string, b: range_expr) -> string` | `a + b` concatenation (range_expr converted to canonical string form) | +| `__add__(a: range_expr, b: string) -> string` | `a + b` concatenation (range_expr converted to canonical string form) | +| `__mul__(s: string, n: int) -> string` | `s * n` repetition | +| `__contains__(a: string, b: string) -> bool` | `b in a` substring test | +| `__not_contains__(a: string, b: string) -> bool` | `b not in a` substring test | + +#### 2.1.3. List Operators + +| Signature | Description | +|-----------|-------------| +| `__add__(a: list[T], b: list[U]) -> list[V]` | `a + b` concatenation (see type coercion below) | +| `__mul__(a: list[T], n: int) -> list[T]` | `a * n` repetition | +| `__contains__(list: list[T], item: T) -> bool` | `item in list` membership test | +| `__contains__(r: range_expr, item: int) -> bool` | `item in r` membership test | +| `__not_contains__(list: list[T], item: T) -> bool` | `item not in list` membership test | +| `__not_contains__(r: range_expr, item: int) -> bool` | `item not in r` membership test | + +When concatenating lists with different element types, the result type is determined by +finding a common type: + +- `list[int] + list[float]` → `list[float]` (int elements coerced to float) +- `list[path] + list[string]` → `list[string]` (path elements coerced to string) +- `list[?] + list[T]` → `list[T]` (empty list takes the other's type) +- `list[int] + range_expr` → `list[int]` (range_expr treated as `list[int]`) +- Concatenation of incompatible list types (e.g., `list[string] + list[int]`) is an error. + +#### 2.1.4. Comparison Operators + +| Signature | Description | +|-----------|-------------| +| `__eq__(a: T1, b: T2) -> bool` | `a == b` equal | +| `__ne__(a: T1, b: T2) -> bool` | `a != b` not equal | +| `__lt__(a: T1, b: T2) -> bool` | `a < b` less than | +| `__gt__(a: T1, b: T2) -> bool` | `a > b` greater than | +| `__le__(a: T1, b: T2) -> bool` | `a <= b` less than or equal | +| `__ge__(a: T1, b: T2) -> bool` | `a >= b` greater than or equal | + +Equality operators work on all types. `T1` and `T2` may differ — see +[section 1.2.5](#125-cross-type-equality-comparison) for cross-type comparison rules. + +Ordering operators work on `int`, `float`, `string`, `path`, and `bool` types. `T1` and `T2` +may differ for compatible pairs (`int`/`float` and `string`/`path`); comparing other +cross-type pairs is an error. Bool comparison treats `False < True`. + +#### 2.1.5. Path Operators + +| Signature | Description | +|-----------|-------------| +| `__truediv__(p: path, child: string) -> path` | `p / child` join path components | +| `__truediv__(p: path, child: path) -> path` | `p / child` join path components | +| `__add__(p: path, suffix: string) -> path` | `p + suffix` append string to last component | + +The `/` operator creates child paths by joining components. If the right operand is an +absolute path, it replaces the left operand entirely (matching Python's `pathlib` behavior): + +```yaml +{{ Param.OutputDir / 'renders' / Param.SceneName }} +``` + +The `+` operator appends a string directly to the path (no separator): + +```yaml +{{ Param.OutputDir / Param.InputFile.stem + '_converted.png' }} +``` + +#### 2.1.6. Logical Operators + +| Signature | Description | +|-----------|-------------| +| `__and__(a: bool, b: bool) -> bool` | `a and b` logical AND (short-circuit) | +| `__or__(a: bool, b: bool) -> bool` | `a or b` logical OR (short-circuit) | +| `__not__(a: bool) -> bool` | `not a` logical NOT | + +#### 2.1.7. Subscript Operator + +| Signature | Description | +|-----------|-------------| +| `__getitem__(list: list[T], index: int) -> T` | `list[index]` access by zero-based index | +| `__getitem__(r: range_expr, index: int) -> int` | `r[index]` access by zero-based index | +| `__getitem__(s: string, index: int) -> string` | `s[index]` access single character by index | + +Negative indices count from the end: `list[-1]` is the last element. Index out of bounds is an error. + +Note: Indexing a `range_expr` treats it as an integer list, not as a string. `r[0]` returns +the first integer in the range, not the first character of the range string. + +#### 2.1.8. Slice Operator + +| Signature | Description | +|-----------|-------------| +| `__getitem__(list: list[T], start: int?, stop: int?, step: int?) -> list[T]` | `list[start:stop:step]` | +| `__getitem__(r: range_expr, start: int?, stop: int?, step: int?) -> list[int]` | `r[start:stop:step]` | +| `__getitem__(s: string, start: int?, stop: int?, step: int?) -> string` | `s[start:stop:step]` | + +Slice semantics follow Python. Out-of-bounds indices are clamped to valid range (no error). +A step of 0 is an error. + +Note: Slicing a `range_expr` treats it as an integer list, not as a string. `r[1:3]` returns +a list of the second and third integers in the range, not a substring of the range string. + +### 2.2. Built-in Functions + +#### 2.2.1. General Functions + +| Signature | Description | +|-----------|-------------| +| `len(list: list[T]) -> int` | Length of list | +| `len(s: string) -> int` | Length of string (number of unicode codepoints) | +| `len(r: range_expr) -> int` | Number of values in range expression | +| `bool(value: bool) -> bool` | Pass-through | +| `bool(value: ?) -> bool` | Returns `false` | +| `bool(value: int) -> bool` | `0` is `false`, all others `true` | +| `bool(value: float) -> bool` | `0.0` is `false`, all others `true` | +| `bool(value: string) -> bool` | See string-to-bool conversion below | +| `string(value: bool \| int \| float \| string \| path \| range_expr \| ?) -> string` | Convert to string (`?` returns `"null"`) | +| `string(value: list[T]) -> string` | Convert list to JSON string representation | +| `int(value: int \| float \| string) -> int` | Convert to integer (error if not exact) | +| `float(value: int \| float \| string) -> float` | Convert to float | +| `list(value: range_expr) -> list[int]` | Convert range expression to list | +| `range_expr(s: string) -> range_expr` | Parse string as range expression (e.g., `"1-10"`, `"1,3,5-7"`) | +| `range_expr(l: list[int]) -> range_expr` | Convert integer list to range expression | +| `fail(message: string) -> noreturn` | Fail with error message | + +Note: `int(3.75)` is an error. Use `floor`, `ceil`, or `round` for lossy conversions. + +Note: `bool()` string conversion accepts the following case-insensitive values: +`"1"`, `"true"`, `"on"`, `"yes"` become `true`; `"0"`, `"false"`, `"off"`, `"no"` become `false`. +All other string values are rejected with an error. + +Note: Calling `bool()` on `path` or `list[T]` values is an error. This prevents accidental implicit +coercion to bool in conditional contexts. Implementations must raise a clear error message such as +"Cannot convert path to bool" or "Cannot convert list to bool". + +Note: `range_expr("")` (empty string) is an error, and `range_expr([])` (empty list) is also an error. +Range expressions must contain at least one value. + +The `fail` function immediately terminates expression evaluation with an error. This is the +only function with a side effect. The `noreturn` type collapses in unions (`T | noreturn` → `T`), +so validation expressions have precise types: + +```python +Param.Count > 0 or fail("Count must be positive") +Param.Mode in ["fast", "slow"] or fail("Invalid mode") + +# Type is float, not float? +rate = Param.Rate if Param.Rate > 0 else fail("must be positive") +``` + +#### 2.2.2. Math Functions + +| Signature | Description | +|-----------|-------------| +| `abs(x: T) -> T` | Absolute value (`T` in `int`, `float`) | +| `min(a: T, b: T) -> T` | Minimum of two values (`T` in `int`, `float`) | +| `min(a: T, b: T, c: T) -> T` | Minimum of three values (`T` in `int`, `float`) | +| `min(values: list[T]) -> T` | Minimum of list (`T` in `int`, `float`); error if empty | +| `min(r: range_expr) -> int` | Minimum value in range expression; error if empty | +| `max(a: T, b: T) -> T` | Maximum of two values (`T` in `int`, `float`) | +| `max(a: T, b: T, c: T) -> T` | Maximum of three values (`T` in `int`, `float`) | +| `max(values: list[T]) -> T` | Maximum of list (`T` in `int`, `float`); error if empty | +| `max(r: range_expr) -> int` | Maximum value in range expression; error if empty | +| `sum(values: list[?]) -> int` | Sum of empty list, returns `0` | +| `sum(values: list[int]) -> int` | Sum of integer list | +| `sum(values: list[float]) -> float` | Sum of float list | +| `sum(r: range_expr) -> int` | Sum of all values in range expression | +| `floor(x: int) -> int` | Floor of integer (identity) | +| `floor(x: float) -> int` | Largest integer less than or equal to x | +| `ceil(x: int) -> int` | Ceiling of integer (identity) | +| `ceil(x: float) -> int` | Smallest integer greater than or equal to x | +| `round(x: float) -> int` | Round to nearest integer, tie rounds to even | +| `round(x: float, ndigits: int) -> float \| int` | Round to number of decimals; returns `int` when `ndigits` ≤ 0, `float` when `ndigits` > 0 | +| `round(x: int, ndigits: int) -> int` | Round integer to given decimal position | + +Note: `round(x, ndigits)` with positive `ndigits` preserves trailing zeros in the decimal +representation. For example, `round(3.5, 2)` produces a value that converts to the string +`"3.50"`, not `"3.5"`. With non-positive `ndigits`, the result is an integer (e.g., +`round(1234.5, -1)` returns `1230`). + +#### 2.2.3. List Functions + +| Signature | Description | +|-----------|-------------| +| `range(stop: int) -> list[int]` | Integers from 0 to stop-1 | +| `range(start: int, stop: int) -> list[int]` | Integers from start to stop-1 | +| `range(start: int, stop: int, step: int) -> list[int]` | Integers from start to stop-1 with step | +| `flatten(lists: list[list[T]]) -> list[T]` | Flatten nested lists | +| `flatten(values: list[T]) -> list[T]` | Identity for already-flat lists | +| `sorted(values: list[T]) -> list[T]` | Return new list with elements sorted in ascending order | +| `reversed(values: list[T]) -> list[T]` | Return new list with elements in reverse order | +| `any(values: list[bool]) -> bool` | True if any element is true (false for empty list) | +| `all(values: list[bool]) -> bool` | True if all elements are true (true for empty list) | + +Examples: +- `range(5)` returns `[0, 1, 2, 3, 4]` +- `range(1, 5)` returns `[1, 2, 3, 4]` +- `range(0, 10, 2)` returns `[0, 2, 4, 6, 8]` +- `range(5, 0, -1)` returns `[5, 4, 3, 2, 1]` +- `flatten([[1, 2], [3]])` returns `[1, 2, 3]` +- `sorted([3, 1, 2])` returns `[1, 2, 3]` +- `sorted(["b", "a", "c"])` returns `["a", "b", "c"]` +- `reversed([1, 2, 3])` returns `[3, 2, 1]` + +#### 2.2.4. String Functions + +| Signature | Description | +|-----------|-------------| +| `upper(s: string) -> string` | Convert to uppercase | +| `lower(s: string) -> string` | Convert to lowercase | +| `capitalize(s: string) -> string` | Capitalize first character, lowercase rest | +| `title(s: string) -> string` | Capitalize first character of each word | +| `strip(s: string) -> string` | Remove leading/trailing whitespace | +| `lstrip(s: string) -> string` | Remove leading whitespace | +| `rstrip(s: string) -> string` | Remove trailing whitespace | +| `removeprefix(s: string, prefix: string) -> string` | Remove prefix if present, otherwise return unchanged | +| `removesuffix(s: string, suffix: string) -> string` | Remove suffix if present, otherwise return unchanged | +| `startswith(s: string, prefix: string) -> bool` | Test if string starts with prefix | +| `endswith(s: string, suffix: string) -> bool` | Test if string ends with suffix | +| `count(s: string, sub: string) -> int` | Count non-overlapping occurrences of substring | +| `find(s: string, sub: string) -> int` | Return lowest index of substring, or -1 if not found | +| `replace(s: string, old: string, new: string) -> string` | Replace all occurrences of old with new | +| `split(s: string, sep: string) -> list[string]` | Split string by separator | +| `join(items: list[string], sep: string) -> string` | Join list elements with separator | +| `join(items: list[path], sep: string) -> string` | Join path list elements with separator | +| `ljust(s: string, width: int) -> string` | Left-justify, pad with spaces to width | +| `rjust(s: string, width: int) -> string` | Right-justify, pad with spaces to width | +| `center(s: string, width: int) -> string` | Center, pad with spaces to width | +| `zfill(s: string, width: int) -> string` | Pad with leading zeros to width; a leading sign (`+`/`-`) is preserved before the padding | +| `zfill(n: int, width: int) -> string` | Convert int to string, pad with leading zeros; negative integers preserve the sign before padding | + +Examples: +- `split("a,b,c", ",")` and `"a,b,c".split(",")` return `["a", "b", "c"]` +- `join(["a", "b", "c"], ",")` and `["a", "b", "c"].join(",")` return `"a,b,c"` +- `zfill(42, 5)` and `(42).zfill(5)` return `"00042"` +- `zfill(-1, 3)` returns `"-01"` (sign preserved, zeros pad after sign) +- `zfill("-10", 4)` returns `"-010"` + +Note: The `join` function uses `list.join(sep)` syntax rather than Python's `sep.join(list)`. +This enables natural method chaining like `items.split(';').join(',')`. + +Note: Method calls on integer and float literals require parentheses around the literal +(e.g., `(42).zfill(5)` not `42.zfill(5)`) because the parser interprets `42.` as the start +of a float literal. + +#### 2.2.5. Regular Expression Functions + +| Signature | Description | +|-----------|-------------| +| `re_match(s: string, pattern: string) -> list[string]?` | Match at START of string, return captured groups or null | +| `re_search(s: string, pattern: string) -> list[string]?` | Match ANYWHERE in string, return captured groups or null | +| `re_findall(s: string, pattern: string) -> list[string] \| list[list[string]]` | Find all non-overlapping matches; returns full matches if no groups, list of captured group values (not full matches) if one group, list of group lists if multiple groups | +| `re_replace(s: string, pattern: string, repl: string) -> string` | Replace all regex matches with replacement | +| `re_escape(s: string) -> string` | Escape regex metacharacters for literal matching | + +The regex syntax is the intersection of Python's `re` module and Rust's `regex` crate, +ensuring cross-platform compatibility. Supported features: +- Character classes: `[abc]`, `[^abc]`, `[a-z]`, `\d`, `\w`, `\s` (and negations) +- Anchors: `^`, `$`, `\b` +- Quantifiers: `*`, `+`, `?`, `{n}`, `{n,m}`, and non-greedy variants +- Groups: `(...)`, `(?:...)` (non-capturing) +- Alternation: `|` + +Not supported (Python `re` features not in Rust `regex`): +- Backreferences (`\1`, `\2`, etc.) +- Lookahead (`(?=...)`, `(?!...)`) +- Lookbehind (`(?<=...)`, `(? string` | Shell-escape a string for POSIX shells | +| `repr_sh(args: list[string]) -> string` | Join list into space-separated shell-escaped strings | +| `repr_cmd(s: string) -> string` | Escape a string for Windows CMD | +| `repr_cmd(args: list[string]) -> string` | Join list into space-separated CMD-escaped strings | +| `repr_pwsh(s: string) -> string` | Escape a string for PowerShell | +| `repr_pwsh(n: int) -> string` | Integer literal for PowerShell | +| `repr_pwsh(f: float) -> string` | Float literal for PowerShell | +| `repr_pwsh(b: bool) -> string` | Boolean literal (`$true`/`$false`) for PowerShell | +| `repr_pwsh(p: path) -> string` | Escape a path for PowerShell | +| `repr_pwsh(r: range_expr) -> string` | String representation of range (e.g., `'1-10'`) | +| `repr_pwsh(args: list[T]) -> string` | PowerShell array literal `@(...)` | +| `repr_py(value: ?) -> string` | Returns `"None"` | +| `repr_py(r: range_expr) -> string` | String representation (e.g., `'1-10'`) | +| `repr_py(p: path) -> string` | Python string repr of path's string representation | +| `repr_py(value: T) -> string` | Convert to Python representation (`T` in `bool`, `int`, `float`, `string`, `list`) | +| `repr_json(value: ?) -> string` | Returns `"null"` | +| `repr_json(r: range_expr) -> string` | String representation (e.g., `"1-10"`) | +| `repr_json(value: T) -> string` | Convert to JSON representation (`T` in `bool`, `int`, `float`, `string`, `list`) | + +`repr_sh` follows the behavior of Python's +[shlex.quote](https://docs.python.org/3/library/shlex.html#shlex.quote) and +[shlex.join](https://docs.python.org/3/library/shlex.html#shlex.join). + +`repr_cmd` produces Windows CMD-safe strings suitable for use in `.bat` files. +Strings containing special characters (`& | < > ^ " ( ) % !` or whitespace) are wrapped in +double quotes. Inside double quotes, `^` and `"` are escaped with `^`, and `%` is doubled +to `%%` (required for `.bat` file contexts where `%` triggers variable expansion even inside +quotes). Other special characters are literal within quotes. Simple strings without special +characters are returned unquoted. + +Note: `repr_cmd` targets the default `cmd.exe` parsing rules without `EnableDelayedExpansion`. +The `!` character is not escaped; if the output is used in a `.bat` file that enables delayed +expansion (`SETLOCAL ENABLEDELAYEDEXPANSION`), `!` will be interpreted as a variable expansion +trigger and there is no single escaping that works in both modes. Template authors should avoid +delayed expansion in scripts that use `repr_cmd` output. + +`repr_pwsh` produces PowerShell literals with proper escaping. Strings and paths are wrapped in +single quotes with embedded single quotes doubled. Booleans become `$true`/`$false`. Lists become +PowerShell array syntax `@(...)`. + +`repr_py` follows the behavior of Python's +[repr](https://docs.python.org/3/library/functions.html#repr). + +Examples: +- `repr_sh(["echo", "hello world"])` returns `"echo 'hello world'"` +- `repr_cmd("a & b")` returns `"\"a & b\""` (quoted, `&` is literal inside quotes) +- `repr_cmd("a ^ b")` returns `"\"a ^^ b\""` (`^` escaped inside quotes) +- `repr_pwsh(["a", "b"])` returns `@('a', 'b')` +- `repr_py("hello\nworld")` returns `"'hello\\nworld'"` +- `repr_json([1, 2, 3])` returns `"[1, 2, 3]"` + +### 2.3. Path Type Properties and Functions + +The `path` type represents filesystem paths and provides properties and functions inspired by +Python's [pathlib.PurePath](https://docs.python.org/3/library/pathlib.html#pure-paths). Job +parameters of type `PATH` evaluate to the `path` type. + +#### 2.3.1. Path Properties + +Properties are accessed using dot notation via UFCS. + +| Signature | Description | +|-----------|-------------| +| `__property_name__(p: path) -> string` | `p.name` final path component (filename with extension) | +| `__property_stem__(p: path) -> string` | `p.stem` final component without the last suffix | +| `__property_suffix__(p: path) -> string` | `p.suffix` last file extension including dot, or empty string | +| `__property_suffixes__(p: path) -> list[string]` | `p.suffixes` list of file extensions (e.g., `['.tar', '.gz']`) | +| `__property_parent__(p: path) -> path` | `p.parent` parent directory path | +| `__property_parts__(p: path) -> list[string]` | `p.parts` path components as a list | + +These properties match Python's `pathlib.PurePath` behavior exactly. + +Examples: + +```yaml +# Given Param.InputFile = "/projects/shot01/render.exr" +{{ Param.InputFile.name }} # "render.exr" +{{ Param.InputFile.stem }} # "render" +{{ Param.InputFile.suffix }} # ".exr" +{{ Param.InputFile.parent }} # path("/projects/shot01") + +# Given Param.Archive = "/data/backup.tar.gz" +{{ Param.Archive.suffix }} # ".gz" +{{ Param.Archive.suffixes }} # [".tar", ".gz"] +{{ Param.Archive.stem }} # "backup.tar" + +# To get all extensions combined or the bare stem: +{{ Param.Archive.suffixes.join("") }} # ".tar.gz" +{{ Param.Archive.name.removesuffix(Param.Archive.suffixes.join("")) }} # "backup" +``` + +#### 2.3.2. Path Functions + +| Signature | Description | +|-----------|-------------| +| `path(s: string) -> path` | Convert string to path | +| `path(parts: list[string]) -> path` | Construct path from components | +| `with_name(p: path, name: string) -> path` | Return path with the filename changed | +| `with_stem(p: path, stem: string) -> path` | Return path with the stem changed | +| `with_suffix(p: path, suffix: string) -> path` | Return path with the suffix changed | +| `with_number(p: path, num: int) -> path` | Return path with the frame number replaced | +| `with_number(s: string, num: int) -> string` | Return string with the frame number replaced | +| `as_posix(p: path) -> string` | Return string with forward slashes | +| `apply_path_mapping(s: string) -> path` | Apply session path mapping rules to a path string | + +The `apply_path_mapping` function is only available in `@fmtstring[host]` contexts (evaluated at +runtime on the worker host) where path mapping rules are available. Using it in submission-time +contexts is an error. The input is `string` rather than `path` because path mapping often involves +cross-platform scenarios where the source path may not be valid path syntax on the worker's OS. + +##### Frame Number Substitution with `with_number` + +The `with_number` function replaces frame number placeholders in path filenames. It recognizes +several common formats: + +| Format | Example Input | `with_number(72)` Output | +|--------|---------------|--------------------------| +| Digits | `file_003.exr` | `file_072.exr` | +| Printf `%d` | `file_%d.exr` | `file_72.exr` | +| Printf `%0Nd` | `file_%04d.exr` | `file_0072.exr` | +| Hash padding | `file_####.exr` | `file_0072.exr` | +| Hash padding | `file_######.exr` | `file_000072.exr` | + +The function searches the filename stem from the end for these patterns and replaces the last match. +If the number exceeds the padding width, the full number is used without truncation +(e.g., `file_###.exr` with `with_number(10000)` produces `file_10000.exr`). +If no pattern is found, `_NNNN` (4-digit zero-padded) is appended to the stem. + +For negative numbers, the sign is included in the output. With digit and hash formats, the sign +precedes the zero-padded digits and counts toward the width (e.g., `file_003.exr` with +`with_number(-1)` produces `file_-01.exr`). With printf formats, standard printf sign handling +applies. + +```yaml +{{ Param.OutputPattern.with_number(Task.Param.Frame) }} +``` + +--- + +## 3. License + +Copyright ©2026 Amazon.com Inc. or Affiliates ("Amazon"). + +This Agreement sets forth the terms under which Amazon is making the Open Job Description +Specification ("the Specification") available to you. + +### 3.1. Copyrights + +This Specification is licensed under [CC BY-ND 4.0](https://creativecommons.org/licenses/by-nd/4.0/deed.en). + +### 3.2. Patents + +Subject to the terms and conditions of this Agreement, Amazon hereby grants to +you a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable +(except as stated in this section) patent license to make, have made, use, offer +to sell, sell, import, and otherwise transfer implementations of the +Specification that implement and are compliant with all relevant portions of the +Specification ("Compliant Implementations"). Notwithstanding the foregoing, no +patent license is granted to any technologies that may be necessary to make or +use any product or portion thereof that complies with the Specification but are +not themselves expressly set forth in the Specification. + +If you institute patent litigation against any entity (including a cross-claim +or counterclaim in a lawsuit) alleging that Compliant Implementations of the +Specification constitute direct or contributory patent infringement, then any +patent licenses granted to You under this Agreement shall terminate as of the +date such litigation is filed. + +### 3.3. Additional Information + +For more info see the [LICENSE file]. + +[LICENSE FILE]: https://github.com/OpenJobDescription/openjd-specifications/blob/mainline/LICENSE diff --git a/wiki/How-Jobs-Are-Constructed.md b/wiki/How-Jobs-Are-Constructed.md index 0f08dd3..af31444 100644 --- a/wiki/How-Jobs-Are-Constructed.md +++ b/wiki/How-Jobs-Are-Constructed.md @@ -83,6 +83,13 @@ replacing the open & closing curly braces and everything within them with the re value, `"1-{{ Param.End}}:10"`, for the "Frame" **Task Parameter** in the previous section's example resolves to `"1-400:10"` when the **Job Template** is evaluated with `End=400`. +When the EXPR extension is used, **String Interpolation Expressions** can contain the full +[expression language](2026-02-Expression-Language) including arithmetic, conditionals, function calls, +list operations, and more. For example, `"{{ min(Task.Param.Frame + Param.FramesPerTask - 1, Param.FrameEnd) }}"` computes +a frame range end value, and `"{{ 64 if Param.Quality == 'final' else 16 }}"` selects a value based on a parameter. +The EXPR extension also introduces new parameter types including booleans and lists. See the +[Expression Language](2026-02-Expression-Language) specification for details. + The time when a **Format String** in a **Job Template** is evaluated is determined by what value from the **Job Template** is being defined. For example, the string `"1-{{Param.End}}:10"` in the previous section's example is evaluated by the render management system when the **Job Template** is submitted to create a **Job**, and the string diff --git a/wiki/How-Jobs-Are-Run.md b/wiki/How-Jobs-Are-Run.md index b5e1f76..ae603ca 100644 --- a/wiki/How-Jobs-Are-Run.md +++ b/wiki/How-Jobs-Are-Run.md @@ -31,6 +31,10 @@ runs Chunks for that Step instead of individual Tasks. A Chunk is a set of Tasks values identical except for the chunked Task parameter. It takes values from an integer range expression like "1-3" or 1-3,5,7" depending on whether the chunks are constrained to be contiguous or not. +When the EXPR extension is used, format strings evaluated on the Worker Host support the full +[expression language](2026-02-Expression-Language) including arithmetic, conditionals, function calls, +path manipulation, and script embedding functions like `repr_sh()` for safe shell quoting. + All failures and cancellations in a **Session** are terminal for the **Session**, as the system generally does not know what state a failure or cancellation leaves the **Session** in. Think of a Task failure which inadvertently terminates the container that was set up in an **Environment** for the Tasks to run diff --git a/wiki/Job-Intro-03-Creating-a-Job-Template.md b/wiki/Job-Intro-03-Creating-a-Job-Template.md index 8827b0b..ff74580 100644 --- a/wiki/Job-Intro-03-Creating-a-Job-Template.md +++ b/wiki/Job-Intro-03-Creating-a-Job-Template.md @@ -1655,5 +1655,64 @@ Mon Jul 8 10:48:30 2024 Frames(STRING) = 1..40 ... ``` +#### 4.2.4. With the EXPR Extension + +The [Open Job Description Expression Language](2026-02-Expression-Language), added +in the EXPR extension of the 2023-09 template schemas, enables arithmetic expressions +in format strings. This eliminates the need for workaround parameters like `FrameEndMinusOne` +from section 4.2.2, because you can compute values directly. + +Enable the extension: + +```yaml +specificationVersion: 'jobtemplate-2023-09' +extensions: +- EXPR +``` + +With EXPR, the combination expression approach from section 4.2.2 becomes much simpler. You only need `RangeStart` as a +task parameter, and compute the end of each chunk inline: + +```yaml +parameterDefinitions: + - name: FrameStart + type: INT + minValue: 1 + default: 1 + - name: FrameEnd + type: INT + minValue: 1 + default: 380 + - name: FramesPerTask + type: INT + default: 11 +... +steps: + - name: BlenderRender + parameterSpace: + taskParameterDefinitions: + - name: RangeStart + type: INT + range: "{{Param.FrameStart}}-{{Param.FrameEnd}}:{{Param.FramesPerTask}}" + script: + actions: + onRun: + command: "{{Task.File.Render}}" + args: + - "{{Param.SceneFile}}" + - "{{Param.FramesDirectory}}" + - "{{Task.Param.RangeStart}}..{{min(Task.Param.RangeStart + Param.FramesPerTask - 1, Param.FrameEnd)}}" +``` + +The key is `{{min(Task.Param.RangeStart + Param.FramesPerTask - 1, Param.FrameEnd)}}` which computes the chunk end, +clamped to `FrameEnd` for the final chunk. + +EXPR also enables other useful patterns: + +- **Progress calculation**: `{{(Task.Param.Frame - Param.FrameStart) * 100 / (Param.FrameEnd - Param.FrameStart + 1)}}%` +- **Total frame count**: `{{Param.FrameEnd - Param.FrameStart + 1}}` +- **Conditional values**: `{{256 if Param.Quality == 'final' else 32}}` +- **Path manipulation**: `{{Param.OutputDir / Param.SceneFile.stem + '_' + zfill(Task.Param.Frame, 4) + '.png'}}` + Continue the walkthrough in [Ready for Production](Job-Intro-04-Ready-for-Production). \ No newline at end of file diff --git a/wiki/_Sidebar.md b/wiki/_Sidebar.md index d33f9c9..2a7037c 100644 --- a/wiki/_Sidebar.md +++ b/wiki/_Sidebar.md @@ -11,3 +11,4 @@ 4. [Ready for Production](Job-Intro-04-Ready-for-Production) 4. Formal Specifications 1. [2023-09: Template Schemas](2023-09-Template-Schemas) + 2. [2026-02: Expression Language](2026-02-Expression-Language)