feat: improve validation error messaging

Summary:
 When validation errors are encountered the error string that we report
comes straight from pydantic and has two issues:
1. It contains extra information that only serves to make it harder to
   read. e.g. all of the parenthetical text in:
```
steps -> 0 -> parameterSpace -> taskParameterDefinitions -> 0
  Discriminator 'type' is missing in value (type=value_error.discriminated_union.missing_discriminator; discriminator_key=type)
```
2. The path-to-error contains the name of classes for discriminated
   unions. e.g. The 'JobStringParameterDefinition' in:
```
parameterDefinitions -> 0 -> JobStringParameterDefinition -> userInterface -> control
  value is not a valid enumeration member ...
```

 We want a more succint and readable set of error messages.

Solution:
  For (1) we eliminate the paranthetical from the reported error
entirely; pydantic's intent is that that text is for machine processing
of errors, and our customers are not machines. For (2) we introspect the
model's module to determine when a location element is a model class
name and remove it from the location when it is.

 As a bonus, we also condensed the array syntax in the reported location
to use the more commonly known square brackets operator to report the
index.

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

@@ -0,0 +1,78 @@
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+
+from typing import TypedDict, Union, Type
+from pydantic import BaseModel
+from inspect import getmodule
+
+# Calling pydantic's ValidationError.errors() returns a list[ErrorDict], but
+# pydantic doesn't export the ErrorDict type publicly. So, we create it here for
+# type checking.
+# Note that we ignore the 'ctx' key since we don't use it.
+# See: https://github.com/pydantic/pydantic/blob/d9c2af3a701ca982945a590de1a1da98b3fb4003/pydantic/error_wrappers.py#L50
+Loc = tuple[Union[int, str], ...]
+
+
+class ErrorDict(TypedDict):
+    loc: Loc
+    msg: str
+    type: str
+
+
+def pydantic_validationerrors_to_str(root_model: Type[BaseModel], errors: list[ErrorDict]) -> str:
+    """This is our own custom stringification of the Pydantic ValidationError to use
+    in place of str(<ValidationError>). Pydantic's default stringification too verbose for
+    our purpose, and contains information that we don't want.
+    """
+    results = list[str]()
+    for error in errors:
+        results.append(_error_dict_to_str(root_model, error))
+    return f"{len(errors)} validation errors for {root_model.__name__}\n" + "\n".join(results)
+
+
+def _error_dict_to_str(root_model: Type[BaseModel], error: ErrorDict) -> str:
+    loc = error["loc"]
+    msg = error["msg"]
+
+    # When a model's root_validator raises an error other than a ValidationError
+    # (i.e. raises something like a ValueError or a TypeError) then pydantic
+    # reports the field name of the error as "__root__". We handle this specially
+    # when printing errors out since there are no "__root__" fields in our models,
+    # and we don't want to mislead or confuse customers.
+    # "__root__" will *always* be the last element of the 'loc' if it is present, by
+    # definition of how root validators work.
+    #
+    # We want errors from the base model's root validator to not be indented.
+    # This conveys that the error isn't from a nested object.
+    # eg.
+    # ```
+    # field -> inner:
+    #    field missing
+    # JobTemplate: must provide one of 'min' or 'max'
+    # ```
+    if loc == ("__root__",):
+        return f"{root_model.__name__}: {msg}"
+    return f"{_loc_to_str(root_model, loc)}:\n\t{msg}"
+
+
+def _loc_to_str(root_model: Type[BaseModel], loc: Loc) -> str:
+    model_module = getmodule(root_model)
+
+    # If a nested error is from a root validator, then just report the error as being
+    # for the field that points to the object.
+    if loc[-1] == "__root__":
+        loc = loc[:-1]
+
+    loc_elements = list[str]()
+    for item in loc:
+        if isinstance(item, int):
+            loc_elements[-1] = f"{loc_elements[-1]}[{item}]"
+        elif item in model_module.__dict__:
+            # If the name appears in the same module as the model then it is itself
+            # a model. This means that it's inserted because we're somewhere within
+            # a discriminated union; pydantic includes the name of the union class
+            # in the loc when traversing through a discriminated union (but, oddly,
+            # *only* when traversing through a discriminated union).
+            pass
+        else:
+            loc_elements.append(item)
+    return " -> ".join(loc_elements)

