Skip to content

add cache to optimize type checking #2737

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
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
23 changes: 22 additions & 1 deletion .generator/src/generator/templates/api_client.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down
174 changes: 129 additions & 45 deletions .generator/src/generator/templates/model_utils.j2
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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]
Expand All @@ -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
Expand All @@ -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):
Expand All @@ -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
Expand All @@ -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:
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand All @@ -1284,27 +1325,46 @@ 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,
configuration,
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:
Expand All @@ -1313,64 +1373,87 @@ 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 == []:
# allow an empty list
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


Expand Down Expand Up @@ -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:
Expand Down
Loading
Loading