fix: make typed union disciminators function correctly

This corrects two errors in the way that typed union disciminators were
implemented in the model:
1. discriminator was spelled incorrectly ("desciminator") causing no
   discriminators to be active in the model; and
2. discriminators for collections of unions were defined incorrectly --
   they need to be annotated on the element rather than applied to the
   collecttion field.

Signed-off-by: Daniel Neilson <53624638+ddneilson@users.noreply.github.com>

@@ -7,6 +7,7 @@
 from enum import Enum
 from graphlib import CycleError, TopologicalSorter
 from typing import TYPE_CHECKING, Any, ClassVar, Literal, Optional, Type, Union, cast
+from typing_extensions import Annotated

 from pydantic import (
     Field,
@@ -498,7 +499,7 @@ class IntTaskParameterDefinition(OpenJDModel_v2023_09):

     Attributes:
         name (Identifier):  A name by which the parameter is referenced.
-        type (TaskParameterType.INT): Descriminator to identify the type of the parameter.
+        type (TaskParameterType.INT): discriminator to identify the type of the parameter.
         range (IntRangeList | RangeString): The list of values that the parameter takes on.
     """

@@ -587,7 +588,7 @@ class FloatTaskParameterDefinition(OpenJDModel_v2023_09):

     Attributes:
         name (Identifier):  A name by which the parameter is referenced.
-        type (TaskParameterType.FLOAT): Descriminator to identify the type of the parameter.
+        type (TaskParameterType.FLOAT): discriminator to identify the type of the parameter.
         range (FloatRangeList): The list of values that the parameter takes on.
     """

@@ -639,7 +640,7 @@ class StringTaskParameterDefinition(OpenJDModel_v2023_09):

     Attributes:
         name (Identifier):  A name by which the parameter is referenced.
-        type (TaskParameterType.STRING): Descriminator to identify the type of the parameter.
+        type (TaskParameterType.STRING): discriminator to identify the type of the parameter.
         range (StringRangeList): The list of values that the parameter takes on.
     """

@@ -667,7 +668,7 @@ class PathTaskParameterDefinition(OpenJDModel_v2023_09):

     Attributes:
         name (Identifier):  A name by which the parameter is referenced.
-        type (TaskParameterType.PATH): Descriminator to identify the type of the parameter.
+        type (TaskParameterType.PATH): discriminator to identify the type of the parameter.
         range (StringRangeList): The list of values that the parameter takes on.
     """

@@ -701,7 +702,11 @@ class PathTaskParameterDefinition(OpenJDModel_v2023_09):
     TaskParameterList = list[TaskParameterDefinition]
     CombinationExpr = str
 else:
-    TaskParameterList = conlist(TaskParameterDefinition, min_items=1, max_items=16)
+    TaskParameterList = conlist(
+        Annotated[TaskParameterDefinition, Field(..., discriminator="type")],
+        min_items=1,
+        max_items=16,
+    )
     # Limit the CombinationExpr to characters allowed in an Identifier plus whitespace
     # and the operator characters.
     CombinationExpr = constr(
@@ -948,7 +953,7 @@ class JobStringParameterDefinition(OpenJDModel_v2023_09, JobParameterInterface):

     Attributes:
         name (Identifier): A name by which the parameter is referenced.
-        type (JobParameterType.STRING): Descriminator to identify the type of the parameter
+        type (JobParameterType.STRING): discriminator to identify the type of the parameter
         userInterface (Optional[JobStringParameterDefinitionUserInterface]): User interface properties
             for this parameter
         description (Optional[Description]): A free form string that can be used to describe
@@ -1163,7 +1168,7 @@ class JobPathParameterDefinition(OpenJDModel_v2023_09, JobParameterInterface):

     Attributes:
         name (Identifier): A name by which the parameter is referenced.
-        type (JobParameterType.PATH): Descriminator to identify the type of the parameter
+        type (JobParameterType.PATH): discriminator to identify the type of the parameter
         objectType (Optional[JobPathParameterDefinitionObjectType]): The type of object the path represents,
             either a FILE or a DIRECTORY.
         dataFlow (Optional[JobPathParameterDefinitionDataFlow]): Whether the object the path represents
@@ -1368,7 +1373,7 @@ class JobIntParameterDefinition(OpenJDModel_v2023_09):

     Attributes:
         name (Identifier): A name by which the parameter is referenced.
-        type (JobParameterType.INT): Descriminator to identify the type of the parameter
+        type (JobParameterType.INT): discriminator to identify the type of the parameter
         userInterface (Optional[JobPathParameterDefinitionUserInterface]): User interface properties
             for this parameter
         description (Optional[Description]): A free form string that can be used to describe
@@ -1577,7 +1582,7 @@ class JobFloatParameterDefinition(OpenJDModel_v2023_09):

     Attributes:
         name (Identifier): A name by which the parameter is referenced.
-        type (JobParameterType.FLOAT): Descriminator to identify the type of the parameter
+        type (JobParameterType.FLOAT): discriminator to identify the type of the parameter
         userInterface (Optional[JobPathParameterDefinitionUserInterface]): User interface properties
             for this parameter.
         description (Optional[Description]): A free form string that can be used to describe
@@ -2117,11 +2122,14 @@ def _validate_no_self_dependency(cls, values: dict[str, Any]) -> dict[str, Any]:
 else:
     StepTemplateList = conlist(StepTemplate, min_items=1)
     JobParameterDefinitionList = conlist(
-        Union[
-            JobIntParameterDefinition,
-            JobFloatParameterDefinition,
-            JobStringParameterDefinition,
-            JobPathParameterDefinition,
+        Annotated[
+            Union[
+                JobIntParameterDefinition,
+                JobFloatParameterDefinition,
+                JobStringParameterDefinition,
+                JobPathParameterDefinition,
+            ],
+            Field(..., discriminator="type"),
         ],
         min_items=1,
         max_items=50,
@@ -2162,7 +2170,7 @@ class JobTemplate(OpenJDModel_v2023_09):
     name: JobTemplateName
     steps: StepTemplateList
     description: Optional[Description] = None
-    parameterDefinitions: Optional[JobParameterDefinitionList] = Field(None, descriminator="type")
+    parameterDefinitions: Optional[JobParameterDefinitionList] = None
     jobEnvironments: Optional[JobEnvironmentsList] = None
     # Note: Cannot call the field 'schema'; it masks a base class field
     schemaStr: Optional[str] = Field(None, alias="$schema")  # noqa: N815

@@ -182,23 +182,25 @@ def test_parse_success(self, data: dict[str, Any]) -> None:
         # no exception was raised.

     @pytest.mark.parametrize(
-        "data",
+        "data,expected_num_errors",
         (
-            pytest.param({}, id="empty object"),
+            pytest.param({}, 3, id="empty object"),
             pytest.param(
                 {
                     "specificationVersion": "jobtemplate-2023-09",
                     "name": "Foo",
                     "steps": [STEP_TEMPLATE],
                     "unknown": "key",
                 },
+                1,
                 id="unknown key",
             ),
             pytest.param(
                 {
                     "name": "Foo",
                     "steps": [STEP_TEMPLATE],
                 },
+                1,
                 id="missing spec ver",
             ),
             #
@@ -209,6 +211,7 @@ def test_parse_success(self, data: dict[str, Any]) -> None:
                     "steps": [STEP_TEMPLATE],
                     "description": 12,
                 },
+                1,
                 id="description not string",
             ),
             #
@@ -217,6 +220,7 @@ def test_parse_success(self, data: dict[str, Any]) -> None:
                     "specificationVersion": "jobtemplate-2023-09",
                     "name": "Foo",
                 },
+                1,
                 id="missing steps",
             ),
             pytest.param(
@@ -225,6 +229,7 @@ def test_parse_success(self, data: dict[str, Any]) -> None:
                     "name": "Foo",
                     "steps": [],
                 },
+                1,
                 id="empty steps",
             ),
             pytest.param(
@@ -233,6 +238,7 @@ def test_parse_success(self, data: dict[str, Any]) -> None:
                     "name": "Foo",
                     "steps": [{"name": "S", "script": STEP_SCRIPT} for i in range(0, 2)],
                 },
+                1,
                 id="duplicate step names",
             ),
             #