@@ -16,6 +16,7 @@
     ParameterValueType,
     SchemaVersion,
 )
+from ._convert_pydantic_error import pydantic_validationerrors_to_str, ErrorDict

 if TYPE_CHECKING:
     # Avoiding a circular import that occurs when trying to import FormatString
@@ -221,6 +222,10 @@ def create_job(*, job_template: JobTemplate, job_parameter_values: JobParameterV
     try:
         job = instantiate_model(job_template, symtab)
     except ValidationError as exc:
-        raise DecodeValidationError(str(exc))
+        raise DecodeValidationError(
+            pydantic_validationerrors_to_str(
+                job_template.__class__, cast(list[ErrorDict], exc.errors())
+            )
+        )

     return cast(Job, job)

@@ -11,6 +11,7 @@

 from ._errors import DecodeValidationError
 from ._types import JobTemplate, OpenJDModel, SchemaVersion
+from ._convert_pydantic_error import pydantic_validationerrors_to_str, ErrorDict
 from .v2023_09 import JobTemplate as JobTemplate_2023_09

 __all__ = ("parse_model", "document_string_to_object", "DocumentType", "decode_template")
@@ -44,7 +45,9 @@ def parse_model(*, model: Type[T], obj: Any) -> T:
     try:
         return _parse_model(model=model, obj=obj)
     except PydanticValidationError as exc:
-        raise DecodeValidationError(str(exc))
+        raise DecodeValidationError(
+            pydantic_validationerrors_to_str(model, cast(list[ErrorDict], exc.errors()))
+        )

 def document_string_to_object(*, document: str, document_type: DocumentType) -> dict[str, Any]:

@@ -0,0 +1,243 @@
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+
+from pydantic import BaseModel, root_validator, Field
+from typing import Literal, Union
+
+from openjd.model._convert_pydantic_error import (
+    ErrorDict,
+    pydantic_validationerrors_to_str,
+    _error_dict_to_str,
+)
+
+
+class TestValidationErrorsToStr:
+    def test(self) -> None:
+        # Just making sure that pydantic_validationerrors_to_str()
+        # prints out all of the given errors.
+
+        # GIVEN
+        class Model(BaseModel):
+            f1: str
+            f2: int
+
+        errors: list[ErrorDict] = [
+            {"loc": ("f1",), "msg": "error message1", "type": "error-type"},
+            {"loc": ("f2",), "msg": "error message2", "type": "error-type"},
+        ]
+        expected = "2 validation errors for Model\nf1:\n\terror message1\nf2:\n\terror message2"
+
+        # WHEN
+        result = pydantic_validationerrors_to_str(Model, errors)
+
+        # THEN
+        assert result == expected
+
+
+class TestSimpleModels:
+    """Tests of converting validation errors from "simple" models. These
+    are models that contain no sequence types or unions as field types.
+    """
+
+    def test_single_level(self) -> None:
+        # Make sure that our path to error is correct for a single-level
+        # error
+
+        # GIVEN
+        class Model(BaseModel):
+            f1: str
+            f2: int
+
+        error: ErrorDict = {"loc": ("f2",), "msg": "error message", "type": "error-type"}
+        expected = "f2:\n\terror message"
+
+        # WHEN
+        result = _error_dict_to_str(Model, error)
+
+        # THEN
+        assert result == expected
+
+    def test_nesting_level(self) -> None:
+        # Make sure that our path to error is correct for a multi-level
+        # error
+
+        # GIVEN
+        class Inner(BaseModel):
+            ff: str
+
+        class Model(BaseModel):
+            inner: Inner
+
+        error: ErrorDict = {
+            "loc": (
+                "inner",
+                "ff",
+            ),
+            "msg": "error message",
+            "type": "error-type",
+        }
+        expected = "inner -> ff:\n\terror message"
+
+        # WHEN
+        result = _error_dict_to_str(Model, error)
+
+        # THEN
+        assert result == expected
+
+    def test_base_root_validator_error(self) -> None:
+        # Make sure that our path to error is correct for validation error
+        # at the base level's root validator
+        # This is a special case where we do not want the error message to be indented
+        # at all. This conveys that the error is not nested.
+        # Errors raised by a root validator have a special "__root__" field name
+
+        # GIVEN
+        class Model(BaseModel):
+            ff: str
+
+            @root_validator
+            def _validate(cls, values):
+                raise ValueError("error message")
+
+        error: ErrorDict = {"loc": ("__root__",), "msg": "error message", "type": "error-type"}
+        expected = "Model: error message"
+
+        # WHEN
+        result = _error_dict_to_str(Model, error)
+
+        # THEN
+        assert result == expected
+
+    def test_inner_root_validator_error(self) -> None:
+        # Make sure that our path to error is correct for validation error
+        # at a nested level's root validator.
+        # In this case we drop the '__root__' field at the end and report the error
+        # as being for the field one level up.
+        # Errors raised by a root validator have a special "__root__" field name
+
+        # GIVEN
+        class Inner(BaseModel):
+            ff: str
+
+            @root_validator
+            def _validate(cls, values):
+                raise ValueError("error message")
+
+        class Model(BaseModel):
+            inner: Inner
+
+        error: ErrorDict = {
+            "loc": (
+                "inner",
+                "__root__",
+            ),
+            "msg": "error message",
+            "type": "error-type",
+        }
+        expected = "inner:\n\terror message"
+
+        # WHEN
+        result = _error_dict_to_str(Model, error)
+
+        # THEN
+        assert result == expected
+
+
+class TestArrayFields:
+    """Testing that we print the locations of errors correctly when there are array
+    fields in the model.
+    """
+
+    def test_scalar(self) -> None:
+        # Test the case where there's an error in one of the elements of
+        # a scalar array (e.g. its the wrong type)
+
+        # GIVEN
+        class Model(BaseModel):
+            field: list[int]
+
+        error: ErrorDict = {
+            "loc": (
+                "field",
+                2,
+            ),
+            "msg": "error message",
+            "type": "error-type",
+        }
+        expected = "field[2]:\n\terror message"
+
+        # WHEN
+        result = _error_dict_to_str(Model, error)
+
+        # THEN
+        assert result == expected
+
+    def test_inner_model(self) -> None:
+        # Test the case where there's an error in one of the elements of
+        # an array of models
+
+        # GIVEN
+        class Inner(BaseModel):
+            ff: int
+
+        class Model(BaseModel):
+            inner: list[Inner]
+
+        error: ErrorDict = {
+            "loc": (
+                "inner",
+                2,
+                "ff",
+            ),
+            "msg": "error message",
+            "type": "error-type",
+        }
+        expected = "inner[2] -> ff:\n\terror message"
+
+        # WHEN
+        result = _error_dict_to_str(Model, error)
+
+        # THEN
+        assert result == expected
+
+
+# Due to the nature of how we defermine locations with discriminated unions,
+# these classes *must* be defined in the module.
+class UnionInner1(BaseModel):
+    type: Literal["Inner1"]
+    ff: int
+
+
+class UnionInner2(BaseModel):
+    type: Literal["Inner2"]
+    ff: str
+
+
+class UnionModel(BaseModel):
+    inner: Union[UnionInner1, UnionInner2] = Field(..., discriminator="type")
+
+
+class TestDiscriminatedUnion:
+    """Testing that the location is correct in the presence of a discriminated union.
+    When an error is through a discriminated union, pydantic will include the name
+    of the union class type that contains the error as one of the location elements.
+    """
+
+    def test(self) -> None:
+        # GIVEN
+        error: ErrorDict = {
+            "loc": (
+                "inner",
+                2,
+                "UnionInner1",
+                "ff",
+            ),
+            "msg": "error message",
+            "type": "error-type",
+        }
+        expected = "inner[2] -> ff:\n\terror message"
+
+        # WHEN
+        result = _error_dict_to_str(UnionModel, error)
+
+        # THEN
+        assert result == expected

@@ -0,0 +1,23 @@
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+
+import pytest
+from pydantic import BaseModel
+from typing import Type
+import openjd.model.v2023_09 as mod
+from inspect import getmembers, getmodule, isclass
+
+
+ALL_MODELS = sorted(
+    [obj for name, obj in getmembers(mod) if isclass(obj) and issubclass(obj, BaseModel)],
+    key=lambda o: o.__name__,
+)
+
+
+@pytest.mark.parametrize("model", ALL_MODELS)
+def test_models_in_same_module(model: Type[BaseModel]) -> None:
+    # For our error reporting of discriminated union fields to be correctly reported
+    # we require that *all* of the models are defined in exactly the same module as the JobTemplate
+    # model.
+    # This is to identify when a name in an error location is actually a class name from
+    # a typed union.
+    assert getmodule(mod.JobTemplate) == getmodule(model)


feat: improve validation error messaging

Note: Draft until https://github.com/xxyggoqtpcmcofkc/openjd-model-for-python/pull/12 is merged.

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

 When validation errors are encountered the error string that we report comes straight from pydantic and has two issues:
1. It contains extra information that only serves to make it harder to  read. e.g. all of the parenthetical text in:
```
steps -> 0 -> parameterSpace -> taskParameterDefinitions -> 0
  Discriminator 'type' is missing in value (type=value_error.discriminated_union.missing_discriminator; discriminator_key=type)
```
2. The path-to-error contains the name of classes for discriminated  unions. e.g. The 'JobStringParameterDefinition' in:
```
parameterDefinitions -> 0 -> JobStringParameterDefinition -> userInterface -> control
  value is not a valid enumeration member ...
```

 We want a more succinct and readable set of error messages.

### What was the solution? (How)

  For (1) we eliminate the paranthetical from the reported error entirely; pydantic's intent is that that text is for machine processing of errors, and our customers are not machines. For (2) we introspect the model's module to determine when a location element is a model class name and remove it from the location when it is.

 As a bonus, we also condensed the array syntax in the reported location to use the more commonly known square brackets operator to report the index.

### What is the impact of this change?

I expect that customers will have an easier time reading and understanding the validation errors from their models. It's likely that we'll iterate on this a little more, but this is a good start to try out for a while.

### How was this change tested?

We have our unit tests, but I also used the CLI to compare error messages before & after the change.

e.g. Checking the job template:
```
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 (with https://github.com/xxyggoqtpcmcofkc/openjd-model-for-python/pull/12 applied):
```
% 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)
```

After:
```
% openjd check ../test.yaml
ERROR: '../test.yaml' failed checks:
steps[0] -> parameterSpace -> taskParameterDefinitions[0]:
        Discriminator 'type' is missing in value
