From 75d074f2b93db5eeb48bbd832911072014812153 Mon Sep 17 00:00:00 2001 From: Simon Perkins Date: Wed, 23 Sep 2026 14:52:49 +0200 Subject: [PATCH 1/4] Add Multiton.clear_cache to evict cached instances Evicts the entire cache (and expiry heap), or only entries whose cached instance matches a type, tuple of types or union via isinstance. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.rst | 2 + src/rarg_python_patterns/multiton/multiton.py | 45 +++++++++- tests/test_multiton.py | 89 +++++++++++++++++++ 3 files changed, 132 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 87a7833..47c0299 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -13,6 +13,8 @@ Unreleased X.Y.Z (DD-MM-YYYY) Added ----- - Freeze Multitons using their internal FrozenKey (:pr:`10`) +- Add ``Multiton.clear_cache`` to evict all cached instances, or only those + of a given type Changed ------- diff --git a/src/rarg_python_patterns/multiton/multiton.py b/src/rarg_python_patterns/multiton/multiton.py index 0e3458d..4cfe055 100644 --- a/src/rarg_python_patterns/multiton/multiton.py +++ b/src/rarg_python_patterns/multiton/multiton.py @@ -7,6 +7,7 @@ import time import weakref from threading import RLock +from types import UnionType from typing import Any, Callable, ClassVar, Dict, Generic, List, Tuple, TypeVar from rarg_python_patterns.multiton.canonicalisation import ( @@ -49,9 +50,10 @@ class Multiton(Generic[T]): A ``ttl`` of ``math.inf`` (set via ``with_ttl(math.inf)`` or the ``with_infinite_ttl()`` shorthand) makes an entry eternal: it never - expires and is only removed by ``release()``. Eternal entries are never - pushed onto the heap (an ``inf`` deadline could never satisfy the sweep - condition anyway), so they cost nothing in heap space. The heap holds + expires and is only removed by ``release()`` or ``clear_cache()``. + Eternal entries are never pushed onto the heap (an ``inf`` deadline could + never satisfy the sweep condition anyway), so they cost nothing in heap + space. The heap holds only finite-TTL tuples and self-compacts to discard stale ones whenever it grows much larger than the live cache. @@ -154,7 +156,8 @@ def with_ttl(self, ttl: float) -> Multiton[T]: Arguments: ttl: Time-to-live in seconds for the cached instance. ``math.inf`` - makes the entry eternal (never expires; only removed by ``release()``). + makes the entry eternal (never expires; only removed by ``release()`` + or ``clear_cache()``). See :meth:`with_args` for the TTL-reset and first-write semantics. """ return self.with_args(ttl=ttl) @@ -333,6 +336,40 @@ def release(self) -> None: with self._INSTANCE_LOCK: self._INSTANCE_CACHE.pop(self._key, None) + @classmethod + def clear_cache( + cls, instance_type: type | tuple[type, ...] | UnionType | None = None + ) -> None: + """Evict cached instances, optionally only those of a given type. + + Arguments: + instance_type: Evict entries whose cached instance satisfies + ``isinstance(instance, instance_type)``: a type, a tuple of types or + a union such as ``int | str``. Subclass instances therefore match + too. Matching is on the object the factory returned, not on the + factory itself. If ``None``, the entire cache is cleared, including + eternal (``math.inf`` TTL) entries. + + Evicted keys are recreated on next access. Like ``release()``, this does + not wait for in-flight constructions: an entry being constructed when + ``clear_cache`` runs is published afterwards as usual. + """ + with cls._INSTANCE_LOCK: + if instance_type is None: + # Every heap entry is now stale, so drop them all at once + cls._INSTANCE_CACHE.clear() + cls._EXPIRY_HEAP.clear() + return + + # Remaining heap entries are discarded as stale during the next purge + keys = [ + k + for k, (obj, *_) in cls._INSTANCE_CACHE.items() + if isinstance(obj, instance_type) + ] + for k in keys: + del cls._INSTANCE_CACHE[k] + def __str__(self) -> str: return f"Multiton({self._factory})" diff --git a/tests/test_multiton.py b/tests/test_multiton.py index eabb643..3d08e77 100644 --- a/tests/test_multiton.py +++ b/tests/test_multiton.py @@ -573,3 +573,92 @@ class WeakMultiton(Multiton[Data]): # The parent is collectable while the child remains cached assert parent_ref() is None assert child_key in Multiton._INSTANCE_CACHE + + +class Base: + pass + + +class Derived(Base): + pass + + +class Other: + pass + + +def test_clear_cache_all(): + """clear_cache() evicts every entry, eternal ones included, and the heap""" + finite = Multiton(Base) + eternal = Multiton(Other).with_infinite_ttl() + old_finite, old_eternal = finite.instance, eternal.instance + + Multiton.clear_cache() + assert not Multiton._INSTANCE_CACHE + assert not Multiton._EXPIRY_HEAP + + assert finite.instance is not old_finite + assert eternal.instance is not old_eternal + + +def test_clear_cache_by_type(): + """Only entries whose instance matches the type are evicted""" + base, other = Multiton(Base), Multiton(Other) + old_base, old_other = base.instance, other.instance + + Multiton.clear_cache(Base) + + assert base.instance is not old_base + assert other.instance is old_other + + +def test_clear_cache_matches_subclasses(): + """isinstance semantics: clearing a base type also evicts subclasses""" + derived, other = Multiton(Derived), Multiton(Other) + old_derived, old_other = derived.instance, other.instance + + Multiton.clear_cache(Base) + + assert derived.instance is not old_derived + assert other.instance is old_other + + +@pytest.mark.parametrize("instance_type", [(Base, Other), Base | Other]) +def test_clear_cache_tuple_and_union(instance_type): + """Tuples of types and unions are both accepted""" + base, other, data = Multiton(Base), Multiton(Other), Multiton(Data, 1.0, 2.0) + old_base, old_other, old_data = base.instance, other.instance, data.instance + + Multiton.clear_cache(instance_type) + + assert base.instance is not old_base + assert other.instance is not old_other + assert data.instance is old_data + + +def test_clear_cache_no_match(): + """A type matching nothing leaves the cache untouched""" + m = Multiton(Base) + old = m.instance + + Multiton.clear_cache(Other) + + assert m.instance is old + + +def test_clear_cache_stale_heap_entry_ignored(): + """A recreated key survives expiry of the heap entry left by clear_cache""" + m = Multiton(Base).with_ttl(10.0) + now = 1000.0 + with patch("rarg_python_patterns.multiton.multiton.time.monotonic") as mono: + mono.return_value = now + m.instance + Multiton.clear_cache(Base) + assert len(Multiton._EXPIRY_HEAP) == 1 # stale entry left behind + + mono.return_value = now + 5.0 + recreated = m.instance + + # The stale entry's deadline passes, but the recreated entry is live + mono.return_value = now + 12.0 + assert m.instance is recreated From 055be856cffff810f8a9b33b305febd4b42d2e24 Mon Sep 17 00:00:00 2001 From: Simon Perkins Date: Mon, 28 Sep 2026 09:15:12 +0200 Subject: [PATCH 2/4] Filter clear_cache by key with a where predicate Add FrozenKey.factory, args and kwargs accessors and a where(key, instance) predicate to Multiton.clear_cache, so callers can evict entries by what created them (e.g. one dataset's tables) rather than only by instance type. clear_cache now returns the number of entries evicted. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.rst | 3 +- .../multiton/canonicalisation.py | 22 +++++ src/rarg_python_patterns/multiton/multiton.py | 40 ++++++--- tests/test_canonicalisation.py | 27 +++++++ tests/test_multiton.py | 81 +++++++++++++++++++ 5 files changed, 160 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 47c0299..b31fea1 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,7 +14,8 @@ Added ----- - Freeze Multitons using their internal FrozenKey (:pr:`10`) - Add ``Multiton.clear_cache`` to evict all cached instances, or only those - of a given type + matching an instance type and/or a ``where(key, instance)`` predicate +- Add ``FrozenKey.factory``, ``FrozenKey.args`` and ``FrozenKey.kwargs`` Changed ------- diff --git a/src/rarg_python_patterns/multiton/canonicalisation.py b/src/rarg_python_patterns/multiton/canonicalisation.py index 6ef9278..5a9eb0b 100644 --- a/src/rarg_python_patterns/multiton/canonicalisation.py +++ b/src/rarg_python_patterns/multiton/canonicalisation.py @@ -81,6 +81,28 @@ def __init__(self, *args, **kw): def frozen(self) -> Tuple[Any, ...]: return self._frozen + @property + def factory(self) -> Any: + """The frozen factory: the first positional argument of a Multiton key.""" + return self._frozen[0] + + @property + def args(self) -> Tuple[Any, ...]: + """The frozen positional arguments following the factory. + + Multiton keys are built from :func:`normalise_args` output, so + parameters that can be passed positionally always appear here, even if + the caller supplied them as keywords. + """ + return self._frozen[1:-1] + + @property + def kwargs(self) -> Dict[str, Any]: + """The frozen keyword arguments, as a new ``dict``. + + For Multiton keys these are the keyword-only parameters.""" + return dict(self._frozen[-1]) + def __hash__(self) -> int: return self._hashvalue diff --git a/src/rarg_python_patterns/multiton/multiton.py b/src/rarg_python_patterns/multiton/multiton.py index 4cfe055..023f125 100644 --- a/src/rarg_python_patterns/multiton/multiton.py +++ b/src/rarg_python_patterns/multiton/multiton.py @@ -53,9 +53,8 @@ class Multiton(Generic[T]): expires and is only removed by ``release()`` or ``clear_cache()``. Eternal entries are never pushed onto the heap (an ``inf`` deadline could never satisfy the sweep condition anyway), so they cost nothing in heap - space. The heap holds - only finite-TTL tuples and self-compacts to discard stale ones whenever - it grows much larger than the live cache. + space. The heap holds only finite-TTL tuples and self-compacts to discard + stale ones whenever it grows much larger than the live cache. **Thread safety.** Cache hits acquire only a brief global lock and never block behind factory execution. Constructions are serialised per key: @@ -338,37 +337,54 @@ def release(self) -> None: @classmethod def clear_cache( - cls, instance_type: type | tuple[type, ...] | UnionType | None = None - ) -> None: - """Evict cached instances, optionally only those of a given type. + cls, + instance_type: type | tuple[type, ...] | UnionType | None = None, + *, + where: Callable[[FrozenKey, Any], bool] | None = None, + ) -> int: + """Evict cached instances, optionally only those matching filters. + + An entry is evicted only if it matches every filter supplied. With no + filters the entire cache is cleared, including eternal (``math.inf`` + TTL) entries. Arguments: - instance_type: Evict entries whose cached instance satisfies + instance_type: Match entries whose cached instance satisfies ``isinstance(instance, instance_type)``: a type, a tuple of types or a union such as ``int | str``. Subclass instances therefore match too. Matching is on the object the factory returned, not on the - factory itself. If ``None``, the entire cache is cleared, including - eternal (``math.inf`` TTL) entries. + factory itself. + where: Match entries for which ``where(key, instance)`` is true. + ``key`` is the entry's :class:`FrozenKey`, whose ``factory``, + ``args`` and ``kwargs`` identify what created it. It is called + under the global cache lock, so it must be quick and must not + access any Multiton's ``instance``. + + Returns: + The number of entries evicted. Evicted keys are recreated on next access. Like ``release()``, this does not wait for in-flight constructions: an entry being constructed when ``clear_cache`` runs is published afterwards as usual. """ with cls._INSTANCE_LOCK: - if instance_type is None: + if instance_type is None and where is None: # Every heap entry is now stale, so drop them all at once + n = len(cls._INSTANCE_CACHE) cls._INSTANCE_CACHE.clear() cls._EXPIRY_HEAP.clear() - return + return n # Remaining heap entries are discarded as stale during the next purge keys = [ k for k, (obj, *_) in cls._INSTANCE_CACHE.items() - if isinstance(obj, instance_type) + if (instance_type is None or isinstance(obj, instance_type)) + and (where is None or where(k, obj)) ] for k in keys: del cls._INSTANCE_CACHE[k] + return len(keys) def __str__(self) -> str: return f"Multiton({self._factory})" diff --git a/tests/test_canonicalisation.py b/tests/test_canonicalisation.py index 281533e..f6bcbf3 100644 --- a/tests/test_canonicalisation.py +++ b/tests/test_canonicalisation.py @@ -127,3 +127,30 @@ def test_normalise_args_non_introspectable_passthrough(): """Callables without a retrievable signature pass arguments through.""" args, kw = normalise_args(dict, (), {"a": 1}) assert (args, kw) == ((), {"a": 1}) + + +def test_frozen_key_accessors(): + """factory, args and kwargs expose the parts of a Multiton-style key""" + + def f(a, b=2, *, c=3): + pass + + key = FrozenKey(f, *normalise_args(f, (1,), {"b": [4, 5]})[0], c=6) + assert key.factory is f + assert key.args == (1, (4, 5)) + assert key.kwargs == {"c": 6} + + +def test_frozen_key_accessors_bound_method(): + """Bound-method factories compare equal across separate lookups""" + + class T: + @classmethod + def open(cls, name, readonly=True): + pass + + args, kw = normalise_args(T.open, ("a.ms",), {"readonly": False}) + key = FrozenKey(T.open, *args, **kw) + assert key.factory == T.open + assert key.args == ("a.ms", False) + assert key.kwargs == {} diff --git a/tests/test_multiton.py b/tests/test_multiton.py index 3d08e77..c132843 100644 --- a/tests/test_multiton.py +++ b/tests/test_multiton.py @@ -662,3 +662,84 @@ def test_clear_cache_stale_heap_entry_ignored(): # The stale entry's deadline passes, but the recreated entry is live mono.return_value = now + 12.0 assert m.instance is recreated + + +def test_clear_cache_returns_count(): + """clear_cache reports how many entries it evicted""" + Multiton(Base).instance + Multiton(Other).instance + Multiton(Data, 1.0, 2.0).instance + + assert Multiton.clear_cache(Base | Other) == 2 + assert Multiton.clear_cache(Base) == 0 + assert Multiton.clear_cache() == 1 + + +def test_clear_cache_where(): + """where filters on the key; combined with instance_type it is an AND""" + d1, d2 = Multiton(Data, 1.0, 2.0), Multiton(Data, 3.0, 4.0) + other = Multiton(Other) + old_d1, old_d2, old_other = d1.instance, d2.instance, other.instance + + def first_arg_is_one(key, _): + return key.factory is Data and key.args[0] == 1.0 + + assert Multiton.clear_cache(Other, where=first_arg_is_one) == 0 + assert Multiton.clear_cache(where=first_arg_is_one) == 1 + + assert d1.instance is not old_d1 + assert d2.instance is old_d2 + assert other.instance is old_other + + +class _Table: + def __init__(self, name): + self.name = name + + +class _Structure: + def __init__(self, table, subtables): + self.table = table.instance.name + self.subtables = {k: v.instance.name for k, v in subtables.items()} + + +def test_clear_cache_where_per_dataset(): + """Evict one dataset's tables while keeping its structure and other datasets. + + Mirrors how xarray-ms keys its main table, subtables and structure. + """ + calls = {"table": 0, "structure": 0} + + def open_table(name): + calls["table"] += 1 + return _Table(name) + + def build_structure(table, subtables): + calls["structure"] += 1 + return _Structure(table, subtables) + + def make(ms): + table = Multiton(open_table, ms) + subtables = {"ANT": Multiton(open_table, f"{ms}::ANT")} + return table, subtables, Multiton(build_structure, table, subtables) + + a_table, a_subtables, a_structure = make("a.ms") + b_table, b_subtables, b_structure = make("b.ms") + a_structure.instance, b_structure.instance + old_a, old_b = a_table.instance, b_table.instance + assert calls == {"table": 4, "structure": 2} + + def a_tables(key, _): + return key.factory is open_table and ( + key.args[0] == "a.ms" or key.args[0].startswith("a.ms::") + ) + + assert Multiton.clear_cache(where=a_tables) == 2 + + # Structures survive; only a.ms's tables reopen + a_structure.instance, b_structure.instance + assert calls == {"table": 4, "structure": 2} + assert a_table.instance is not old_a + assert a_subtables["ANT"].instance.name == "a.ms::ANT" + assert b_table.instance is old_b + assert calls == {"table": 6, "structure": 2} From ea4247023d342cd32f0fb9a9282ed769fa7a53dc Mon Sep 17 00:00:00 2001 From: Simon Perkins Date: Mon, 28 Sep 2026 10:58:19 +0200 Subject: [PATCH 3/4] Filter clear_cache on the instance, drop FrozenKey accessors The where predicate now receives only the cached instance, which is the supported way to decide what to evict. FrozenKey no longer exposes factory, args and kwargs reconstructed from its frozen tuple. Co-Authored-By: Claude Opus 5.5 (1M context) --- CHANGELOG.rst | 3 +-- .../multiton/canonicalisation.py | 22 --------------- src/rarg_python_patterns/multiton/multiton.py | 9 +++---- tests/test_canonicalisation.py | 27 ------------------- tests/test_multiton.py | 12 ++++----- 5 files changed, 10 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b31fea1..a0aef59 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -14,8 +14,7 @@ Added ----- - Freeze Multitons using their internal FrozenKey (:pr:`10`) - Add ``Multiton.clear_cache`` to evict all cached instances, or only those - matching an instance type and/or a ``where(key, instance)`` predicate -- Add ``FrozenKey.factory``, ``FrozenKey.args`` and ``FrozenKey.kwargs`` + matching an instance type and/or a ``where(instance)`` predicate Changed ------- diff --git a/src/rarg_python_patterns/multiton/canonicalisation.py b/src/rarg_python_patterns/multiton/canonicalisation.py index 5a9eb0b..6ef9278 100644 --- a/src/rarg_python_patterns/multiton/canonicalisation.py +++ b/src/rarg_python_patterns/multiton/canonicalisation.py @@ -81,28 +81,6 @@ def __init__(self, *args, **kw): def frozen(self) -> Tuple[Any, ...]: return self._frozen - @property - def factory(self) -> Any: - """The frozen factory: the first positional argument of a Multiton key.""" - return self._frozen[0] - - @property - def args(self) -> Tuple[Any, ...]: - """The frozen positional arguments following the factory. - - Multiton keys are built from :func:`normalise_args` output, so - parameters that can be passed positionally always appear here, even if - the caller supplied them as keywords. - """ - return self._frozen[1:-1] - - @property - def kwargs(self) -> Dict[str, Any]: - """The frozen keyword arguments, as a new ``dict``. - - For Multiton keys these are the keyword-only parameters.""" - return dict(self._frozen[-1]) - def __hash__(self) -> int: return self._hashvalue diff --git a/src/rarg_python_patterns/multiton/multiton.py b/src/rarg_python_patterns/multiton/multiton.py index 023f125..dfde948 100644 --- a/src/rarg_python_patterns/multiton/multiton.py +++ b/src/rarg_python_patterns/multiton/multiton.py @@ -340,7 +340,7 @@ def clear_cache( cls, instance_type: type | tuple[type, ...] | UnionType | None = None, *, - where: Callable[[FrozenKey, Any], bool] | None = None, + where: Callable[[Any], bool] | None = None, ) -> int: """Evict cached instances, optionally only those matching filters. @@ -354,9 +354,8 @@ def clear_cache( a union such as ``int | str``. Subclass instances therefore match too. Matching is on the object the factory returned, not on the factory itself. - where: Match entries for which ``where(key, instance)`` is true. - ``key`` is the entry's :class:`FrozenKey`, whose ``factory``, - ``args`` and ``kwargs`` identify what created it. It is called + where: Match entries for which ``where(instance)`` is true, e.g. to + evict only instances tied to a particular resource. It is called under the global cache lock, so it must be quick and must not access any Multiton's ``instance``. @@ -380,7 +379,7 @@ def clear_cache( k for k, (obj, *_) in cls._INSTANCE_CACHE.items() if (instance_type is None or isinstance(obj, instance_type)) - and (where is None or where(k, obj)) + and (where is None or where(obj)) ] for k in keys: del cls._INSTANCE_CACHE[k] diff --git a/tests/test_canonicalisation.py b/tests/test_canonicalisation.py index f6bcbf3..281533e 100644 --- a/tests/test_canonicalisation.py +++ b/tests/test_canonicalisation.py @@ -127,30 +127,3 @@ def test_normalise_args_non_introspectable_passthrough(): """Callables without a retrievable signature pass arguments through.""" args, kw = normalise_args(dict, (), {"a": 1}) assert (args, kw) == ((), {"a": 1}) - - -def test_frozen_key_accessors(): - """factory, args and kwargs expose the parts of a Multiton-style key""" - - def f(a, b=2, *, c=3): - pass - - key = FrozenKey(f, *normalise_args(f, (1,), {"b": [4, 5]})[0], c=6) - assert key.factory is f - assert key.args == (1, (4, 5)) - assert key.kwargs == {"c": 6} - - -def test_frozen_key_accessors_bound_method(): - """Bound-method factories compare equal across separate lookups""" - - class T: - @classmethod - def open(cls, name, readonly=True): - pass - - args, kw = normalise_args(T.open, ("a.ms",), {"readonly": False}) - key = FrozenKey(T.open, *args, **kw) - assert key.factory == T.open - assert key.args == ("a.ms", False) - assert key.kwargs == {} diff --git a/tests/test_multiton.py b/tests/test_multiton.py index c132843..a99fc39 100644 --- a/tests/test_multiton.py +++ b/tests/test_multiton.py @@ -676,13 +676,13 @@ def test_clear_cache_returns_count(): def test_clear_cache_where(): - """where filters on the key; combined with instance_type it is an AND""" + """where filters on the instance; combined with instance_type it is an AND""" d1, d2 = Multiton(Data, 1.0, 2.0), Multiton(Data, 3.0, 4.0) other = Multiton(Other) old_d1, old_d2, old_other = d1.instance, d2.instance, other.instance - def first_arg_is_one(key, _): - return key.factory is Data and key.args[0] == 1.0 + def first_arg_is_one(obj): + return isinstance(obj, Data) and obj.a == 1.0 assert Multiton.clear_cache(Other, where=first_arg_is_one) == 0 assert Multiton.clear_cache(where=first_arg_is_one) == 1 @@ -729,10 +729,8 @@ def make(ms): old_a, old_b = a_table.instance, b_table.instance assert calls == {"table": 4, "structure": 2} - def a_tables(key, _): - return key.factory is open_table and ( - key.args[0] == "a.ms" or key.args[0].startswith("a.ms::") - ) + def a_tables(obj): + return isinstance(obj, _Table) and obj.name.partition("::")[0] == "a.ms" assert Multiton.clear_cache(where=a_tables) == 2 From 9e493f635d67f9db66df659354c64616f7f03cfa Mon Sep 17 00:00:00 2001 From: Simon Perkins Date: Mon, 28 Sep 2026 11:10:04 +0200 Subject: [PATCH 4/4] bump ci