@@ -243,6 +249,7 @@ def test_parse_success(self, data: dict[str, Any]) -> None:
                     "steps": [STEP_TEMPLATE],
                     "parameterDefinitions": [],
                 },
+                1,
                 id="empty parameters",
             ),
             pytest.param(
@@ -254,6 +261,7 @@ def test_parse_success(self, data: dict[str, Any]) -> None:
                         {"name": f"P{i}", "type": "INT"} for i in range(0, 51)
                     ],
                 },
+                1,
                 id="too many job parameters",
             ),
             pytest.param(
@@ -263,8 +271,42 @@ def test_parse_success(self, data: dict[str, Any]) -> None:
                     "steps": [STEP_TEMPLATE],
                     "parameterDefinitions": [{"name": "P", "type": "INT"} for i in range(0, 2)],
                 },
+                1,
                 id="duplicate parameter names",
             ),
+            pytest.param(
+                {
+                    "specificationVersion": "jobtemplate-2023-09",
+                    "name": "Foo",
+                    "steps": [STEP_TEMPLATE],
+                    "parameterDefinitions": [
+                        {
+                            "name": "foo",
+                        },
+                    ],
+                },
+                # If the discriminator ("type" field) is missing then we should only see a single
+                # error if the typed union discriminator is set up correctly. If it's not
+                # set up correctly, then we'll get one error for every type in the union.
+                1,
+                id="discriminator missing",
+            ),
+            pytest.param(
+                {
+                    "specificationVersion": "jobtemplate-2023-09",
+                    "name": "Foo",
+                    "steps": [STEP_TEMPLATE],
+                    "parameterDefinitions": [
+                        {"name": "foo", "type": "INT", "default": "nine"},
+                    ],
+                },
+                # If have a single error in the parameter definition and the Union discriminator
+                # is set up correctly, then we should only see a single error for the field in
+                # the specific Unioned type. If it's not set up correctly, then we'll
+                # see at least an error from each type in the Union.
+                1,
+                id="discriminator works",
+            ),
             #
             pytest.param(
                 {
@@ -273,6 +315,7 @@ def test_parse_success(self, data: dict[str, Any]) -> None:
                     "steps": [STEP_TEMPLATE],
                     "jobEnvironments": [],
                 },
+                1,
                 id="empty environments",
             ),
             pytest.param(
@@ -282,6 +325,7 @@ def test_parse_success(self, data: dict[str, Any]) -> None:
                     "steps": [STEP_TEMPLATE],
                     "jobEnvironments": [{"name": "E", "script": ENV_SCRIPT} for i in range(0, 2)],
                 },