steps[0] -> parameterSpace -> taskParameterDefinitions[2] -> range:
        field required
steps[0] -> parameterSpace -> taskParameterDefinitions[3] -> range:
        field required
parameterDefinitions[0] -> userInterface -> control:
        value is not a valid enumeration member; permitted: 'LINE_EDIT', 'MULTILINE_EDIT', 'DROPDOWN_LIST', 'CHECK_BOX', 'HIDDEN'
parameterDefinitions[1] -> userInterface -> control:
        value is not a valid enumeration member; permitted: 'LINE_EDIT', 'MULTILINE_EDIT', 'DROPDOWN_LIST', 'CHECK_BOX', 'HIDDEN'
parameterDefinitions[2]:
        Discriminator 'type' is missing in value
```

I also did a spot check with `openjd run` on the following template to ensure that the errors for instantiating a job also look correct:
```
specificationVersion: jobtemplate-2023-09
name: "{{ Param.Name }}"
parameterDefinitions:
  - name: Name
    type: STRING
    default: 01234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789012345678901234567890123456789
steps:
  - name: Step
    script:
      actions:
        onRun:
          command: echo
          args:
          - Hi
```

### 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.*

enhancement

New feature or request

openjd-model-for-python

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


@@ -0,0 +1,78 @@
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+
+from typing import TypedDict, Union, Type
+from pydantic import BaseModel
+from inspect import getmodule
+
+# Calling pydantic's ValidationError.errors() returns a list[ErrorDict], but
+# pydantic doesn't export the ErrorDict type publicly. So, we create it here for
+# type checking.
+# Note that we ignore the 'ctx' key since we don't use it.
+# See: https://github.com/pydantic/pydantic/blob/d9c2af3a701ca982945a590de1a1da98b3fb4003/pydantic/error_wrappers.py#L50
+Loc = tuple[Union[int, str], ...]
+
+
+class ErrorDict(TypedDict):
+    loc: Loc
+    msg: str
+    type: str
+
+
+def pydantic_validationerrors_to_str(root_model: Type[BaseModel], errors: list[ErrorDict]) -> str:
+    """This is our own custom stringification of the Pydantic ValidationError to use
+    in place of str(<ValidationError>). Pydantic's default stringification too verbose for
+    our purpose, and contains information that we don't want.
+    """
+    results = list[str]()
+    for error in errors:
+        results.append(_error_dict_to_str(root_model, error))
+    return "\n".join(results)
+
+
+def _error_dict_to_str(root_model: Type[BaseModel], error: ErrorDict) -> str:
+    loc = error["loc"]
+    msg = error["msg"]
+
+    # When a model's root_validator raises an error other than a ValidationError
+    # (i.e. raises something like a ValueError or a TypeError) then pydantic
+    # reports the field name of the error as "__root__". We handle this specially
+    # when printing errors out since there are no "__root__" fields in our models,
+    # and we don't want to mislead or confuse customers.
+    # "__root__" will *always* be the last element of the 'loc' if it is present, by
+    # definition of how root validators work.
+    #
+    # We want errors from the base model's root validator to not be indented.
+    # This conveys that the error isn't from a nested object.
+    # eg.
+    # ```
+    # field -> inner:
+    #    field missing
+    # JobTemplate: must provide one of 'min' or 'max'
+    # ```
+    if loc == ("__root__",):
+        return f"{root_model.__name__}: {msg}"
+    return f"{_loc_to_str(root_model, loc)}:\n\t{msg}"
+
+
+def _loc_to_str(root_model: Type[BaseModel], loc: Loc) -> str:
+    model_module = getmodule(root_model)
+
+    # If a nested error is from a root validator, then just report the error as being
+    # for the field that points to the object.
+    if loc[-1] == "__root__":
+        loc = loc[:-1]
+
+    loc_elements = list[str]()
+    for item in loc:
+        if isinstance(item, int):
+            loc_elements[-1] = f"{loc_elements[-1]}[{item}]"

