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
48 changes: 28 additions & 20 deletions .generator/src/generator/templates/model_utils.j2
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,9 @@ def allows_single_value_input(cls):
elif issubclass(cls, ModelComposed):
if not cls._composed_schemas["oneOf"]:
return False
return any(allows_single_value_input(c) for c in cls._composed_schemas["oneOf"] if not isinstance(c, list))
return any(
isinstance(c, list) or allows_single_value_input(c) for c in cls._composed_schemas["oneOf"]
)
return False


Expand Down Expand Up @@ -169,7 +171,7 @@ class OpenApiModel:
)
if isinstance(value, list):
for x in value:
if isinstance(x, UnparsedObject):
if isinstance(x, UnparsedObject) or (isinstance(x, OpenApiModel) and x._unparsed):
self._unparsed = True
if name in self.validations:
check_validations(self.validations[name], name, value, self._configuration)
Expand Down Expand Up @@ -1664,6 +1666,9 @@ def get_oneof_instance(cls, model_kwargs, constant_kwargs, model_arg=None):

with suppress(Exception):
if not single_value_input:
if model_arg is not None and not isinstance(model_arg, dict):
# A non-mapping input (list or scalar) cannot match an object schema.
continue
if constant_kwargs.get("_spec_property_naming"):
oneof_instance = oneof_class(
**change_keys_js_to_python(model_kwargs, oneof_class), **constant_kwargs
Expand All @@ -1675,16 +1680,19 @@ def get_oneof_instance(cls, model_kwargs, constant_kwargs, model_arg=None):
else:
if isinstance(oneof_class, list):
oneof_class = oneof_class[0]
list_oneof_instance = []
if model_arg is None and not model_kwargs:
# Empty data
oneof_instances.append(list_oneof_instance)
oneof_instances.append([])
continue
if not isinstance(model_arg, list):
# A non-array input cannot match a list schema.
continue

# Check if inner type is primitive - follows same pattern as complex objects:
# https://github.com/DataDog/datadog-api-client-python/blob/008536d34760ce096e118edc54df613d82194529/.generator/src/generator/templates/model_utils.j2#L1590-L1599
if oneof_class in PRIMITIVE_TYPES:
# Handle list of primitives (e.g., [str], [float])
list_oneof_instance = []
for arg in model_arg:
oneof_instance = validate_and_convert_types(
arg,
Expand All @@ -1695,28 +1703,28 @@ def get_oneof_instance(cls, model_kwargs, constant_kwargs, model_arg=None):
configuration=constant_kwargs.get("_configuration"),
)
list_oneof_instance.append(oneof_instance)
if list_oneof_instance:
oneof_instances.append(list_oneof_instance)
oneof_instances.append(list_oneof_instance)
elif inspect.isclass(oneof_class) and issubclass(oneof_class, ModelSimple):
# Handle list of ModelSimple
for arg in model_arg:
oneof_instance = oneof_class(arg, **constant_kwargs)
if not oneof_instance._unparsed:
list_oneof_instance.append(oneof_instance)
if list_oneof_instance:
list_oneof_instance = [oneof_class(arg, **constant_kwargs) for arg in model_arg]
if not any(item._unparsed for item in list_oneof_instance):
oneof_instances.append(list_oneof_instance)
else:
# Handle list of complex objects (ModelNormal, ModelComposed)
# Handle list of complex objects (ModelNormal, ModelComposed).
# Mapping items are unpacked as keyword arguments; scalar items
# are passed positionally so a composed item schema that accepts
# primitives (e.g. AnyValueItem) can resolve them.
spec_property_naming = constant_kwargs.get("_spec_property_naming")
list_oneof_instance = []
for arg in model_arg:
if constant_kwargs.get("_spec_property_naming"):
oneof_instance = oneof_class(
**change_keys_js_to_python(arg, oneof_class), **constant_kwargs
)
if not isinstance(arg, dict):
item = oneof_class(arg, **constant_kwargs)
elif spec_property_naming:
item = oneof_class(**change_keys_js_to_python(arg, oneof_class), **constant_kwargs)
else:
oneof_instance = oneof_class(**arg, **constant_kwargs)
if not oneof_instance._unparsed:
list_oneof_instance.append(oneof_instance)
if list_oneof_instance:
item = oneof_class(**arg, **constant_kwargs)
list_oneof_instance.append(item)
if not any(getattr(item, "_unparsed", False) for item in list_oneof_instance):
oneof_instances.append(list_oneof_instance)
elif issubclass(oneof_class, ModelSimple):
if model_arg is not None:
Expand Down
46 changes: 26 additions & 20 deletions src/datadog_api_client/model_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ def allows_single_value_input(cls):
elif issubclass(cls, ModelComposed):
if not cls._composed_schemas["oneOf"]:
return False
return any(allows_single_value_input(c) for c in cls._composed_schemas["oneOf"] if not isinstance(c, list))
return any(isinstance(c, list) or allows_single_value_input(c) for c in cls._composed_schemas["oneOf"])
Comment thread
ddadamhooper marked this conversation as resolved.
return False


Expand Down Expand Up @@ -181,7 +181,7 @@ def set_attribute(self, name, value):
)
if isinstance(value, list):
for x in value:
if isinstance(x, UnparsedObject):
if isinstance(x, UnparsedObject) or (isinstance(x, OpenApiModel) and x._unparsed):
self._unparsed = True
if name in self.validations:
check_validations(self.validations[name], name, value, self._configuration)
Expand Down Expand Up @@ -1676,6 +1676,9 @@ def get_oneof_instance(cls, model_kwargs, constant_kwargs, model_arg=None):

