From 3538fdcaa48d85faddb276a63e62f044d62b8368 Mon Sep 17 00:00:00 2001 From: Hernan Grecco Date: Sat, 15 Jul 2023 13:18:41 -0300 Subject: [PATCH 1/8] Add cache to parse_unit_name --- pint/compat.py | 24 ++++++++++++++++++ pint/facets/context/registry.py | 11 +++++++-- pint/facets/plain/__init__.py | 9 ++++++- pint/facets/plain/registry.py | 44 +++++++++++++++++++++++++++------ 4 files changed, 77 insertions(+), 11 deletions(-) diff --git a/pint/compat.py b/pint/compat.py index 6be906f4d..a67bf20a7 100644 --- a/pint/compat.py +++ b/pint/compat.py @@ -14,12 +14,14 @@ import math import tokenize from decimal import Decimal +import functools from importlib import import_module from io import BytesIO from numbers import Number from collections.abc import Mapping from typing import Any, NoReturn, Callable, Optional, Union from collections.abc import Generator, Iterable +import warnings if sys.version_info >= (3, 10): @@ -362,3 +364,25 @@ def zero_or_nan(obj: Any, check_all: bool) -> Union[bool, Iterable[bool]]: if check_all and is_duck_array_type(type(out)): return out.all() return out + + +def deprecated(msg: str): + def _inner(func: Callable[..., Any]): + """This is a decorator which can be used to mark functions + as deprecated. It will result in a warning being emitted + when the function is used.""" + + @functools.wraps(func) + def _new_func(*args: Any, **kwargs: Any): + warnings.simplefilter("always", DeprecationWarning) # turn off filter + warnings.warn( + f"Call to deprecated function {func.__name__}.\n{msg}", + category=DeprecationWarning, + stacklevel=2, + ) + warnings.simplefilter("default", DeprecationWarning) # reset filter + return func(*args, **kwargs) + + return _new_func + + return _inner diff --git a/pint/facets/context/registry.py b/pint/facets/context/registry.py index 85682d198..18a024077 100644 --- a/pint/facets/context/registry.py +++ b/pint/facets/context/registry.py @@ -17,7 +17,13 @@ from ..._typing import F, Magnitude from ...errors import UndefinedUnitError from ...util import find_connected_nodes, find_shortest_path, logger, UnitsContainer -from ..plain import GenericPlainRegistry, UnitDefinition, QuantityT, UnitT +from ..plain import ( + GenericPlainRegistry, + UnitDefinition, + QuantityT, + UnitT, + RegistryCache, +) from .definitions import ContextDefinition from . import objects @@ -30,11 +36,12 @@ class ContextCacheOverlay: active contexts which contain unit redefinitions. """ - def __init__(self, registry_cache) -> None: + def __init__(self, registry_cache: RegistryCache) -> None: self.dimensional_equivalents = registry_cache.dimensional_equivalents self.root_units = {} self.dimensionality = registry_cache.dimensionality self.parse_unit = registry_cache.parse_unit + self.parse_unit_name = registry_cache.parse_unit_name class GenericContextRegistry( diff --git a/pint/facets/plain/__init__.py b/pint/facets/plain/__init__.py index 90bf2e35a..742ee3cf9 100644 --- a/pint/facets/plain/__init__.py +++ b/pint/facets/plain/__init__.py @@ -19,7 +19,13 @@ UnitDefinition, ) from .objects import PlainQuantity, PlainUnit -from .registry import PlainRegistry, GenericPlainRegistry, QuantityT, UnitT +from .registry import ( + PlainRegistry, + GenericPlainRegistry, + QuantityT, + UnitT, + RegistryCache, +) from .quantity import MagnitudeT __all__ = [ @@ -27,6 +33,7 @@ "PlainUnit", "PlainQuantity", "PlainRegistry", + "RegistryCache", "AliasDefinition", "DefaultsDefinition", "DimensionDefinition", diff --git a/pint/facets/plain/registry.py b/pint/facets/plain/registry.py index fb7797d6c..e39c4b75c 100644 --- a/pint/facets/plain/registry.py +++ b/pint/facets/plain/registry.py @@ -9,7 +9,7 @@ - parse_unit_name: Parse a unit to identify prefix, unit name and suffix by walking the list of prefix and suffix. - Result is cached: NO + Result is cached: YES - parse_units: Parse a units expression and returns a UnitContainer with the canonical names. The expression can only contain products, ratios and powers of units; @@ -131,6 +131,11 @@ def __init__(self) -> None: #: Cache the unit name associated to user input. ('mV' -> 'millivolt') self.parse_unit: dict[str, UnitsContainer] = {} + #: Maps (string and case insensitive) to (prefix, unit name, suffix) + self.parse_unit_name: dict[ + tuple[str, bool], tuple[tuple[str, str, str], ...] + ] = {} + def __eq__(self, other: Any): if not isinstance(other, self.__class__): return False @@ -139,6 +144,7 @@ def __eq__(self, other: Any): "root_units", "dimensionality", "parse_unit", + "parse_unit_name", ) return all(getattr(self, attr) == getattr(other, attr) for attr in attrs) @@ -1040,8 +1046,14 @@ def _convert( return value + # @deprecated("Use parse_single_unit") def parse_unit_name( self, unit_name: str, case_sensitive: Optional[bool] = None + ) -> tuple[tuple[str, str, str], ...]: + return self.parse_single_unit(unit_name, case_sensitive) + + def parse_single_unit( + self, unit_name: str, case_sensitive: Optional[bool] = None ) -> tuple[tuple[str, str, str], ...]: """Parse a unit to identify prefix, unit name and suffix by walking the list of prefix and suffix. @@ -1061,17 +1073,33 @@ def parse_unit_name( tuple of tuples (str, str, str) all non-equivalent combinations of (prefix, unit name, suffix) """ - return self._dedup_candidates( - self._parse_unit_name(unit_name, case_sensitive=case_sensitive) - ) - def _parse_unit_name( - self, unit_name: str, case_sensitive: Optional[bool] = None - ) -> Generator[tuple[str, str, str], None, None]: - """Helper of parse_unit_name.""" case_sensitive = ( self.case_sensitive if case_sensitive is None else case_sensitive ) + + return self._parse_single_unit(unit_name, case_sensitive) + + def _parse_single_unit( + self, unit_name: str, case_sensitive: bool + ) -> tuple[tuple[str, str, str], ...]: + """Helper of parse_unit_name.""" + + key = (unit_name, case_sensitive) + this_cache = self._cache.parse_unit_name + if key in this_cache: + return this_cache[key] + + out = this_cache[key] = self._dedup_candidates( + self._yield_potential_units(unit_name, case_sensitive=case_sensitive) + ) + return out + + def _yield_potential_units( + self, unit_name: str, case_sensitive: bool + ) -> Generator[tuple[str, str, str], None, None]: + """Helper of parse_unit_name.""" + stw = unit_name.startswith edw = unit_name.endswith for suffix, prefix in itertools.product(self._suffixes, self._prefixes): From b4048cd39ab4eb482243f195adc580c5125d8b66 Mon Sep 17 00:00:00 2001 From: Hernan Grecco Date: Sat, 15 Jul 2023 14:40:04 -0300 Subject: [PATCH 2/8] Use parse_single_unit instead of parse_unit_name in benchmark (but keep benchmark name) --- pint/testsuite/benchmarks/test_10_registry.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pint/testsuite/benchmarks/test_10_registry.py b/pint/testsuite/benchmarks/test_10_registry.py index ec0a43429..073bc9230 100644 --- a/pint/testsuite/benchmarks/test_10_registry.py +++ b/pint/testsuite/benchmarks/test_10_registry.py @@ -71,8 +71,8 @@ def test_getitem(benchmark, setup: SetupType, key: str, pre_run: bool): def test_parse_unit_name(benchmark, setup: SetupType, key: str, pre_run: bool): ureg, _ = setup if pre_run: - no_benchmark(ureg.parse_unit_name, key) - benchmark(ureg.parse_unit_name, key) + no_benchmark(ureg.parse_single_unit, key) + benchmark(ureg.parse_single_unit, key) @pytest.mark.parametrize("key", UNITS) From f23e451c0b64200aa7e959ca525abf2b3d9ece52 Mon Sep 17 00:00:00 2001 From: Hernan Grecco Date: Sat, 15 Jul 2023 15:41:21 -0300 Subject: [PATCH 3/8] Migrate parse_single_unit to functools.cache --- pint/facets/context/registry.py | 1 - pint/facets/plain/registry.py | 15 ++------------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/pint/facets/context/registry.py b/pint/facets/context/registry.py index 18a024077..540481567 100644 --- a/pint/facets/context/registry.py +++ b/pint/facets/context/registry.py @@ -41,7 +41,6 @@ def __init__(self, registry_cache: RegistryCache) -> None: self.root_units = {} self.dimensionality = registry_cache.dimensionality self.parse_unit = registry_cache.parse_unit - self.parse_unit_name = registry_cache.parse_unit_name class GenericContextRegistry( diff --git a/pint/facets/plain/registry.py b/pint/facets/plain/registry.py index e39c4b75c..903620b2e 100644 --- a/pint/facets/plain/registry.py +++ b/pint/facets/plain/registry.py @@ -131,11 +131,6 @@ def __init__(self) -> None: #: Cache the unit name associated to user input. ('mV' -> 'millivolt') self.parse_unit: dict[str, UnitsContainer] = {} - #: Maps (string and case insensitive) to (prefix, unit name, suffix) - self.parse_unit_name: dict[ - tuple[str, bool], tuple[tuple[str, str, str], ...] - ] = {} - def __eq__(self, other: Any): if not isinstance(other, self.__class__): return False @@ -144,7 +139,6 @@ def __eq__(self, other: Any): "root_units", "dimensionality", "parse_unit", - "parse_unit_name", ) return all(getattr(self, attr) == getattr(other, attr) for attr in attrs) @@ -1080,20 +1074,15 @@ def parse_single_unit( return self._parse_single_unit(unit_name, case_sensitive) + @functools.cache def _parse_single_unit( self, unit_name: str, case_sensitive: bool ) -> tuple[tuple[str, str, str], ...]: """Helper of parse_unit_name.""" - key = (unit_name, case_sensitive) - this_cache = self._cache.parse_unit_name - if key in this_cache: - return this_cache[key] - - out = this_cache[key] = self._dedup_candidates( + return self._dedup_candidates( self._yield_potential_units(unit_name, case_sensitive=case_sensitive) ) - return out def _yield_potential_units( self, unit_name: str, case_sensitive: bool From a92064ac735b87fb60939a8f0ded04bc163d6e02 Mon Sep 17 00:00:00 2001 From: Hernan Grecco Date: Sat, 15 Jul 2023 15:46:24 -0300 Subject: [PATCH 4/8] In _parse_units make as_delta and case_sensitive mandatory and pure boolean. Also make external uses of _parse_units to parse_units --- pint/facets/plain/registry.py | 48 +++++++++++++++++++++++++++++++++-- pint/util.py | 3 +-- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/pint/facets/plain/registry.py b/pint/facets/plain/registry.py index 903620b2e..e6573657e 100644 --- a/pint/facets/plain/registry.py +++ b/pint/facets/plain/registry.py @@ -1161,17 +1161,61 @@ def parse_units( """ + case_sensitive = ( + self.case_sensitive if case_sensitive is None else case_sensitive + ) + + as_delta = ( + getattr(self, "default_as_delta", True) if as_delta is None else as_delta + ) + units = self._parse_units(input_string, as_delta, case_sensitive) return self.Unit(units) - def _parse_units( + def parse_units_as_container( self, input_string: str, - as_delta: bool = True, + as_delta: Optional[bool] = None, case_sensitive: Optional[bool] = None, ) -> UnitsContainer: """Parse a units expression and returns a UnitContainer with the canonical names. + + The expression can only contain products, ratios and powers of units. + + Parameters + ---------- + input_string : str + as_delta : bool or None + if the expression has multiple units, the parser will + interpret non multiplicative units as their `delta_` counterparts. (Default value = None) + case_sensitive : bool or None + Control if unit parsing is case sensitive. Defaults to None, which uses the + registry's setting. + + Returns + ------- + pint.Unit + + """ + case_sensitive = ( + self.case_sensitive if case_sensitive is None else case_sensitive + ) + + as_delta = ( + getattr(self, "default_as_delta", True) if as_delta is None else as_delta + ) + + return self._parse_units(input_string, as_delta, case_sensitive) + + def _parse_units( + self, + input_string: str, + as_delta: bool, + case_sensitive: bool, + ) -> UnitsContainer: + """Parse a units expression and returns a UnitContainer with + the canonical names. """ cache = self._cache.parse_unit diff --git a/pint/util.py b/pint/util.py index e940ea6c2..eb48fd4fc 100644 --- a/pint/util.py +++ b/pint/util.py @@ -1039,8 +1039,7 @@ def to_units_container( return unit_like._units elif str in mro: if registry: - # TODO: Why not parse.units here? - return registry._parse_units(unit_like) + return registry.parse_units_as_container(unit_like) else: return ParserHelper.from_string(unit_like) elif dict in mro: From 964e7a58bc230e225fce398499b603c3c3fb397b Mon Sep 17 00:00:00 2001 From: Hernan Grecco Date: Sat, 15 Jul 2023 16:21:03 -0300 Subject: [PATCH 5/8] Add benchmarks to gitignore --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index ae702bac3..def5bec49 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,9 @@ pint/testsuite/dask-worker-space # WebDAV file system cache files .DAV/ +# pytest benchmarks folder +.benchmarks + # tags files (from ctags) tags From 688a5af07d75f6fce26b9acb9b437e9120d1506f Mon Sep 17 00:00:00 2001 From: Hernan Grecco Date: Sat, 15 Jul 2023 18:26:21 -0300 Subject: [PATCH 6/8] Reorganize convert and _convert for better caching --- pint/facets/context/registry.py | 5 +++-- pint/facets/nonmultiplicative/registry.py | 9 +++++++-- pint/facets/plain/registry.py | 6 +++--- pint/registry_helpers.py | 6 +++--- pint/testsuite/benchmarks/test_10_registry.py | 4 ++-- 5 files changed, 18 insertions(+), 12 deletions(-) diff --git a/pint/facets/context/registry.py b/pint/facets/context/registry.py index 540481567..31653dd96 100644 --- a/pint/facets/context/registry.py +++ b/pint/facets/context/registry.py @@ -367,7 +367,8 @@ def _convert( value: Magnitude, src: UnitsContainer, dst: UnitsContainer, - inplace: bool = False, + inplace: bool, + check_dimensionality: bool, ) -> Magnitude: """Convert value from some source to destination units. @@ -406,7 +407,7 @@ def _convert( value, src = src._magnitude, src._units - return super()._convert(value, src, dst, inplace) + return super()._convert(value, src, dst, inplace, check_dimensionality) def _get_compatible_units( self, input_units: UnitsContainer, group_or_system: Optional[str] = None diff --git a/pint/facets/nonmultiplicative/registry.py b/pint/facets/nonmultiplicative/registry.py index 7d783de11..bd1e83677 100644 --- a/pint/facets/nonmultiplicative/registry.py +++ b/pint/facets/nonmultiplicative/registry.py @@ -209,7 +209,12 @@ def _add_ref_of_log_or_offset_unit( return all_units def _convert( - self, value: T, src: UnitsContainer, dst: UnitsContainer, inplace: bool = False + self, + value: T, + src: UnitsContainer, + dst: UnitsContainer, + inplace: bool, + check_dimensionality: bool, ) -> T: """Convert value from some source to destination units. @@ -251,7 +256,7 @@ def _convert( ) if not (src_offset_unit or dst_offset_unit): - return super()._convert(value, src, dst, inplace) + return super()._convert(value, src, dst, inplace, check_dimensionality) src_dim = self._get_dimensionality(src) dst_dim = self._get_dimensionality(dst) diff --git a/pint/facets/plain/registry.py b/pint/facets/plain/registry.py index e6573657e..a43750a8b 100644 --- a/pint/facets/plain/registry.py +++ b/pint/facets/plain/registry.py @@ -981,15 +981,15 @@ def convert( if src == dst: return value - return self._convert(value, src, dst, inplace) + return self._convert(value, src, dst, inplace, True) def _convert( self, value: T, src: UnitsContainer, dst: UnitsContainer, - inplace: bool = False, - check_dimensionality: bool = True, + inplace: bool, + check_dimensionality: bool, ) -> T: """Convert value from some source to destination units. diff --git a/pint/registry_helpers.py b/pint/registry_helpers.py index 6b2f0e0b6..86f43163d 100644 --- a/pint/registry_helpers.py +++ b/pint/registry_helpers.py @@ -134,7 +134,7 @@ def _converter(ureg, values, strict): for ndx in dependent_args_ndx: value = values[ndx] assert _replace_units(args_as_uc[ndx][0], values_by_name) is not None - new_values[ndx] = ureg._convert( + new_values[ndx] = ureg.convert( getattr(value, "_magnitude", value), getattr(value, "_units", UnitsContainer({})), _replace_units(args_as_uc[ndx][0], values_by_name), @@ -143,7 +143,7 @@ def _converter(ureg, values, strict): # third pass: convert other arguments for ndx in unit_args_ndx: if isinstance(values[ndx], ureg.Quantity): - new_values[ndx] = ureg._convert( + new_values[ndx] = ureg.convert( values[ndx]._magnitude, values[ndx]._units, args_as_uc[ndx][0] ) else: @@ -151,7 +151,7 @@ def _converter(ureg, values, strict): if isinstance(values[ndx], str): # if the value is a string, we try to parse it tmp_value = ureg.parse_expression(values[ndx]) - new_values[ndx] = ureg._convert( + new_values[ndx] = ureg.convert( tmp_value._magnitude, tmp_value._units, args_as_uc[ndx][0] ) else: diff --git a/pint/testsuite/benchmarks/test_10_registry.py b/pint/testsuite/benchmarks/test_10_registry.py index 073bc9230..c77159930 100644 --- a/pint/testsuite/benchmarks/test_10_registry.py +++ b/pint/testsuite/benchmarks/test_10_registry.py @@ -132,8 +132,8 @@ def test_convert_from_uc(benchmark, my_setup: SetupType, key: str, pre_run: bool src, dst = key ureg, data = my_setup if pre_run: - no_benchmark(ureg._convert, 1.0, data[src], data[dst]) - benchmark(ureg._convert, 1.0, data[src], data[dst]) + no_benchmark(ureg._convert, 1.0, data[src], data[dst], False, True) + benchmark(ureg._convert, 1.0, data[src], data[dst], False, True) def test_parse_math_expression(benchmark, my_setup): From db97439cd90a789ee0af99868c553c1832b5d53b Mon Sep 17 00:00:00 2001 From: Hernan Grecco Date: Sun, 16 Jul 2023 00:34:10 -0300 Subject: [PATCH 7/8] Reorganize _get_root_units for better caching --- pint/facets/plain/registry.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/pint/facets/plain/registry.py b/pint/facets/plain/registry.py index a43750a8b..2949f1eb1 100644 --- a/pint/facets/plain/registry.py +++ b/pint/facets/plain/registry.py @@ -795,12 +795,16 @@ def get_root_units( """ input_units = to_units_container(input_units, self) - f, units = self._get_root_units(input_units, check_nonmult) + f, units = self._get_root_units(input_units, check_nonmult=check_nonmult) return f, self.Unit(units) def _get_root_units( - self, input_units: UnitsContainer, check_nonmult: bool = True + self, + numerator: UnitsContainer, + denominator: UnitsContainer | None = None, + *, + check_nonmult: bool = True, ) -> tuple[Scalar, UnitsContainer]: """Convert unit or dict of units to the root units. @@ -809,7 +813,9 @@ def _get_root_units( Parameters ---------- - input_units : UnitsContainer or dict + numerator : UnitsContainer or dict + units + denominator : UnitsContainer or dict units check_nonmult : bool if True, None will be returned as the @@ -822,18 +828,22 @@ def _get_root_units( multiplicative factor, plain units """ - if not input_units: + + if denominator is not None: + numerator = numerator / denominator + + if not numerator: return 1, self.UnitsContainer() cache = self._cache.root_units try: - return cache[input_units] + return cache[numerator] except KeyError: pass accumulators: dict[Optional[str], int] = defaultdict(int) accumulators[None] = 1 - self._get_root_units_recurse(input_units, 1, accumulators) + self._get_root_units_recurse(numerator, 1, accumulators) factor = accumulators[None] units = self.UnitsContainer( @@ -845,7 +855,7 @@ def _get_root_units( if any(not self._units[unit].converter.is_multiplicative for unit in units): factor = None - cache[input_units] = factor, units + cache[numerator] = factor, units return factor, units def get_base_units( @@ -1024,7 +1034,7 @@ def _convert( # Here src and dst have only multiplicative units left. Thus we can # convert with a factor. - factor, _ = self._get_root_units(src / dst) + factor, _ = self._get_root_units(src, dst) # factor is type float and if our magnitude is type Decimal then # must first convert to Decimal before we can '*' the values From 1e157bf15a1f21cbe7d9715ad3e2985736869952 Mon Sep 17 00:00:00 2001 From: Hernan Grecco Date: Sun, 16 Jul 2023 09:58:10 -0300 Subject: [PATCH 8/8] Copy functools.cache into pint for further modification This will be required to have instance specific and stackable cache --- pint/cache.py | 163 ++++++++++++++++++++++++++++++++++ pint/facets/plain/registry.py | 5 +- pint/testsuite/test_cache.py | 72 +++++++++++++++ 3 files changed, 239 insertions(+), 1 deletion(-) create mode 100644 pint/cache.py create mode 100644 pint/testsuite/test_cache.py diff --git a/pint/cache.py b/pint/cache.py new file mode 100644 index 000000000..f63bd7931 --- /dev/null +++ b/pint/cache.py @@ -0,0 +1,163 @@ +"""functools.py - Tools for working with functions and callable objects +""" +# Python module wrapper for _functools C module +# to allow utilities written in Python to be added +# to the functools module. +# Written by Nick Coghlan , +# Raymond Hettinger , +# and Ɓukasz Langa . +# Copyright (C) 2006-2013 Python Software Foundation. +# See C source code for _functools credits/copyright + +from __future__ import annotations + +__all__ = [ + "cache", + "lru_cache", +] +from weakref import WeakKeyDictionary + +from functools import update_wrapper + +from typing import Any, Callable, Protocol, TYPE_CHECKING, TypeVar + +T = TypeVar("T") + +if TYPE_CHECKING: + from . import UnitRegistry + + +################################################################################ +### LRU Cache function decorator +################################################################################ + + +class Hashable(Protocol): + def __hash__(self) -> int: + ... + + +class _HashedSeq(list[Any]): + """This class guarantees that hash() will be called no more than once + per element. This is important because the lru_cache() will hash + the key multiple times on a cache miss. + + """ + + __slots__ = "hashvalue" + + def __init__(self, tup: tuple[Any, ...], hashfun: Callable[[Any], int] = hash): + self[:] = tup + self.hashvalue = hashfun(tup) + + def __hash__(self) -> int: + return self.hashvalue + + +def _make_key( + args: tuple[Any, ...], + kwds: dict[str, Any], + kwd_mark: tuple[Any, ...] = (object(),), + fasttypes: set[type] = {int, str}, + tuple: type = tuple, + type: type = type, + len: Callable[[Any], int] = len, +) -> Hashable: + """Make a cache key from optionally typed positional and keyword arguments + + The key is constructed in a way that is flat as possible rather than + as a nested structure that would take more memory. + + If there is only a single argument and its data type is known to cache + its hash value, then that argument is returned without a wrapper. This + saves space and improves lookup speed. + + """ + # All of code below relies on kwds preserving the order input by the user. + # Formerly, we sorted() the kwds before looping. The new way is *much* + # faster; however, it means that f(x=1, y=2) will now be treated as a + # distinct call from f(y=2, x=1) which will be cached separately. + key = args + if kwds: + key += kwd_mark + for item in kwds.items(): + key += item + if len(key) == 1 and type(key[0]) in fasttypes: + return key[0] + return _HashedSeq(key) + + +def lru_cache(): + """Least-recently-used cache decorator. + + If *maxsize* is set to None, the LRU features are disabled and the cache + can grow without bound. + + If *typed* is True, arguments of different types will be cached separately. + For example, f(decimal.Decimal("3.0")) and f(3.0) will be treated as + distinct calls with distinct results. Some types such as str and int may + be cached separately even when typed is false. + + Arguments to the cached function must be hashable. + + View the cache statistics named tuple (hits, misses, maxsize, currsize) + with f.cache_info(). Clear the cache and statistics with f.cache_clear(). + Access the underlying function with f.__wrapped__. + + See: https://en.wikipedia.org/wiki/Cache_replacement_policies#Least_recently_used_(LRU) + + """ + + # Users should only access the lru_cache through its public API: + # cache_info, cache_clear, and f.__wrapped__ + # The internals of the lru_cache are encapsulated for thread safety and + # to allow the implementation to change (including a possible C version). + + def decorating_function(user_function: Callable[..., T]) -> Callable[..., T]: + wrapper = _lru_cache_wrapper(user_function) + return update_wrapper(wrapper, user_function) + + return decorating_function + + +def _lru_cache_wrapper(user_function: Callable[..., T]) -> Callable[..., T]: + # Constants shared by all lru cache instances: + sentinel = object() # unique object used to signal cache misses + make_key = _make_key # build a key from the function arguments + + cache: WeakKeyDictionary[object, dict[Any, T]] = WeakKeyDictionary() + + def wrapper(self: UnitRegistry, *args: Any, **kwds: Any) -> T: + # Simple caching without ordering or size limit + + key = make_key(args, kwds) + + subcache = cache.get(self, None) + if subcache is None: + cache[self] = subcache = {} + + result = subcache.get(key, sentinel) + + if result is not sentinel: + return result + + subcache[key] = result = user_function(self, *args, **kwds) + return result + + def cache_clear(self: UnitRegistry): + """Clear the cache and cache statistics""" + if self in cache: + cache[self].clear() + + wrapper.cache_clear = cache_clear + return wrapper + + +################################################################################ +### cache -- simplified access to the infinity cache +################################################################################ + + +def cache(user_function: Callable[..., Any], /): + 'Simple lightweight unbounded cache. Sometimes called "memoize".' + return lru_cache()(user_function) diff --git a/pint/facets/plain/registry.py b/pint/facets/plain/registry.py index 2949f1eb1..ab1f203f5 100644 --- a/pint/facets/plain/registry.py +++ b/pint/facets/plain/registry.py @@ -63,6 +63,9 @@ Handler, ) + +from ...cache import cache as methodcache + from ..._vendor import appdirs from ...compat import babel_parse, tokenizer, TypeAlias, Self from ...errors import DimensionalityError, RedefinitionError, UndefinedUnitError @@ -1084,7 +1087,7 @@ def parse_single_unit( return self._parse_single_unit(unit_name, case_sensitive) - @functools.cache + @methodcache def _parse_single_unit( self, unit_name: str, case_sensitive: bool ) -> tuple[tuple[str, str, str], ...]: diff --git a/pint/testsuite/test_cache.py b/pint/testsuite/test_cache.py new file mode 100644 index 000000000..d2c68b512 --- /dev/null +++ b/pint/testsuite/test_cache.py @@ -0,0 +1,72 @@ +# This is a weird test module as it is currently testing python's cache +# Its purpose is to summarize the requirements for any replacements +# and test for undocumented features. + +from pint.cache import cache + + +class Demo: + def __init__(self, value) -> None: + self.value = value + + @cache + def calculated_value(self, value): + return self.value * value + + +class DerivedDemo(Demo): + @cache + def calculated_value(self, value): + if value is None: + return super().calculated_value(3) + return self.value * value + 0.5 + + +def test_cache_clear(): + demo = Demo(2) + + assert demo.calculated_value(3) == 6 + assert demo.calculated_value(3) == 6 + demo.value = 3 + assert demo.calculated_value(3) == 6 + demo.calculated_value.cache_clear(demo) + assert demo.calculated_value(3) == 9 + assert demo.calculated_value(3) == 9 + + +def test_per_instance_cache(): + demo2 = Demo(2) + demo3 = Demo(3) + + assert demo2.calculated_value(3) == 6 + assert demo2.calculated_value(3) == 6 + assert demo3.calculated_value(3) == 9 + assert demo3.calculated_value(3) == 9 + + +def test_per_instance_cache_clear(): + demo2 = Demo(2) + demo3 = Demo(3) + + demo2.calculated_value(3) + demo3.calculated_value(3) + + demo2.value = 4 + demo3.value = 5 + assert demo2.calculated_value(3) == 6 + assert demo3.calculated_value(3) == 9 + demo2.calculated_value.cache_clear(demo2) + assert demo2.calculated_value(3) == 12 + assert demo3.calculated_value(3) == 9 + demo3.calculated_value.cache_clear(demo3) + assert demo3.calculated_value(5) == 15 + + +def test_inheritance(): + demo = DerivedDemo(2) + + assert demo.calculated_value(3) == 6.5 + assert demo.calculated_value(3) == 6.5 + assert demo.calculated_value(None) == 6 + assert demo.calculated_value(None) == 6 + assert demo.calculated_value(1)