diff --git a/.generator/src/generator/templates/model_utils.j2 b/.generator/src/generator/templates/model_utils.j2 index b0d701eaab..e091f010e8 100644 --- a/.generator/src/generator/templates/model_utils.j2 +++ b/.generator/src/generator/templates/model_utils.j2 @@ -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 @@ -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) @@ -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 isinstance(model_arg, list): + # A list input cannot match a non-single-value (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 @@ -1675,16 +1680,16 @@ 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 # 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, @@ -1695,28 +1700,22 @@ 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) - 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 - ) - else: - oneof_instance = oneof_class(**arg, **constant_kwargs) - if not oneof_instance._unparsed: - list_oneof_instance.append(oneof_instance) - if list_oneof_instance: + if constant_kwargs.get("_spec_property_naming"): + list_oneof_instance = [ + oneof_class(**change_keys_js_to_python(arg, oneof_class), **constant_kwargs) + for arg in model_arg + ] + else: + 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) elif issubclass(oneof_class, ModelSimple): if model_arg is not None: diff --git a/src/datadog_api_client/model_utils.py b/src/datadog_api_client/model_utils.py index 9f508e1f57..1152fc3628 100644 --- a/src/datadog_api_client/model_utils.py +++ b/src/datadog_api_client/model_utils.py @@ -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"]) return False @@ -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) @@ -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 isinstance(model_arg, list): + # A list input cannot match a non-single-value (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 @@ -1687,16 +1690,16 @@ 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 # 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, @@ -1707,28 +1710,22 @@ 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) - 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 - ) - else: - oneof_instance = oneof_class(**arg, **constant_kwargs) - if not oneof_instance._unparsed: - list_oneof_instance.append(oneof_instance) - if list_oneof_instance: + if constant_kwargs.get("_spec_property_naming"): + list_oneof_instance = [ + oneof_class(**change_keys_js_to_python(arg, oneof_class), **constant_kwargs) + for arg in model_arg + ] + else: + 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) elif issubclass(oneof_class, ModelSimple): if model_arg is not None: diff --git a/tests/test_deserialization.py b/tests/test_deserialization.py index 503bc0bdc4..422c439a9e 100644 --- a/tests/test_deserialization.py +++ b/tests/test_deserialization.py @@ -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(): @@ -339,3 +349,79 @@ 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)