diff --git a/.generator/src/generator/templates/api_client.j2 b/.generator/src/generator/templates/api_client.j2 index 77b7b93904..822a3292ab 100644 --- a/.generator/src/generator/templates/api_client.j2 +++ b/.generator/src/generator/templates/api_client.j2 @@ -50,6 +50,11 @@ class ApiClient: self.rest_client = self._build_rest_client() self.default_headers = {} + + # Cache for validation performance optimization - persists across requests + # Simple size limiting to prevent memory leaks + self._validation_cache = {} + self._validation_cache_max_size = 1000 # Configurable limit if self.configuration.compress: self.default_headers["Accept-Encoding"] = "gzip" # Set default User-Agent. @@ -178,8 +183,23 @@ class ApiClient: # store our data under the key of 'received_data' so users have some # context if they are deserializing a string and the data type is wrong + + # Use ApiClient's validation cache for performance optimization across requests + request_cache = self._validation_cache if check_type else None + + # Simple cache size limiting to prevent memory leaks + if request_cache is not None and len(request_cache) > self._validation_cache_max_size: + # Remove 25% of cache entries when full (keep most recent 75%) + items_to_keep = int(self._validation_cache_max_size * 0.75) + cache_items = list(request_cache.items()) + request_cache.clear() + # Keep the most recently added items (simple FIFO) + for key, value in cache_items[-items_to_keep:]: + request_cache[key] = value + deserialized_data = validate_and_convert_types( - received_data, response_type, ["received_data"], True, check_type, configuration=self.configuration + received_data, response_type, ["received_data"], True, check_type, + configuration=self.configuration, request_cache=request_cache ) return deserialized_data @@ -682,6 +702,7 @@ class Endpoint: self.api_client.configuration.spec_property_naming, self.api_client.configuration.check_input_type, configuration=self.api_client.configuration, + request_cache=None, # No cache available for input validation ) kwargs[key] = fixed_val diff --git a/.generator/src/generator/templates/model_utils.j2 b/.generator/src/generator/templates/model_utils.j2 index 52b627d78e..35dad4b99d 100644 --- a/.generator/src/generator/templates/model_utils.j2 +++ b/.generator/src/generator/templates/model_utils.j2 @@ -4,6 +4,7 @@ from contextlib import suppress from datetime import date, datetime from uuid import UUID import enum +from functools import lru_cache import inspect import io import os @@ -28,6 +29,24 @@ file_type = io.IOBase empty_dict = MappingProxyType({}) # type: ignore +def _make_hashable(obj): + """Convert potentially unhashable objects to hashable representations for caching.""" + if isinstance(obj, (list, tuple)): + return tuple(_make_hashable(item) for item in obj) + elif isinstance(obj, dict): + return tuple(sorted((_make_hashable(k), _make_hashable(v)) for k, v in obj.items())) + elif isinstance(obj, set): + return tuple(sorted(_make_hashable(item) for item in obj)) + elif hasattr(obj, '__name__'): # Classes and functions + return obj.__name__ + else: + try: + hash(obj) + return obj + except TypeError: + return str(obj) + + class UnsetType(enum.Enum): unset = 0 @@ -146,6 +165,7 @@ class OpenApiModel: self._spec_property_naming, self._check_type, configuration=self._configuration, + request_cache=None, # No cache available in model __setattr__ ) if isinstance(value, list): for x in value: @@ -870,7 +890,6 @@ def order_response_types(required_types): of list or dict with class information inside it. :rtype: list """ - def index_getter(class_or_instance): if isinstance(class_or_instance, list): return COERCION_INDEX_BY_TYPE[list] @@ -887,31 +906,11 @@ def order_response_types(required_types): raise ApiValueError("Unsupported type: %s" % class_or_instance) sorted_types = sorted(required_types, key=index_getter) - return sorted_types - + return tuple(sorted_types) -def remove_uncoercible(required_types_classes, current_item, spec_property_naming, must_convert=True): - """Only keeps the type conversions that are possible. - - :param required_types_classes: Classes that are required, these should be - ordered by COERCION_INDEX_BY_TYPE. - :type required_types_classes: tuple - :param spec_property_naming: True if the variable names in the input data - are serialized names as specified in the OpenAPI document. False if the - variables names in the input data are python variable names in PEP-8 snake - case. - :type spec_property_naming: bool - :param current_item: The current item (input data) to be converted. - - :param must_convert: If True the item to convert is of the wrong type and - we want a big list of coercibles if False, we want a limited list of coercibles. - :type must_convert: bool - - :return: The remaining coercible required types, classes only. - :rtype: list - """ - current_type_simple = get_simple_class(current_item) +def _remove_uncoercible_impl(required_types_classes, current_type_simple, spec_property_naming, must_convert=True): + """Implementation of remove_uncoercible logic.""" results_classes = [] for required_type_class in required_types_classes: # convert our models to OpenApiModel @@ -933,7 +932,31 @@ def remove_uncoercible(required_types_classes, current_item, spec_property_namin results_classes.append(required_type_class) elif class_pair in UPCONVERSION_TYPE_PAIRS: results_classes.append(required_type_class) - return results_classes + return tuple(results_classes) + + +def remove_uncoercible(required_types_classes, current_item, spec_property_naming, must_convert=True): + """Only keeps the type conversions that are possible. + + :param required_types_classes: Classes that are required, these should be + ordered by COERCION_INDEX_BY_TYPE. + :type required_types_classes: tuple + :param spec_property_naming: True if the variable names in the input data + are serialized names as specified in the OpenAPI document. False if the + variables names in the input data are python variable names in PEP-8 snake + case. + :type spec_property_naming: bool + :param current_item: The current item (input data) to be converted. + + :param must_convert: If True the item to convert is of the wrong type and + we want a big list of coercibles if False, we want a limited list of coercibles. + :type must_convert: bool + + :return: The remaining coercible required types, classes only. + :rtype: list + """ + current_type_simple = get_simple_class(current_item) + return list(_remove_uncoercible_impl(required_types_classes, current_type_simple, spec_property_naming, must_convert)) def get_possible_classes(cls, from_server_context): @@ -945,7 +968,7 @@ def get_possible_classes(cls, from_server_context): return possible_classes -def get_required_type_classes(required_types_mixed, spec_property_naming): +def get_required_type_classes(required_types_mixed, spec_property_naming, request_cache=None): """Converts the tuple required_types into a tuple and a dict described below. :param required_types_mixed: Will contain either classes or instance of @@ -965,6 +988,23 @@ def get_required_type_classes(required_types_mixed, spec_property_naming): :rtype: tuple """ + # PERFORMANCE: Cache expensive type class computation within request + if request_cache is not None: + cache_key = ('get_required_type_classes', _make_hashable(required_types_mixed), spec_property_naming) + if cache_key in request_cache: + return request_cache[cache_key] + else: + cache_key = None + + result = _get_required_type_classes_impl(required_types_mixed, spec_property_naming) + + if cache_key and request_cache is not None: + request_cache[cache_key] = result + return result + + +def _get_required_type_classes_impl(required_types_mixed, spec_property_naming): + """Implementation of get_required_type_classes without caching.""" valid_classes = [] child_req_types_by_current_type = {} for required_type in required_types_mixed: @@ -1164,6 +1204,7 @@ def attempt_convert_item( key_type=False, must_convert=False, check_type=True, + request_cache=None, ): """ :param input_value: The data to convert. @@ -1262,7 +1303,7 @@ def is_valid_type(input_class_simple, valid_classes): def validate_and_convert_types( - input_value, required_types_mixed, path_to_item, spec_property_naming, check_type, configuration=None + input_value, required_types_mixed, path_to_item, spec_property_naming, check_type, configuration=None, request_cache=None ): """Raises a TypeError is there is a problem, otherwise returns value. @@ -1284,19 +1325,34 @@ def validate_and_convert_types( :param configuration:: The configuration class to use when converting file_type items. :type configuration: Configuration + :param request_cache: Optional cache dict for storing validation results + within a single request to avoid redundant validations. + :type request_cache: dict :return: The correctly typed value. :raise: ApiTypeError """ - results = get_required_type_classes(required_types_mixed, spec_property_naming) + # Per-request caching: Cache validation results within a single request + cache_key = None + if request_cache is not None: + try: + input_hash = _make_hashable(input_value) + cache_key = (input_hash, _make_hashable(required_types_mixed), tuple(path_to_item), spec_property_naming, check_type) + if cache_key in request_cache: + return request_cache[cache_key] + except (TypeError, AttributeError): + # If we can't create a cache key, proceed without caching + cache_key = None + + results = get_required_type_classes(required_types_mixed, spec_property_naming, request_cache) valid_classes, child_req_types_by_current_type = results input_class_simple = get_simple_class(input_value) valid_type = is_valid_type(input_class_simple, valid_classes) if not valid_type: # if input_value is not valid_type try to convert it - return attempt_convert_item( + result = attempt_convert_item( input_value, valid_classes, path_to_item, @@ -1304,7 +1360,11 @@ def validate_and_convert_types( spec_property_naming, must_convert=True, check_type=check_type, + request_cache=request_cache, ) + if cache_key and request_cache is not None: + request_cache[cache_key] = result + return result # input_value's type is in valid_classes if len(valid_classes) > 1 and configuration: @@ -1313,22 +1373,30 @@ def validate_and_convert_types( valid_classes, input_value, spec_property_naming, must_convert=False ) if valid_classes_coercible: - return attempt_convert_item( + result = attempt_convert_item( input_value, valid_classes_coercible, path_to_item, configuration, spec_property_naming, check_type=check_type, + request_cache=request_cache, ) + if cache_key and request_cache is not None: + request_cache[cache_key] = result + return result if child_req_types_by_current_type == {}: # all types are of the required types and there are no more inner # variables left to look at + if cache_key and request_cache is not None: + request_cache[cache_key] = input_value return input_value inner_required_types = child_req_types_by_current_type.get(type(input_value)) if inner_required_types is None: # for this type, there are not more inner variables left to look at + if cache_key and request_cache is not None: + request_cache[cache_key] = input_value return input_value if isinstance(input_value, list): if input_value == []: @@ -1336,41 +1404,56 @@ def validate_and_convert_types( return input_value result = [] for index, inner_value in enumerate(input_value): - inner_path = list(path_to_item) - inner_path.append(index) + path_to_item.append(index) try: result.append( validate_and_convert_types( inner_value, inner_required_types, - inner_path, + path_to_item, spec_property_naming, check_type, configuration=configuration, + request_cache=request_cache, ) ) except TypeError: result.append(UnparsedObject(**inner_value)) + finally: + # Restore path state + path_to_item.pop() + if cache_key and request_cache is not None: + request_cache[cache_key] = result return result elif isinstance(input_value, dict): if input_value == {}: # allow an empty dict + if cache_key and request_cache is not None: + request_cache[cache_key] = input_value return input_value result = {} for inner_key, inner_val in input_value.items(): - inner_path = list(path_to_item) - inner_path.append(inner_key) - if get_simple_class(inner_key) != str: - raise get_type_error(inner_key, inner_path, valid_classes, key_type=True) - result[inner_key] = validate_and_convert_types( - inner_val, - inner_required_types, - inner_path, - spec_property_naming, - check_type, - configuration=configuration, - ) + path_to_item.append(inner_key) + try: + if get_simple_class(inner_key) != str: + raise get_type_error(inner_key, path_to_item, valid_classes, key_type=True) + result[inner_key] = validate_and_convert_types( + inner_val, + inner_required_types, + path_to_item, + spec_property_naming, + check_type, + configuration=configuration, + request_cache=request_cache, + ) + finally: + # Restore path state + path_to_item.pop() + if cache_key and request_cache is not None: + request_cache[cache_key] = result return result + if cache_key and request_cache is not None: + request_cache[cache_key] = input_value return input_value @@ -1581,6 +1664,7 @@ def get_oneof_instance(cls, model_kwargs, constant_kwargs, model_arg=None): constant_kwargs.get("_spec_property_naming", False), constant_kwargs.get("_check_type", True), configuration=constant_kwargs.get("_configuration"), + request_cache=None, # No cache available in this context ) oneof_instances.append(oneof_instance) if len(oneof_instances) != 1: diff --git a/src/datadog_api_client/api_client.py b/src/datadog_api_client/api_client.py index d9794ede9c..8fd2c59585 100644 --- a/src/datadog_api_client/api_client.py +++ b/src/datadog_api_client/api_client.py @@ -52,6 +52,11 @@ def __init__(self, configuration: Configuration): self.rest_client = self._build_rest_client() self.default_headers = {} + + # Cache for validation performance optimization - persists across requests + # Simple size limiting to prevent memory leaks + self._validation_cache = {} + self._validation_cache_max_size = 1000 # Configurable limit if self.configuration.compress: self.default_headers["Accept-Encoding"] = "gzip" # Set default User-Agent. @@ -180,8 +185,28 @@ def deserialize(self, response_data: str, response_type: Any, check_type: Option # store our data under the key of 'received_data' so users have some # context if they are deserializing a string and the data type is wrong + + # Use ApiClient's validation cache for performance optimization across requests + request_cache = self._validation_cache if check_type else None + + # Simple cache size limiting to prevent memory leaks + if request_cache is not None and len(request_cache) > self._validation_cache_max_size: + # Remove 25% of cache entries when full (keep most recent 75%) + items_to_keep = int(self._validation_cache_max_size * 0.75) + cache_items = list(request_cache.items()) + request_cache.clear() + # Keep the most recently added items (simple FIFO) + for key, value in cache_items[-items_to_keep:]: + request_cache[key] = value + deserialized_data = validate_and_convert_types( - received_data, response_type, ["received_data"], True, check_type, configuration=self.configuration + received_data, + response_type, + ["received_data"], + True, + check_type, + configuration=self.configuration, + request_cache=request_cache, ) return deserialized_data @@ -680,6 +705,7 @@ def _validate_inputs(self, kwargs): self.api_client.configuration.spec_property_naming, self.api_client.configuration.check_input_type, configuration=self.api_client.configuration, + request_cache=None, # No cache available for input validation ) kwargs[key] = fixed_val diff --git a/src/datadog_api_client/model_utils.py b/src/datadog_api_client/model_utils.py index b76c1b18a6..84dbb7dc11 100644 --- a/src/datadog_api_client/model_utils.py +++ b/src/datadog_api_client/model_utils.py @@ -30,6 +30,24 @@ empty_dict = MappingProxyType({}) # type: ignore +def _make_hashable(obj): + """Convert potentially unhashable objects to hashable representations for caching.""" + if isinstance(obj, (list, tuple)): + return tuple(_make_hashable(item) for item in obj) + elif isinstance(obj, dict): + return tuple(sorted((_make_hashable(k), _make_hashable(v)) for k, v in obj.items())) + elif isinstance(obj, set): + return tuple(sorted(_make_hashable(item) for item in obj)) + elif hasattr(obj, "__name__"): # Classes and functions + return obj.__name__ + else: + try: + hash(obj) + return obj + except TypeError: + return str(obj) + + class UnsetType(enum.Enum): unset = 0 @@ -159,6 +177,7 @@ def set_attribute(self, name, value): self._spec_property_naming, self._check_type, configuration=self._configuration, + request_cache=None, # No cache available in model __setattr__ ) if isinstance(value, list): for x in value: @@ -901,31 +920,11 @@ def index_getter(class_or_instance): raise ApiValueError("Unsupported type: %s" % class_or_instance) sorted_types = sorted(required_types, key=index_getter) - return sorted_types - - -def remove_uncoercible(required_types_classes, current_item, spec_property_naming, must_convert=True): - """Only keeps the type conversions that are possible. - - :param required_types_classes: Classes that are required, these should be - ordered by COERCION_INDEX_BY_TYPE. - :type required_types_classes: tuple - :param spec_property_naming: True if the variable names in the input data - are serialized names as specified in the OpenAPI document. False if the - variables names in the input data are python variable names in PEP-8 snake - case. - :type spec_property_naming: bool - :param current_item: The current item (input data) to be converted. + return tuple(sorted_types) - :param must_convert: If True the item to convert is of the wrong type and - we want a big list of coercibles if False, we want a limited list of coercibles. - :type must_convert: bool - - :return: The remaining coercible required types, classes only. - :rtype: list - """ - current_type_simple = get_simple_class(current_item) +def _remove_uncoercible_impl(required_types_classes, current_type_simple, spec_property_naming, must_convert=True): + """Implementation of remove_uncoercible logic.""" results_classes = [] for required_type_class in required_types_classes: # convert our models to OpenApiModel @@ -947,7 +946,33 @@ def remove_uncoercible(required_types_classes, current_item, spec_property_namin results_classes.append(required_type_class) elif class_pair in UPCONVERSION_TYPE_PAIRS: results_classes.append(required_type_class) - return results_classes + return tuple(results_classes) + + +def remove_uncoercible(required_types_classes, current_item, spec_property_naming, must_convert=True): + """Only keeps the type conversions that are possible. + + :param required_types_classes: Classes that are required, these should be + ordered by COERCION_INDEX_BY_TYPE. + :type required_types_classes: tuple + :param spec_property_naming: True if the variable names in the input data + are serialized names as specified in the OpenAPI document. False if the + variables names in the input data are python variable names in PEP-8 snake + case. + :type spec_property_naming: bool + :param current_item: The current item (input data) to be converted. + + :param must_convert: If True the item to convert is of the wrong type and + we want a big list of coercibles if False, we want a limited list of coercibles. + :type must_convert: bool + + :return: The remaining coercible required types, classes only. + :rtype: list + """ + current_type_simple = get_simple_class(current_item) + return list( + _remove_uncoercible_impl(required_types_classes, current_type_simple, spec_property_naming, must_convert) + ) def get_possible_classes(cls, from_server_context): @@ -959,7 +984,7 @@ def get_possible_classes(cls, from_server_context): return possible_classes -def get_required_type_classes(required_types_mixed, spec_property_naming): +def get_required_type_classes(required_types_mixed, spec_property_naming, request_cache=None): """Converts the tuple required_types into a tuple and a dict described below. :param required_types_mixed: Will contain either classes or instance of @@ -979,6 +1004,23 @@ def get_required_type_classes(required_types_mixed, spec_property_naming): :rtype: tuple """ + # PERFORMANCE: Cache expensive type class computation within request + if request_cache is not None: + cache_key = ("get_required_type_classes", _make_hashable(required_types_mixed), spec_property_naming) + if cache_key in request_cache: + return request_cache[cache_key] + else: + cache_key = None + + result = _get_required_type_classes_impl(required_types_mixed, spec_property_naming) + + if cache_key and request_cache is not None: + request_cache[cache_key] = result + return result + + +def _get_required_type_classes_impl(required_types_mixed, spec_property_naming): + """Implementation of get_required_type_classes without caching.""" valid_classes = [] child_req_types_by_current_type = {} for required_type in required_types_mixed: @@ -1178,6 +1220,7 @@ def attempt_convert_item( key_type=False, must_convert=False, check_type=True, + request_cache=None, ): """ :param input_value: The data to convert. @@ -1276,7 +1319,13 @@ def is_valid_type(input_class_simple, valid_classes): def validate_and_convert_types( - input_value, required_types_mixed, path_to_item, spec_property_naming, check_type, configuration=None + input_value, + required_types_mixed, + path_to_item, + spec_property_naming, + check_type, + configuration=None, + request_cache=None, ): """Raises a TypeError is there is a problem, otherwise returns value. @@ -1298,19 +1347,40 @@ def validate_and_convert_types( :param configuration:: The configuration class to use when converting file_type items. :type configuration: Configuration + :param request_cache: Optional cache dict for storing validation results + within a single request to avoid redundant validations. + :type request_cache: dict :return: The correctly typed value. :raise: ApiTypeError """ - results = get_required_type_classes(required_types_mixed, spec_property_naming) + # Per-request caching: Cache validation results within a single request + cache_key = None + if request_cache is not None: + try: + input_hash = _make_hashable(input_value) + cache_key = ( + input_hash, + _make_hashable(required_types_mixed), + tuple(path_to_item), + spec_property_naming, + check_type, + ) + if cache_key in request_cache: + return request_cache[cache_key] + except (TypeError, AttributeError): + # If we can't create a cache key, proceed without caching + cache_key = None + + results = get_required_type_classes(required_types_mixed, spec_property_naming, request_cache) valid_classes, child_req_types_by_current_type = results input_class_simple = get_simple_class(input_value) valid_type = is_valid_type(input_class_simple, valid_classes) if not valid_type: # if input_value is not valid_type try to convert it - return attempt_convert_item( + result = attempt_convert_item( input_value, valid_classes, path_to_item, @@ -1318,7 +1388,11 @@ def validate_and_convert_types( spec_property_naming, must_convert=True, check_type=check_type, + request_cache=request_cache, ) + if cache_key and request_cache is not None: + request_cache[cache_key] = result + return result # input_value's type is in valid_classes if len(valid_classes) > 1 and configuration: @@ -1327,22 +1401,30 @@ def validate_and_convert_types( valid_classes, input_value, spec_property_naming, must_convert=False ) if valid_classes_coercible: - return attempt_convert_item( + result = attempt_convert_item( input_value, valid_classes_coercible, path_to_item, configuration, spec_property_naming, check_type=check_type, + request_cache=request_cache, ) + if cache_key and request_cache is not None: + request_cache[cache_key] = result + return result if child_req_types_by_current_type == {}: # all types are of the required types and there are no more inner # variables left to look at + if cache_key and request_cache is not None: + request_cache[cache_key] = input_value return input_value inner_required_types = child_req_types_by_current_type.get(type(input_value)) if inner_required_types is None: # for this type, there are not more inner variables left to look at + if cache_key and request_cache is not None: + request_cache[cache_key] = input_value return input_value if isinstance(input_value, list): if input_value == []: @@ -1350,41 +1432,56 @@ def validate_and_convert_types( return input_value result = [] for index, inner_value in enumerate(input_value): - inner_path = list(path_to_item) - inner_path.append(index) + path_to_item.append(index) try: result.append( validate_and_convert_types( inner_value, inner_required_types, - inner_path, + path_to_item, spec_property_naming, check_type, configuration=configuration, + request_cache=request_cache, ) ) except TypeError: result.append(UnparsedObject(**inner_value)) + finally: + # Restore path state + path_to_item.pop() + if cache_key and request_cache is not None: + request_cache[cache_key] = result return result elif isinstance(input_value, dict): if input_value == {}: # allow an empty dict + if cache_key and request_cache is not None: + request_cache[cache_key] = input_value return input_value result = {} for inner_key, inner_val in input_value.items(): - inner_path = list(path_to_item) - inner_path.append(inner_key) - if get_simple_class(inner_key) != str: - raise get_type_error(inner_key, inner_path, valid_classes, key_type=True) - result[inner_key] = validate_and_convert_types( - inner_val, - inner_required_types, - inner_path, - spec_property_naming, - check_type, - configuration=configuration, - ) + path_to_item.append(inner_key) + try: + if get_simple_class(inner_key) != str: + raise get_type_error(inner_key, path_to_item, valid_classes, key_type=True) + result[inner_key] = validate_and_convert_types( + inner_val, + inner_required_types, + path_to_item, + spec_property_naming, + check_type, + configuration=configuration, + request_cache=request_cache, + ) + finally: + # Restore path state + path_to_item.pop() + if cache_key and request_cache is not None: + request_cache[cache_key] = result return result + if cache_key and request_cache is not None: + request_cache[cache_key] = input_value return input_value @@ -1595,6 +1692,7 @@ def get_oneof_instance(cls, model_kwargs, constant_kwargs, model_arg=None): constant_kwargs.get("_spec_property_naming", False), constant_kwargs.get("_check_type", True), configuration=constant_kwargs.get("_configuration"), + request_cache=None, # No cache available in this context ) oneof_instances.append(oneof_instance) if len(oneof_instances) != 1: