diff --git a/cachebox/_core.pyi b/cachebox/_core.pyi index d99a5f0..e6a7dd2 100644 --- a/cachebox/_core.pyi +++ b/cachebox/_core.pyi @@ -9,6 +9,7 @@ __version__: typing.Final[str] KT = typing.TypeVar("KT", bound=typing.Hashable) VT = typing.TypeVar("VT") DT = typing.TypeVar("DT") +T_co = typing.TypeVar("T_co", covariant=True) _IterableType: typing.TypeAlias = ( typing.Dict[KT, VT] @@ -17,6 +18,42 @@ _IterableType: typing.TypeAlias = ( | typing.Iterable[typing.Tuple[KT, VT]] ) +class CacheIterator(typing.Iterator[T_co]): + """ + What ``keys()``, ``values()`` and ``items()`` return. + + This is a one-shot iterator, not a ``dict`` view: it walks the cache once + and is empty after that. It knows how many items it still has to yield, so + ``len()`` and ``bool()`` work on it, but it does not support ``in`` or set + operations the way ``dict.keys()`` does. + + Warning: + Do not modify the cache while one of these is alive. Every method + raises ``RuntimeError`` if the cache changed. + """ + + def __next__(self) -> T_co: ... + def __len__(self) -> int: + """ + Returns how many items are left to yield. + + For ``TTLCache`` and ``VTTLCache`` this skips expired entries, which + takes O(n). + + Returns: + The number of items left. + """ + ... + + def __bool__(self) -> bool: + """ + Returns whether any item is left to yield. + + Returns: + ``True`` if at least one item is left. + """ + ... + class BaseCacheImpl(typing.Generic[KT, VT]): """ Base implementation for cache classes. @@ -221,10 +258,10 @@ class BaseCacheImpl(typing.Generic[KT, VT]): def __eq__(self, other: typing.Any) -> bool: ... def __ne__(self, other: typing.Any) -> bool: ... - def items(self) -> typing.Iterable[typing.Tuple[KT, VT]]: ... - def values(self) -> typing.Iterable[VT]: ... - def keys(self) -> typing.Iterable[KT]: ... - def __iter__(self) -> typing.Iterator[KT]: ... + def items(self) -> CacheIterator[typing.Tuple[KT, VT]]: ... + def values(self) -> CacheIterator[VT]: ... + def keys(self) -> CacheIterator[KT]: ... + def __iter__(self) -> CacheIterator[KT]: ... def copy(self) -> typing.Self: ... def __copy__(self) -> typing.Self: ... def __getstate__(self) -> object: ... @@ -410,7 +447,7 @@ class Cache(BaseCacheImpl[KT, VT]): """ ... - def items(self) -> typing.Iterable[typing.Tuple[KT, VT]]: + def items(self) -> CacheIterator[typing.Tuple[KT, VT]]: """ Returns an iterable of the cache's ``(key, value)`` pairs. @@ -422,7 +459,7 @@ class Cache(BaseCacheImpl[KT, VT]): """ ... - def keys(self) -> typing.Iterable[KT]: + def keys(self) -> CacheIterator[KT]: """ Returns an iterable of the cache's keys. @@ -434,7 +471,7 @@ class Cache(BaseCacheImpl[KT, VT]): """ ... - def values(self) -> typing.Iterable[VT]: + def values(self) -> CacheIterator[VT]: """ Returns an iterable of the cache's values. @@ -593,7 +630,7 @@ class FIFOCache(BaseCacheImpl[KT, VT]): """ ... - def items(self) -> typing.Iterable[typing.Tuple[KT, VT]]: + def items(self) -> CacheIterator[typing.Tuple[KT, VT]]: """ Returns an ordered iterable of the cache's ``(key, value)`` pairs. @@ -605,7 +642,7 @@ class FIFOCache(BaseCacheImpl[KT, VT]): """ ... - def keys(self) -> typing.Iterable[KT]: + def keys(self) -> CacheIterator[KT]: """ Returns an ordered iterable of the cache's keys. @@ -617,7 +654,7 @@ class FIFOCache(BaseCacheImpl[KT, VT]): """ ... - def values(self) -> typing.Iterable[VT]: + def values(self) -> CacheIterator[VT]: """ Returns an ordered iterable of the cache's values. @@ -808,7 +845,7 @@ class RRCache(BaseCacheImpl[KT, VT]): """ ... - def items(self) -> typing.Iterable[typing.Tuple[KT, VT]]: + def items(self) -> CacheIterator[typing.Tuple[KT, VT]]: """ Returns an iterable of the cache's ``(key, value)`` pairs. @@ -820,7 +857,7 @@ class RRCache(BaseCacheImpl[KT, VT]): """ ... - def keys(self) -> typing.Iterable[KT]: + def keys(self) -> CacheIterator[KT]: """ Returns an iterable of the cache's keys. @@ -832,7 +869,7 @@ class RRCache(BaseCacheImpl[KT, VT]): """ ... - def values(self) -> typing.Iterable[VT]: + def values(self) -> CacheIterator[VT]: """ Returns an iterable of the cache's values. @@ -1016,7 +1053,7 @@ class LRUCache(BaseCacheImpl[KT, VT]): """ ... - def items(self) -> typing.Iterable[typing.Tuple[KT, VT]]: + def items(self) -> CacheIterator[typing.Tuple[KT, VT]]: """ Returns an ordered iterable of the cache's ``(key, value)`` pairs. @@ -1028,7 +1065,7 @@ class LRUCache(BaseCacheImpl[KT, VT]): """ ... - def keys(self) -> typing.Iterable[KT]: + def keys(self) -> CacheIterator[KT]: """ Returns an ordered iterable of the cache's keys. @@ -1040,7 +1077,7 @@ class LRUCache(BaseCacheImpl[KT, VT]): """ ... - def values(self) -> typing.Iterable[VT]: + def values(self) -> CacheIterator[VT]: """ Returns an ordered iterable of the cache's values. @@ -1265,7 +1302,7 @@ class LFUCache(BaseCacheImpl[KT, VT]): """ ... - def items(self) -> typing.Iterable[typing.Tuple[KT, VT]]: + def items(self) -> CacheIterator[typing.Tuple[KT, VT]]: """ Returns an ordered iterable of the cache's ``(key, value)`` pairs. @@ -1277,7 +1314,7 @@ class LFUCache(BaseCacheImpl[KT, VT]): """ ... - def keys(self) -> typing.Iterable[KT]: + def keys(self) -> CacheIterator[KT]: """ Returns an ordered iterable of the cache's keys. @@ -1289,7 +1326,7 @@ class LFUCache(BaseCacheImpl[KT, VT]): """ ... - def values(self) -> typing.Iterable[VT]: + def values(self) -> CacheIterator[VT]: """ Returns an ordered iterable of the cache's values. @@ -1301,7 +1338,7 @@ class LFUCache(BaseCacheImpl[KT, VT]): """ ... - def items_with_frequency(self) -> typing.Iterable[typing.Tuple[KT, VT, int]]: + def items_with_frequency(self) -> CacheIterator[typing.Tuple[KT, VT, int]]: """ Returns an ordered iterable of the cache's ``(key, value)`` pairs with their frequency counter. @@ -1473,7 +1510,7 @@ class TTLCache(BaseCacheImpl[KT, VT]): """ ... - def items(self) -> typing.Iterable[typing.Tuple[KT, VT]]: + def items(self) -> CacheIterator[typing.Tuple[KT, VT]]: """ Returns an ordered iterable of the cache's ``(key, value)`` pairs. @@ -1485,7 +1522,7 @@ class TTLCache(BaseCacheImpl[KT, VT]): """ ... - def keys(self) -> typing.Iterable[KT]: + def keys(self) -> CacheIterator[KT]: """ Returns an ordered iterable of the cache's keys. @@ -1497,7 +1534,7 @@ class TTLCache(BaseCacheImpl[KT, VT]): """ ... - def values(self) -> typing.Iterable[VT]: + def values(self) -> CacheIterator[VT]: """ Returns an ordered iterable of the cache's values. @@ -1599,7 +1636,7 @@ class TTLCache(BaseCacheImpl[KT, VT]): """ ... - def items_with_expire(self) -> typing.Iterable[typing.Tuple[KT, VT, float]]: + def items_with_expire(self) -> CacheIterator[typing.Tuple[KT, VT, float]]: """ Returns an ordered iterable of items with their remaining TTL. @@ -1741,7 +1778,7 @@ class VTTLCache(BaseCacheImpl[KT, VT]): KeyError: If the cache is empty. """ - def items(self) -> typing.Iterable[typing.Tuple[KT, VT]]: + def items(self) -> CacheIterator[typing.Tuple[KT, VT]]: """ Returns an ordered iterable of the cache's ``(key, value)`` pairs. @@ -1753,7 +1790,7 @@ class VTTLCache(BaseCacheImpl[KT, VT]): """ ... - def keys(self) -> typing.Iterable[KT]: + def keys(self) -> CacheIterator[KT]: """ Returns an ordered iterable of the cache's keys. @@ -1765,7 +1802,7 @@ class VTTLCache(BaseCacheImpl[KT, VT]): """ ... - def values(self) -> typing.Iterable[VT]: + def values(self) -> CacheIterator[VT]: """ Returns an ordered iterable of the cache's values. @@ -1838,7 +1875,7 @@ class VTTLCache(BaseCacheImpl[KT, VT]): """ ... - def items_with_expire(self) -> typing.Iterable[typing.Tuple[KT, VT, float | None]]: + def items_with_expire(self) -> CacheIterator[typing.Tuple[KT, VT, float | None]]: """ Returns an ordered iterable of items with their remaining TTL. diff --git a/src/internal/lazyheap.rs b/src/internal/lazyheap.rs index d2cacb4..b63f05b 100644 --- a/src/internal/lazyheap.rs +++ b/src/internal/lazyheap.rs @@ -340,6 +340,24 @@ impl Iterator for RawIter { } } } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + let left = self.first.len() + self.second.len(); + (left, Some(left)) + } +} + +impl ExactSizeIterator for RawIter {} + +impl Clone for RawIter { + #[inline] + fn clone(&self) -> Self { + Self { + first: self.first.clone(), + second: self.second.clone(), + } + } } unsafe impl Send for LazyHeap {} diff --git a/src/internal/linked_list.rs b/src/internal/linked_list.rs index 4572e07..8944dac 100644 --- a/src/internal/linked_list.rs +++ b/src/internal/linked_list.rs @@ -640,6 +640,8 @@ impl Iterator for RawIter { } } +impl ExactSizeIterator for RawIter {} + unsafe impl Send for LinkedList {} unsafe impl Sync for LinkedList {} unsafe impl Send for RawIter {} diff --git a/src/internal/utils.rs b/src/internal/utils.rs index e078c26..309c816 100644 --- a/src/internal/utils.rs +++ b/src/internal/utils.rs @@ -476,6 +476,26 @@ impl Iterator for RawSliceIter { Some(value) } } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + let left = self.len - self.index; + (left, Some(left)) + } +} + +impl ExactSizeIterator for RawSliceIter {} + +// Cloning gives a second cursor over the same elements; it never touches them. +impl Clone for RawSliceIter { + #[inline] + fn clone(&self) -> Self { + Self { + pointer: self.pointer, + index: self.index, + len: self.len, + } + } } unsafe impl Send for RawSliceIter {} @@ -514,4 +534,22 @@ impl Iterator for RawVecDequeIter { } } } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + let left = self.first.len() + self.second.len(); + (left, Some(left)) + } +} + +impl ExactSizeIterator for RawVecDequeIter {} + +impl Clone for RawVecDequeIter { + #[inline] + fn clone(&self) -> Self { + Self { + first: self.first.clone(), + second: self.second.clone(), + } + } } diff --git a/src/macro_rules.rs b/src/macro_rules.rs index 1ccdf5b..65ab0f3 100644 --- a/src/macro_rules.rs +++ b/src/macro_rules.rs @@ -25,6 +25,33 @@ macro_rules! implement_pyclass { }; } +/// Implements the generation-version guard shared by every cache view type. +/// +/// # Example +/// +/// ```ignore +/// implement_view_guard!(PyCacheKeys); +/// ``` +#[macro_export] +macro_rules! implement_view_guard { + ($name:ident) => { + impl $name { + /// Fails if the cache changed after this view was created. + #[inline] + fn check_generation(&self) -> pyo3::PyResult<()> { + if self.initial_gv == self.gv.get() { + return Ok(()); + } + + Err($crate::new_py_error!( + PyRuntimeError, + "cache size changed during iteration" + )) + } + } + }; +} + /// Creates a new [`PyErr`] of the given exception type. #[macro_export] macro_rules! new_py_error { diff --git a/src/pyclasses/cache.rs b/src/pyclasses/cache.rs index 13fe4d4..28f07e6 100644 --- a/src/pyclasses/cache.rs +++ b/src/pyclasses/cache.rs @@ -691,6 +691,8 @@ macro_rules! implement_iterator { } } + implement_view_guard!($name); + #[pyo3::pymethods] impl $name { #[inline] @@ -703,12 +705,7 @@ macro_rules! implement_iterator { } fn __next__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult<$rt_type> { - if slf.initial_gv != slf.gv.get() { - return Err(new_py_error!( - PyRuntimeError, - "cache size changed during iteration" - )); - } + slf.check_generation()?; let mut iter = slf.iter.lock(); @@ -721,6 +718,13 @@ macro_rules! implement_iterator { None => return Err(new_py_error!(PyStopIteration, ())), } } + + /// Returns how many items are left to yield. + fn __len__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult { + slf.check_generation()?; + + Ok(slf.iter.lock().len()) + } } )+ }; diff --git a/src/pyclasses/fifocache.rs b/src/pyclasses/fifocache.rs index 3ee15b1..58e3b46 100644 --- a/src/pyclasses/fifocache.rs +++ b/src/pyclasses/fifocache.rs @@ -717,6 +717,8 @@ macro_rules! implement_iterator { } } + implement_view_guard!($name); + #[pyo3::pymethods] impl $name { #[inline] @@ -729,12 +731,7 @@ macro_rules! implement_iterator { } fn __next__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult<$rt_type> { - if slf.initial_gv != slf.gv.get() { - return Err(new_py_error!( - PyRuntimeError, - "cache size changed during iteration" - )); - } + slf.check_generation()?; let mut iter = slf.iter.lock(); @@ -747,6 +744,13 @@ macro_rules! implement_iterator { None => return Err(new_py_error!(PyStopIteration, ())), } } + + /// Returns how many items are left to yield. + fn __len__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult { + slf.check_generation()?; + + Ok(slf.iter.lock().len()) + } } )+ }; diff --git a/src/pyclasses/lfucache.rs b/src/pyclasses/lfucache.rs index 0d4a494..423b14c 100644 --- a/src/pyclasses/lfucache.rs +++ b/src/pyclasses/lfucache.rs @@ -784,6 +784,8 @@ macro_rules! implement_iterator { } } + implement_view_guard!($name); + #[pyo3::pymethods] impl $name { #[inline] @@ -796,12 +798,7 @@ macro_rules! implement_iterator { } fn __next__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult<$rt_type> { - if slf.initial_gv != slf.gv.get() { - return Err(new_py_error!( - PyRuntimeError, - "cache size changed during iteration" - )); - } + slf.check_generation()?; let mut iter = slf.iter.lock(); @@ -814,6 +811,13 @@ macro_rules! implement_iterator { None => return Err(new_py_error!(PyStopIteration, ())), } } + + /// Returns how many items are left to yield. + fn __len__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult { + slf.check_generation()?; + + Ok(slf.iter.lock().len()) + } } )+ }; diff --git a/src/pyclasses/lrucache.rs b/src/pyclasses/lrucache.rs index cb7c02f..43c345e 100644 --- a/src/pyclasses/lrucache.rs +++ b/src/pyclasses/lrucache.rs @@ -765,6 +765,8 @@ macro_rules! implement_iterator { } } + implement_view_guard!($name); + #[pyo3::pymethods] impl $name { #[inline] @@ -777,12 +779,7 @@ macro_rules! implement_iterator { } fn __next__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult<$rt_type> { - if slf.initial_gv != slf.gv.get() { - return Err(new_py_error!( - PyRuntimeError, - "cache size changed during iteration" - )); - } + slf.check_generation()?; let mut iter = slf.iter.lock(); @@ -795,6 +792,13 @@ macro_rules! implement_iterator { None => return Err(new_py_error!(PyStopIteration, ())), } } + + /// Returns how many items are left to yield. + fn __len__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult { + slf.check_generation()?; + + Ok(slf.iter.lock().len()) + } } )+ }; diff --git a/src/pyclasses/rrcache.rs b/src/pyclasses/rrcache.rs index 63a6575..295d6d9 100644 --- a/src/pyclasses/rrcache.rs +++ b/src/pyclasses/rrcache.rs @@ -711,6 +711,8 @@ macro_rules! implement_iterator { } } + implement_view_guard!($name); + #[pyo3::pymethods] impl $name { #[inline] @@ -723,12 +725,7 @@ macro_rules! implement_iterator { } fn __next__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult<$rt_type> { - if slf.initial_gv != slf.gv.get() { - return Err(new_py_error!( - PyRuntimeError, - "cache size changed during iteration" - )); - } + slf.check_generation()?; let mut iter = slf.iter.lock(); @@ -741,6 +738,13 @@ macro_rules! implement_iterator { None => return Err(new_py_error!(PyStopIteration, ())), } } + + /// Returns how many items are left to yield. + fn __len__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult { + slf.check_generation()?; + + Ok(slf.iter.lock().len()) + } } )+ }; diff --git a/src/pyclasses/ttlcache.rs b/src/pyclasses/ttlcache.rs index 992d0f0..8b3dbe1 100644 --- a/src/pyclasses/ttlcache.rs +++ b/src/pyclasses/ttlcache.rs @@ -854,6 +854,8 @@ macro_rules! implement_iterator { } } + implement_view_guard!($name); + #[pyo3::pymethods] impl $name { #[inline] @@ -866,12 +868,7 @@ macro_rules! implement_iterator { } fn __next__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult<$rt_type> { - if slf.initial_gv != slf.gv.get() { - return Err(new_py_error!( - PyRuntimeError, - "cache size changed during iteration" - )); - } + slf.check_generation()?; let now = std::time::SystemTime::now(); let mut iter = slf.iter.lock(); @@ -888,6 +885,26 @@ macro_rules! implement_iterator { Err(new_py_error!(PyStopIteration, ())) } + + /// Returns how many not-expired items are left to yield. + fn __len__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult { + slf.check_generation()?; + + let now = std::time::SystemTime::now(); + let iter = slf.iter.lock().clone(); + + Ok(iter.filter(|x| !unsafe { x.as_ref() }.is_expired(now)).count()) + } + + /// Returns whether any not-expired item is left, without counting them all. + fn __bool__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult { + slf.check_generation()?; + + let now = std::time::SystemTime::now(); + let mut iter = slf.iter.lock().clone(); + + Ok(iter.any(|x| !unsafe { x.as_ref() }.is_expired(now))) + } } )+ }; diff --git a/src/pyclasses/vttlcache.rs b/src/pyclasses/vttlcache.rs index 0644453..0510b76 100644 --- a/src/pyclasses/vttlcache.rs +++ b/src/pyclasses/vttlcache.rs @@ -816,6 +816,8 @@ macro_rules! implement_iterator { } } + implement_view_guard!($name); + #[pyo3::pymethods] impl $name { #[inline] @@ -828,12 +830,7 @@ macro_rules! implement_iterator { } fn __next__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult<$rt_type> { - if slf.initial_gv != slf.gv.get() { - return Err(new_py_error!( - PyRuntimeError, - "cache size changed during iteration" - )); - } + slf.check_generation()?; let now = std::time::SystemTime::now(); let mut iter = slf.iter.lock(); @@ -850,6 +847,26 @@ macro_rules! implement_iterator { Err(new_py_error!(PyStopIteration, ())) } + + /// Returns how many not-expired items are left to yield. + fn __len__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult { + slf.check_generation()?; + + let now = std::time::SystemTime::now(); + let iter = slf.iter.lock().clone(); + + Ok(iter.filter(|x| !unsafe { x.element() }.is_expired(now)).count()) + } + + /// Returns whether any not-expired item is left, without counting them all. + fn __bool__(slf: pyo3::PyRef<'_, Self>) -> pyo3::PyResult { + slf.check_generation()?; + + let now = std::time::SystemTime::now(); + let mut iter = slf.iter.lock().clone(); + + Ok(iter.any(|x| !unsafe { x.element() }.is_expired(now))) + } } )+ }; diff --git a/tests/mixins.py b/tests/mixins.py index 0ae7e0c..57efa1f 100644 --- a/tests/mixins.py +++ b/tests/mixins.py @@ -523,6 +523,46 @@ class Canary: assert ref() is None + def test_iterators_report_len(self): + cache = self.create_cache() + + cache.update({"a": 1, "b": 2, "c": 3}) + assert len(cache.keys()) == 3 + assert len(cache.values()) == 3 + assert len(cache.items()) == 3 + + def test_iterator_len_counts_what_is_left(self): + cache = self.create_cache() + + cache.update({"a": 1, "b": 2, "c": 3}) + it = cache.keys() + + next(it) + assert len(it) == 2 + + list(it) + assert len(it) == 0 + + def test_empty_iterator_is_falsy(self): + cache = self.create_cache() + + assert not cache.keys() + assert not cache.values() + assert not cache.items() + + cache.insert("a", 1) + assert cache.keys() + + def test_iterator_len_after_cache_changed(self): + cache = self.create_cache() + + cache.insert("a", 1) + it = cache.keys() + cache.insert("b", 2) + + with pytest.raises(RuntimeError): + len(it) + def test_generation_version_on_remove(self): cache = self.create_cache(10, {i: i for i in range(10)}) diff --git a/tests/test_impls.py b/tests/test_impls.py index 51725a0..ea2d664 100644 --- a/tests/test_impls.py +++ b/tests/test_impls.py @@ -128,6 +128,19 @@ def create_cache( getsizeof=getsizeof, ) + def test_iterator_len_correct_across_ring_wrap(self): + """Evictions wrap the ring buffer; len() must stay correct after every next().""" + cache = self.create_cache(5) + for i in range(9): + cache.insert(f"k{i}", i) + + it = cache.keys() + for left in range(len(cache), 0, -1): + assert len(it) == left + next(it) + + assert len(it) == 0 + def test_oldest_item_evicted_on_overflow(self): """When capacity is exceeded, the first inserted key must be evicted.""" cache = self.create_cache(3, [(1, "a"), (2, "b"), (3, "c")]) @@ -1008,6 +1021,19 @@ def create_cache( sweep_interval=sweep_interval, ) + def test_iterator_len_correct_across_ring_wrap(self): + """Evictions wrap the ring buffer; len() must stay correct after every next().""" + cache = self.create_cache(5) + for i in range(9): + cache.insert(f"k{i}", i) + + it = cache.keys() + for left in range(len(cache), 0, -1): + assert len(it) == left + next(it) + + assert len(it) == 0 + def test_global_ttl_property(self): c = self.create_cache(10, global_ttl=5) assert c.global_ttl == 5 @@ -1021,6 +1047,21 @@ def test_global_ttl_property(self): with pytest.raises(ValueError): c = self.create_cache(10, global_ttl=-1) + def test_iterator_len_skips_expired_item_behind_a_live_one(self): + cache = self.create_cache(global_ttl=1) + + cache.insert("A", 1) + cache.insert("B", 2) + time.sleep(0.6) + cache.insert("A", 11) # refreshed, but keeps its place in front of "B" + time.sleep(0.6) + + # "B" is expired, but the sweep stops at the live "A" in front of it + assert len(cache) == 2 + + assert len(cache.keys()) == 1 + assert list(cache.keys()) == ["A"] + def test_global_ttl_with_iterable(self): c = self.create_cache(10, {"A": "B", "C": "D"}, global_ttl=1) assert c.global_ttl == 1 @@ -1508,6 +1549,20 @@ def test_item_expires_after_ttl(self): time.sleep(0.15) assert "k" not in c + def test_iterator_len_skips_item_expired_after_creation(self): + c = self.create_cache() + c.insert("alive", 1, ttl=10) + c.insert("soon", 2, ttl=0.2) + + it = c.keys() + assert len(it) == 2 + + time.sleep(0.25) + assert len(it) == 1 + + # asking for the length does not consume the iterator + assert list(it) == ["alive"] + def test_expired_item_not_returned_by_get(self): c = self.create_cache() c.insert("k", "v", ttl=0.1)