with suppress(Exception):
if not single_value_input:
if model_arg is not None and not isinstance(model_arg, dict):
# A non-mapping input (list or scalar) cannot match an object schema.
continue
if constant_kwargs.get("_spec_property_naming"):
oneof_instance = oneof_class(
**change_keys_js_to_python(model_kwargs, oneof_class), **constant_kwargs
Expand All @@ -1687,16 +1690,19 @@ def get_oneof_instance(cls, model_kwargs, constant_kwargs, model_arg=None):
else:
if isinstance(oneof_class, list):
oneof_class = oneof_class[0]
list_oneof_instance = []
if model_arg is None and not model_kwargs:
# Empty data
oneof_instances.append(list_oneof_instance)
oneof_instances.append([])
continue
if not isinstance(model_arg, list):
# A non-array input cannot match a list schema.
continue

# Check if inner type is primitive - follows same pattern as complex objects:
# https://github.com/DataDog/datadog-api-client-python/blob/008536d34760ce096e118edc54df613d82194529/.generator/src/generator/templates/model_utils.j2#L1590-L1599
if oneof_class in PRIMITIVE_TYPES:
# Handle list of primitives (e.g., [str], [float])
list_oneof_instance = []
for arg in model_arg:
oneof_instance = validate_and_convert_types(
arg,
Expand All @@ -1707,28 +1713,28 @@ def get_oneof_instance(cls, model_kwargs, constant_kwargs, model_arg=None):
configuration=constant_kwargs.get("_configuration"),
)
list_oneof_instance.append(oneof_instance)
if list_oneof_instance:
oneof_instances.append(list_oneof_instance)
oneof_instances.append(list_oneof_instance)
elif inspect.isclass(oneof_class) and issubclass(oneof_class, ModelSimple):
# Handle list of ModelSimple
for arg in model_arg:
oneof_instance = oneof_class(arg, **constant_kwargs)
if not oneof_instance._unparsed:
list_oneof_instance.append(oneof_instance)
if list_oneof_instance:
list_oneof_instance = [oneof_class(arg, **constant_kwargs) for arg in model_arg]
if not any(item._unparsed for item in list_oneof_instance):
oneof_instances.append(list_oneof_instance)
else:
# Handle list of complex objects (ModelNormal, ModelComposed)
# Handle list of complex objects (ModelNormal, ModelComposed).
# Mapping items are unpacked as keyword arguments; scalar items
# are passed positionally so a composed item schema that accepts
# primitives (e.g. AnyValueItem) can resolve them.
spec_property_naming = constant_kwargs.get("_spec_property_naming")
list_oneof_instance = []
for arg in model_arg:
if constant_kwargs.get("_spec_property_naming"):
oneof_instance = oneof_class(
**change_keys_js_to_python(arg, oneof_class), **constant_kwargs
)
if not isinstance(arg, dict):
item = oneof_class(arg, **constant_kwargs)
elif spec_property_naming:
item = oneof_class(**change_keys_js_to_python(arg, oneof_class), **constant_kwargs)
else:
oneof_instance = oneof_class(**arg, **constant_kwargs)
if not oneof_instance._unparsed:
list_oneof_instance.append(oneof_instance)
if list_oneof_instance:
item = oneof_class(**arg, **constant_kwargs)
list_oneof_instance.append(item)
if not any(getattr(item, "_unparsed", False) for item in list_oneof_instance):
oneof_instances.append(list_oneof_instance)
elif issubclass(oneof_class, ModelSimple):
if model_arg is not None:
Expand Down
125 changes: 125 additions & 0 deletions tests/test_deserialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,21 @@
from datadog_api_client.v1.model.synthetics_api_test import SyntheticsAPITest
from datadog_api_client.v1.model.synthetics_browser_test import SyntheticsBrowserTest
from datadog_api_client.v1.model.synthetics_assertion import SyntheticsAssertion
from datadog_api_client.v2.model.any_value import AnyValue
from datadog_api_client.v2.model.downtime_response import DowntimeResponse
from datadog_api_client.v2.model.logs_aggregate_response import LogsAggregateResponse
from datadog_api_client.v2.model.logs_archive import LogsArchive
from datadog_api_client.v2.model.logs_archive_destination import LogsArchiveDestination
from datadog_api_client.v2.model.user_response import UserResponse
from datadog_api_client.v1.model.formula_and_function_event_query_definition import (
FormulaAndFunctionEventQueryDefinition,
)
from datadog_api_client.v1.model.formula_and_function_event_query_group_by import (
FormulaAndFunctionEventQueryGroupBy,
)
from datadog_api_client.v1.model.formula_and_function_event_query_group_by_config import (
FormulaAndFunctionEventQueryGroupByConfig,
)


def test_unknown_nested_oneof_in_list():
Expand Down Expand Up @@ -339,3 +349,118 @@ def test_schema_declared_float_still_upconverts_int_input():
an integer JSON value must still upconvert to float."""
converted = validate_and_convert_types(3, (float,), ["received_data"], True, True, Configuration())
assert type(converted) is float and converted == 3.0


def test_one_of_list_branch_rejected_when_element_unparsed():
"""A oneOf list branch must reject the whole value when any element fails to
parse, rather than silently dropping the bad element and accepting a
truncated list. ``FormulaAndFunctionEventQueryGroupByConfig`` is a oneOf
whose first branch is ``[FormulaAndFunctionEventQueryGroupBy]``."""
value = [
{"facet": "@geo.country_iso_code", "sort": {"aggregation": "count", "order": "desc"}},
# Unknown aggregation enum -> this element is unparsed.
{"facet": "@http.status_code", "sort": {"aggregation": "a non existent aggregation"}},
]
config = Configuration()
deserialized = validate_and_convert_types(
value, (FormulaAndFunctionEventQueryGroupByConfig,), ["received_data"], True, True, config
)
# The whole value is unparsed rather than a one-element list of the valid item.
assert isinstance(deserialized, UnparsedObject)


def test_one_of_list_branch_all_valid_yields_objects():
"""When every element of a oneOf list branch parses, the value deserializes
to a list of model objects (not raw dicts)."""
config = Configuration()

valid = [
{"facet": "@geo.country_iso_code", "limit": 250, "sort": {"aggregation": "count", "order": "desc"}},
]
group_by = validate_and_convert_types(
valid, (FormulaAndFunctionEventQueryGroupByConfig,), ["received_data"], True, True, config
)
assert isinstance(group_by, list)
assert isinstance(group_by[0], FormulaAndFunctionEventQueryGroupBy)
assert group_by[0].facet == "@geo.country_iso_code"
assert str(group_by[0].sort.order) == "desc"


def test_one_of_list_branch_unparsed_propagates_to_enclosing_model():
"""An unparsed element inside a oneOf list branch must propagate ``_unparsed``
up to the containing model, rather than being silently dropped so the model
looks fully parsed."""
config = Configuration()
body = {
"data_source": "logs",
"name": "my_query",
"compute": {"aggregation": "count"},
"group_by": [
{"facet": "@geo.country_iso_code", "sort": {"aggregation": "count", "order": "desc"}},
# Unknown aggregation enum -> this element is unparsed.
{"facet": "@http.status_code", "sort": {"aggregation": "a non existent aggregation"}},
],
}
definition = validate_and_convert_types(
body, (FormulaAndFunctionEventQueryDefinition,), ["received_data"], True, True, config
)
assert isinstance(definition, FormulaAndFunctionEventQueryDefinition)
assert definition._unparsed


def test_one_of_empty_list_branch_accepted():
"""An empty array for a oneOf list branch is valid: every element parses
vacuously, so the value deserializes to an empty list rather than falling
through to ``UnparsedObject`` and marking the enclosing model unparsed."""
config = Configuration()
group_by = validate_and_convert_types(
[], (FormulaAndFunctionEventQueryGroupByConfig,), ["received_data"], True, True, config
)
assert group_by == []


def test_one_of_empty_list_not_ambiguous_with_object_branch():
"""``AnyValue`` must match an empty array."""
config = Configuration()
value = validate_and_convert_types([], (AnyValue,), ["received_data"], True, True, config)
assert value == []
assert not isinstance(value, UnparsedObject)


def test_one_of_list_branch_of_composed_primitive_items():
"""``AnyValue`` has an ``[AnyValueItem]`` list branch whose item schema is a
``ModelComposed`` accepting primitives (string/number/object/bool). A JSON
array of scalars such as ``["hello", 1]`` must deserialize to that list
branch rather than falling through to ``UnparsedObject``."""
config = Configuration()
value = validate_and_convert_types(["hello", 1], (AnyValue,), ["received_data"], True, True, config)
assert not isinstance(value, UnparsedObject)
assert isinstance(value, list)
assert len(value) == 2
assert value[0] == "hello"
assert value[1] == 1.0


def test_any_value_scalar_branches():
"""``AnyValue`` must match each scalar oneOf branch without being confused by
the ``[AnyValueItem]`` list branch (a string is iterable but is not an array)."""
config = Configuration()
for raw, expected in (("hello", "hello"), (42, 42.0), (True, True)):
value = validate_and_convert_types(raw, (AnyValue,), ["received_data"], True, True, config)
assert not isinstance(value, UnparsedObject)
assert value == expected
obj = validate_and_convert_types({"a": 1}, (AnyValue,), ["received_data"], True, True, config)
assert not isinstance(obj, UnparsedObject)
assert model_to_dict(obj) == {"a": 1}


def test_any_value_mixed_array():
"""A mixed array of scalars and objects deserializes element-wise."""
config = Configuration()
value = validate_and_convert_types(["a", {"b": 2}, 3, False], (AnyValue,), ["received_data"], True, True, config)
assert not isinstance(value, UnparsedObject)
assert isinstance(value, list)
assert value[0] == "a"
assert model_to_dict(value[1]) == {"b": 2}
assert value[2] == 3.0
assert value[3] is False
Loading