+                1,
                 id="duplicate environment names",
             ),
             pytest.param(
@@ -306,6 +350,7 @@ def test_parse_success(self, data: dict[str, Any]) -> None:
                         },
                     ],
                 },
+                1,
                 id="with step dependency cycle",
             ),
             pytest.param(
@@ -324,6 +369,7 @@ def test_parse_success(self, data: dict[str, Any]) -> None:
                         },
                     ],
                 },
+                1,
                 id="refs unknown step",
             ),
             pytest.param(
@@ -351,11 +397,12 @@ def test_parse_success(self, data: dict[str, Any]) -> None:
                         {"name": "B0", "script": ENV_SCRIPT},
                     ],
                 },
+                2,
                 id="step env name duplicates job env name",
             ),
         ),
     )
-    def test_parse_fails(self, data: dict[str, Any]) -> None:
+    def test_parse_fails(self, data: dict[str, Any], expected_num_errors: int) -> None:
         # Failure case testing for Open Job Description JobTemplate.
         # - Constraint tests
         # - extra field test
@@ -365,4 +412,4 @@ def test_parse_fails(self, data: dict[str, Any]) -> None:
             _parse_model(model=JobTemplate, obj=data)

         # THEN
-        assert len(excinfo.value.errors()) > 0
+        assert len(excinfo.value.errors()) == expected_num_errors