When there are a large number of parameters, this will be "parameterDefinition[24]" out of 42 parameters. Now I go back to the template and need to find the 25th entry.

Can we also traverse the model itself, extract the "name" attribute from there when it exists, and make this "parameteDefinition[25] (cameraForLayer5)" so that the user can now search for the parameter called "cameraForLayer5"?


Yeah, I completely agree. It's a bit trickier to do that as it involves recursing through the model definitions and may not always work. I have a separate ticket to dig in to this particular improvement.


@@ -0,0 +1,78 @@
+# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
+
+from typing import TypedDict, Union, Type
+from pydantic import BaseModel
+from inspect import getmodule
+
+# Calling pydantic's ValidationError.errors() returns a list[ErrorDict], but
+# pydantic doesn't export the ErrorDict type publicly. So, we create it here for
+# type checking.
+# Note that we ignore the 'ctx' key since we don't use it.
+# See: https://github.com/pydantic/pydantic/blob/d9c2af3a701ca982945a590de1a1da98b3fb4003/pydantic/error_wrappers.py#L50
+Loc = tuple[Union[int, str], ...]
+
+
+class ErrorDict(TypedDict):
+    loc: Loc
+    msg: str
+    type: str
+
+
+def pydantic_validationerrors_to_str(root_model: Type[BaseModel], errors: list[ErrorDict]) -> str:
+    """This is our own custom stringification of the Pydantic ValidationError to use
+    in place of str(<ValidationError>). Pydantic's default stringification too verbose for
+    our purpose, and contains information that we don't want.
+    """
+    results = list[str]()
+    for error in errors:
+        results.append(_error_dict_to_str(root_model, error))
+    return "\n".join(results)

Hrm. This should be:
```
return f"{len(errors)} validation errors for {root_model.__name__}\n" + "\n".join(results)
```

I'll update