Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 55 additions & 10 deletions src/openjd/model/_internal/_create_job.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.

from contextlib import contextmanager
from typing import Annotated, Any, Union, Dict
from typing import Annotated, Any, Optional, Union, Dict

from pydantic import ValidationError
from pydantic import TypeAdapter
from pydantic_core import InitErrorDetails

from .._symbol_table import SymbolTable
from .._format_strings import FormatString
from .._types import OpenJDModel
from .._types import ModelParsingContextInterface, OpenJDModel

__all__ = ("instantiate_model", "resolve_whole_field_typed_list")

Expand Down Expand Up @@ -147,7 +147,7 @@ def capture_validation_errors(
)


def instantiate_model( # noqa: C901
def instantiate_model(
model: OpenJDModel,
symtab: SymbolTable,
) -> OpenJDModel:
Expand All @@ -166,6 +166,33 @@ def instantiate_model( # noqa: C901

Returns:
OpenJDModel: The transformed model.

Target models are constructed with a parsing context seeded from the root
template's declared extensions, so extension-aware limits (e.g. FEATURE_BUNDLE_1
name lengths) apply to resolved values exactly as they did at decode time.
"""
# Models whose class does not bind a parsing-context type (revision-agnostic
# models, e.g. internal test fixtures) are validated without a context, exactly
# as before; every v2023_09 model binds it via its base class.
context_type = getattr(type(model), "model_parsing_context_type", None)
context: Optional[ModelParsingContextInterface]
if context_type is None:
context = None
else:
context = context_type(supported_extensions=getattr(model, "extensions", None) or [])
return _instantiate_model(model, symtab, context=context)


def _instantiate_model( # noqa: C901
model: OpenJDModel,
symtab: SymbolTable,
*,
context: Optional[ModelParsingContextInterface],
) -> OpenJDModel:
"""Recursive worker for :func:`instantiate_model`.

Carries the seeded parsing ``context`` down the depth-first traversal so
each target model is constructed with it.
"""
errors = list[InitErrorDetails]()
instantiated_fields = dict[str, Any]()
Expand Down Expand Up @@ -239,16 +266,20 @@ def instantiate_model( # noqa: C901
if field_name in model._job_creation_metadata.reshape_field_to_dict:
key_field = model._job_creation_metadata.reshape_field_to_dict[field_name]
instantiated = _instantiate_list_field_as_dict(
field_value, symtab, needs_resolve, key_field
field_value, symtab, needs_resolve, key_field, context=context
)
else:
instantiated = _instantiate_list_field_as_list(
field_value, symtab, needs_resolve
field_value, symtab, needs_resolve, context=context
)
elif isinstance(field_value, dict):
instantiated = _instantiate_dict_field(field_value, symtab, needs_resolve)
instantiated = _instantiate_dict_field(
field_value, symtab, needs_resolve, context=context
)
else:
instantiated = _instantiate_noncollection_value(field_value, symtab, needs_resolve)
instantiated = _instantiate_noncollection_value(
field_value, symtab, needs_resolve, context=context
)

# Validate as the target field type using cached TypeAdapter
type_adapter = get_type_adapter(target_field_type)
Expand All @@ -261,7 +292,7 @@ def instantiate_model( # noqa: C901
instantiated_fields.update(**new_fields)

with capture_validation_errors(output_errors=errors, loc=(), input=field_value):
result = target_model(**instantiated_fields)
result = target_model.model_validate(instantiated_fields, context=context)

if errors:
raise ValidationError.from_exception_data(
Expand All @@ -275,6 +306,8 @@ def _instantiate_noncollection_value(
value: Any,
symtab: SymbolTable,
needs_resolve: bool,
*,
context: Optional[ModelParsingContextInterface],
) -> Any:
"""Instantiate a single value that must not be a collection type (list, dict, etc).

Expand All @@ -289,7 +322,7 @@ def _instantiate_noncollection_value(
``instantiate_model`` resolves them once up front.
"""
if isinstance(value, OpenJDModel):
return instantiate_model(value, symtab)
return _instantiate_model(value, symtab, context=context)
elif isinstance(value, FormatString) and needs_resolve:
value = value.resolve(symtab=symtab)

Expand All @@ -300,6 +333,8 @@ def _instantiate_list_field_as_list( # noqa: C901
value: list[Any],
symtab: SymbolTable,
needs_resolve: bool,
*,
context: Optional[ModelParsingContextInterface],
) -> list[Any]:
"""As _instantiate_noncollection_value, but where the value is a list.

Expand All @@ -319,6 +354,7 @@ def _instantiate_list_field_as_list( # noqa: C901
item,
symtab,
needs_resolve,
context=context,
)
)

Expand All @@ -331,7 +367,12 @@ def _instantiate_list_field_as_list( # noqa: C901


def _instantiate_list_field_as_dict( # noqa: C901
value: list[Any], symtab: SymbolTable, needs_resolve: bool, key_field: str
value: list[Any],
symtab: SymbolTable,
needs_resolve: bool,
key_field: str,
*,
context: Optional[ModelParsingContextInterface],
) -> dict[str, Any]:
"""As _instantiate_noncollection_value, but where the value is a list.

Expand All @@ -351,6 +392,7 @@ def _instantiate_list_field_as_dict( # noqa: C901
item,
symtab,
needs_resolve,
context=context,
)

if errors:
Expand All @@ -365,6 +407,8 @@ def _instantiate_dict_field(
value: dict[str, Any],
symtab: SymbolTable,
needs_resolve: bool,
*,
context: Optional[ModelParsingContextInterface],
) -> dict[str, Any]:
"""As _instantiate_noncollection_value, but where the value is a dict.

Expand All @@ -382,6 +426,7 @@ def _instantiate_dict_field(
item,
symtab,
needs_resolve,
context=context,
)

if errors:
Expand Down
15 changes: 9 additions & 6 deletions src/openjd/model/v2023_09/_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -734,11 +734,13 @@ def _requires_oneof(cls, values: dict[str, Any], info: ValidationInfo) -> dict[s
any_wrap = any(v is not None for v in wrap_values.values())

if any_wrap:
# Extension gating is a template-decode concern and only applies
# when a parsing context is present. During job instantiation
# (create_job re-validates the model without a ModelParsingContext)
# the template has already been validated at decode time, so the
# extension-requirement checks are skipped then -- mirroring the
# Extension gating only applies when a parsing context is present.
# At decode time the context carries the supported set; during job
# instantiation create_job re-validates the model with a context
# seeded from the template's declared extensions (see
# _internal/_create_job.py), so this check reproduces its decode-time
# result. A missing context only occurs for model classes that bind no
# parsing-context type, and the check is skipped then -- mirroring the
# `if context` guard the other extension gates in this module use.
if context is not None:
if "WRAP_ACTIONS" not in extensions:
Expand Down Expand Up @@ -898,7 +900,8 @@ def _validate_end_of_line(
if v is None:
return v
context = cast(Optional[ModelParsingContext], info.context)
# Skip extension check if no context (e.g., during job creation from validated template)
# Skip only when there is no parsing context (model classes that bind no
# context type); create_job seeds one from the template's declared extensions.
if context and "FEATURE_BUNDLE_1" not in context.extensions:
raise ValueError("The endOfLine property requires the FEATURE_BUNDLE_1 extension.")
return v
Expand Down
Loading
Loading