@@ -452,6 +452,31 @@ def test_parse_success(self, data: dict[str, Any]) -> None:
                 1,
                 id="duplicate parameter name",
             ),
+            pytest.param(
+                {
+                    "taskParameterDefinitions": [
+                        {"name": "foo", "range": [1]},
+                    ]
+                },
+                # If the discriminator ("type" field) is missing then we should only see a single
+                # error if the typed union discriminator is set up correctly. If it's not
+                # set up correctly, then we'll get one error for every type in the union.
+                1,
+                id="discriminator missing",
+            ),
+            pytest.param(
+                {
+                    "taskParameterDefinitions": [
+                        {"name": "foo", "type": "INT"},
+                    ]
+                },
+                # If we're missing a required field ("range") and the Union discriminator
+                # is set up correctly, then we should only see a single error for the field being
+                # missing in the specific Unioned type. If it's not set up correctly, then we'll
+                # see at least an error from each type in the Union.
+                1,
+                id="discriminator works",
+            ),
             pytest.param(
                 {
                     "taskParameterDefinitions": [


fix: make typed union disciminators function correctly

### What was the problem/requirement? (What/Why)

Model validation errors were much too verbose. If an error was made in a typed union, then you would see at least one error (often at least 2) for each type in the union when you should only see one for the specific union type that is applicable.

### What was the solution? (How)

This corrects two errors in the way that typed union disciminators were implemented in the model:
1. discriminator was spelled incorrectly ("desciminator") causing no discriminators to be active in the model; and
2. discriminators for collections of unions were defined incorrectly -- they need to be annotated on the element rather than applied to the collecttion field.

### What is the impact of this change?

Validation error messages are much easier to grok. The work's not done, but this is a step in the right direction.

### How was this change tested?

Unit tests, and manually via the cli `check` command.

As a sample, given:

```yaml
specificationVersion: 'jobtemplate-2023-09'
name: Test template
parameterDefinitions:
  - name: LineEditControl
    type: STRING
    userInterface:
      control: LINE__EDIT # LINE_EDIT
      label: Line Edit Control
      groupLabel: Text Controls
    description: "Unrestricted line of text!"
    default: Default line edit value.
  - name: MultiLineEditControl
    type: STRING
    userInterface:
      control: MULTILINE__EDIT # MULTILINE_EDIT
      label: Multi-line Edit Control
      groupLabel: Text Controls
    description: "Unrestricted text file"
    default: |
      This is a
      text file with
      multiple lines.
  - name: IntSpinner
    # type: INT
    userInterface:
      control: SPIN_BOX
      label: Default Int Spinner
      groupLabel: Int Spinners
    description: A default integer spinner.
    default: 42
steps:
- name: Step
  parameterSpace:
    taskParameterDefinitions:
    - name: p1
      # type: INT
      range: 1-10
    - name: p2
      type: STRING
      range:
      - "foo"
      - "bar"
    - name: p3
      type: FLOAT
      # range:
    - name: p4
      type: INT
      # range:
  script:
    embeddedFiles:
      - name: runScript
        type: TEXT
        runnable: true
        data: |
          #!/usr/bin/env bash

          echo 'LineEditControl value:'
          echo '{{Param.LineEditControl}}'

          echo 'ConstrainedLine value"'
          # For testing: This value doesn't exist
          echo '{{Param.ConstrainedLine}}'

          echo 'MultiLineEditControl value:'
          echo '{{Param.MultiLineEditControl}}'

    actions:
      onRun:
        command: '{{Task.File.runScript}}'
```

Before the change we get:
```bash
% openjd check ../test.yaml
ERROR: '../test.yaml' failed checks: 47 validation errors for JobTemplate
steps -> 0 -> parameterSpace -> taskParameterDefinitions -> 0 -> type
  field required (type=value_error.missing)
steps -> 0 -> parameterSpace -> taskParameterDefinitions -> 0 -> type
  field required (type=value_error.missing)
steps -> 0 -> parameterSpace -> taskParameterDefinitions -> 0 -> range
  value is not a valid list (type=type_error.list)
steps -> 0 -> parameterSpace -> taskParameterDefinitions -> 0 -> type
... and so on
```

After this change we get:
```bash
% openjd check ../test.yaml
ERROR: '../test.yaml' failed checks: 6 validation errors for JobTemplate
steps -> 0 -> parameterSpace -> taskParameterDefinitions -> 0
  Discriminator 'type' is missing in value (type=value_error.discriminated_union.missing_discriminator; discriminator_key=type)
steps -> 0 -> parameterSpace -> taskParameterDefinitions -> 2 -> FloatTaskParameterDefinition -> range
  field required (type=value_error.missing)
steps -> 0 -> parameterSpace -> taskParameterDefinitions -> 3 -> IntTaskParameterDefinition -> range
  field required (type=value_error.missing)
parameterDefinitions -> 0 -> JobStringParameterDefinition -> userInterface -> control
  value is not a valid enumeration member; permitted: 'LINE_EDIT', 'MULTILINE_EDIT', 'DROPDOWN_LIST', 'CHECK_BOX', 'HIDDEN' (type=type_error.enum; enum_values=[<StringUserInterfaceControl.LINE_EDIT: 'LINE_EDIT'>, <StringUserInterfaceControl.MULTILINE_EDIT: 'MULTILINE_EDIT'>, <StringUserInterfaceControl.DROPDOWN_LIST: 'DROPDOWN_LIST'>, <StringUserInterfaceControl.CHECK_BOX: 'CHECK_BOX'>, <StringUserInterfaceControl.HIDDEN: 'HIDDEN'>])
parameterDefinitions -> 1 -> JobStringParameterDefinition -> userInterface -> control
  value is not a valid enumeration member; permitted: 'LINE_EDIT', 'MULTILINE_EDIT', 'DROPDOWN_LIST', 'CHECK_BOX', 'HIDDEN' (type=type_error.enum; enum_values=[<StringUserInterfaceControl.LINE_EDIT: 'LINE_EDIT'>, <StringUserInterfaceControl.MULTILINE_EDIT: 'MULTILINE_EDIT'>, <StringUserInterfaceControl.DROPDOWN_LIST: 'DROPDOWN_LIST'>, <StringUserInterfaceControl.CHECK_BOX: 'CHECK_BOX'>, <StringUserInterfaceControl.HIDDEN: 'HIDDEN'>])
parameterDefinitions -> 2
  Discriminator 'type' is missing in value (type=value_error.discriminated_union.missing_discriminator; discriminator_key=type)
```

### Was this change documented?

N/A

### Is this a breaking change?

No
----

*By submitting this pull request, I confirm that you can use, modify, copy, and redistribute this contribution, under the terms of your choice.*

openjd-model-for-python

Provides a Python implementation of the data model for Open Job Description's template schemas.


@@ -351,11 +364,12 @@ def test_parse_success(self, data: dict[str, Any]) -> None:
                         {"name": "B0", "script": ENV_SCRIPT},
                     ],
                 },
+                2,
                 id="step env name duplicates job env name",
             ),
         ),
     )
-    def test_parse_fails(self, data: dict[str, Any]) -> None:
+    def test_parse_fails(self, data: dict[str, Any], expected_num_errors: int) -> None:

I added all of the error counts in preparation of adding test cases like in the step's parameter space tests, but then didn't add the tests. Whoops!