diff --git a/packages/reflex-base/news/+bounded-mapping-inference.performance.md b/packages/reflex-base/news/+bounded-mapping-inference.performance.md new file mode 100644 index 00000000000..34095d8f693 --- /dev/null +++ b/packages/reflex-base/news/+bounded-mapping-inference.performance.md @@ -0,0 +1 @@ +Avoid copying entire mappings when sampling their key and value types during compilation. diff --git a/packages/reflex-base/src/reflex_base/vars/base.py b/packages/reflex-base/src/reflex_base/vars/base.py index 7c6fd796278..18685b4780a 100644 --- a/packages/reflex-base/src/reflex_base/vars/base.py +++ b/packages/reflex-base/src/reflex_base/vars/base.py @@ -21,6 +21,7 @@ from collections.abc import Callable, Coroutine, Iterable, Iterator, Mapping, Sequence from dataclasses import MISSING from decimal import Decimal +from itertools import islice from types import CodeType, FunctionType from typing import ( TYPE_CHECKING, @@ -2239,8 +2240,8 @@ def figure_out_type(value: Any) -> types.GenericType: if not value: return Mapping[NoReturn, NoReturn] return Mapping[ - unionize(*{figure_out_type(k) for k in list(value.keys())[:100]}), - unionize(*{figure_out_type(v) for v in list(value.values())[:100]}), + unionize(*{figure_out_type(k) for k in islice(value.keys(), 100)}), + unionize(*{figure_out_type(v) for v in islice(value.values(), 100)}), ] return type(value) diff --git a/tests/benchmarks/test_type_inference.py b/tests/benchmarks/test_type_inference.py new file mode 100644 index 00000000000..553cb0cb210 --- /dev/null +++ b/tests/benchmarks/test_type_inference.py @@ -0,0 +1,17 @@ +"""Benchmarks for inference from container values.""" + +import pytest +from pytest_codspeed import BenchmarkFixture +from reflex_base.vars.base import figure_out_type + + +@pytest.mark.parametrize("size", [100, 10_000, 100_000]) +def test_mapping_type_inference(benchmark: BenchmarkFixture, size: int): + """Infer a mapping's type without work proportional to its size. + + Args: + benchmark: The benchmark fixture. + size: The number of entries in the mapping. + """ + value = {str(index): index for index in range(size)} + benchmark(figure_out_type, value) diff --git a/tests/units/reflex_base/vars/test_base.py b/tests/units/reflex_base/vars/test_base.py index 53b93d996ce..41c5742bcd9 100644 --- a/tests/units/reflex_base/vars/test_base.py +++ b/tests/units/reflex_base/vars/test_base.py @@ -15,7 +15,7 @@ import typing import weakref from abc import ABC, abstractmethod -from collections.abc import Iterable, Iterator, Sequence +from collections.abc import Iterable, Iterator, Mapping, Sequence from pathlib import Path from typing import Any, ClassVar, Literal, Self, TypeVar from unittest import mock @@ -57,6 +57,7 @@ cached_property_no_lock, computed_var, field, + figure_out_type, get_leaf_vars, var_operation, var_operation_return, @@ -90,6 +91,40 @@ _MARKER_ATTR = "_marker" +@pytest.mark.parametrize("size", [0, 1, 100, 101, 1000]) +def test_mapping_type_inference_bounds_iteration(size): + """Sampling does not iterate or retrieve values beyond the first 100 items.""" + keys_seen = [] + values_seen = [] + + class CountingMapping(Mapping): + def __len__(self): + return size + + def __iter__(self): + for index in range(size): + keys_seen.append(index) + yield index + + def __getitem__(self, key): + values_seen.append(key) + return str(key) + + expected = Mapping[int, str] if size else Mapping[typing.NoReturn, typing.NoReturn] + assert figure_out_type(CountingMapping()) == expected + sampled = list(range(min(size, 100))) + assert keys_seen == sampled * 2 + assert values_seen == sampled + + +def test_mapping_type_inference_preserves_sample_boundary(): + """The hundredth item contributes types, while later items do not.""" + value: dict[Any, Any] = dict.fromkeys(range(99), 1) + value["last sampled"] = "included" + value[1.5] = ["not sampled"] + assert figure_out_type(value) == Mapping[int | str, int | str] + + def test_custom_field_attr_survives_annotated_rebuild(): """A custom attribute on an annotated Field survives a rebuild.""" f = field("x")