From bc1bc83dca05ac3c5cfea2c91703b68458df6492 Mon Sep 17 00:00:00 2001 From: virgesmith Date: Sun, 5 Jul 2026 09:21:37 +0100 Subject: [PATCH 1/6] Issue #18 enhancements: O(1) peek + next_if, Rust-staple methods, repeat(1) fix - peek() now uses a single-item lookahead buffer (like Rust's Peekable) instead of stacking an itertools.tee layer per call, making repeated peeks O(1). Adaptors flush the buffered item back onto the underlying iterator before consuming it, so nothing is lost. The flush helper is a module-level function because ty 0.0.37 mis-infers generics at some call sites when it is a method or property. - next_if(predicate): consume the next item only if it satisfies the predicate, else leave it peekable and return None (Peekable::next_if). - New methods: sum(), prod(), dedup(), zip_longest(other, fillvalue=...), sorted_by(key, reverse=...). - repeat(1) no longer returns self; it behaves like every other n, returning a new Itr and leaving the original exhausted. Closes #18. Co-Authored-By: Claude Fable 5 --- README.md | 12 +- doc/apidoc.md | 123 +++++++++++++- relnotes.md | 13 ++ src/itrx/itr.py | 255 +++++++++++++++++++++++------- src/test/test_aggregation.py | 29 ++++ src/test/test_collection.py | 66 ++++++++ src/test/test_combine_split.py | 25 +++ src/test/test_transform_filter.py | 38 +++++ 8 files changed, 498 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index dc1a770..facb3cc 100644 --- a/README.md +++ b/README.md @@ -88,21 +88,21 @@ Note: Most `Itr` methods are **lazy transformations**, meaning they return a new `Itr` instance without immediately processing any data. This allows for arbitrary chaining and efficient memory usage, as items are only processed as they are requested. In most cases, `Itr` simply acts as a convenient wrapper around `itertools`, enabling this left-to-right chaining syntax. -- **Combining and splitting:** `partition`, `copy`, `batched`, `pairwise`, `rolling`, `chain`, `cycle`, `repeat`, `product`, `inspect`, `intersperse`, `interleave`, `chunk_by` -- **Transformation and filtering:** `accumulate`, `filter`, `map`, `starmap`, `map_while`, `flatten`, `flat_map`, `skip_while`, `take_while` +- **Combining and splitting:** `partition`, `copy`, `batched`, `pairwise`, `rolling`, `chain`, `cycle`, `repeat`, `product`, `inspect`, `intersperse`, `interleave`, `chunk_by`, `zip_longest` +- **Transformation and filtering:** `accumulate`, `filter`, `map`, `starmap`, `map_while`, `flatten`, `flat_map`, `skip_while`, `take_while`, `dedup` However, some methods are **eager consumers**. These methods iterate over and consume the underlying data, returning concrete values, collections, or aggregates. Examples include: -* **Collection methods:** `collect`, `last`, `next`, `next_chunk`, `nth`, `position` -* **Aggregation methods:** `count`, `reduce`, `max`, `min`, `all`, `any`, `consume`, `find`, `fold` -* **Sorting/grouping:** `groupby` sorts the entire input up front, and `value_counts` counts it (most common first, like pandas), so both consume the whole iterator immediately and must not be used on infinite sources. Use the lazy `chunk_by` to group consecutive runs without sorting. +* **Collection methods:** `collect`, `last`, `next`, `next_chunk`, `next_if`, `nth`, `position` +* **Aggregation methods:** `count`, `reduce`, `max`, `min`, `sum`, `prod`, `all`, `any`, `consume`, `find`, `fold` +* **Sorting/grouping:** `sorted_by` and `groupby` sort the entire input up front, and `value_counts` counts it (most common first, like pandas), so all three consume the whole iterator immediately and must not be used on infinite sources. Use the lazy `chunk_by` to group consecutive runs without sorting. ### Important Considerations When working with `Itr`, keep these points in mind: * **Single-Pass Iterators:** Like all Python iterators, `Itr` instances (and their underlying iterators) can generally only be consumed once. If you need to process the same sequence multiple times, use methods like `copy()`, `cycle()`, or `repeat()` as necessary. -* **No Rewinding:** It's not possible to rewind an `Itr` to an earlier state. You can "preview" the next value using the `peek()` method, but be aware that `peek()` often copies the iterator internally, which can be inefficient if used excessively. +* **No Rewinding:** It's not possible to rewind an `Itr` to an earlier state. You can "preview" the next value using the `peek()` method, which holds the value in a single-item lookahead buffer (so repeated peeks are cheap), and conditionally consume it with `next_if()`. * **Infinite Iterators:** Be cautious with open-ended iterators (e.g., those from `itertools.count()` or custom generators). Eager evaluation methods (like `collect()`, `count()`, `reduce()`) will attempt to consume the entire sequence, potentially leading to infinite loops or out-of-memory errors if applied to an infinite source. ## API Reference diff --git a/doc/apidoc.md b/doc/apidoc.md index 2401bda..44353ff 100644 --- a/doc/apidoc.md +++ b/doc/apidoc.md @@ -167,6 +167,21 @@ Example: (1, 2, 3, 1, 2) +### `dedup` + +Lazily remove *consecutive* duplicate items, keeping the first of each run (like Rust's `dedup`). + +Only adjacent duplicates are removed, so the result may still contain repeated values if they are not +contiguous. Items are compared by equality and do not need to be hashable. Works on infinite iterators. + +Returns: + Itr[T]: An iterator over the items with consecutive duplicates removed. + +Example: + >>> Itr([1, 1, 2, 2, 2, 3, 1]).dedup().collect() + (1, 2, 3, 1) + + ### `enumerate` Yield pairs of (index, item) for each item in the iterator, where index starts at 0 or the value provided @@ -415,6 +430,31 @@ Returns: +### `next_if` + +Consume and return the next item only if it satisfies the predicate (like Rust's `Peekable::next_if`). + +If the next item fails the predicate it is left in place (retrievable by a subsequent `next` or `peek`), +and None is returned. None is also returned if the iterator is exhausted. + +Args: + predicate (Callable[[T], bool]): A function to test the next item. + +Returns: + T | None: The next item if it satisfies the predicate, otherwise None. + +Example: + >>> it = Itr([1, 2, 3]) + >>> it.next_if(lambda x: x < 3) + 1 + >>> it.next_if(lambda x: x < 3) + 2 + >>> it.next_if(lambda x: x < 3) is None + True + >>> it.next() + 3 + + ### `nth` Return the n-th item (0-based) from the iterator, consuming the preceding items. @@ -464,12 +504,23 @@ Returns: Returns the next element in the sequence without advancing the iterator. +The element is held in a single-item lookahead buffer (like Rust's `Peekable`), so repeated calls are O(1) +and return the same item until the iterator is advanced. + Returns: T: The next element in the sequence. -Note: - This method creates a copy of the iterator to avoid modifying the original iterator's state. +Raises: + StopIteration: If the iterator is exhausted. +Example: + >>> it = Itr([1, 2]) + >>> it.peek(), it.peek() + (1, 1) + >>> it.next() + 1 + >>> it.peek() + 2 ### `position` @@ -487,6 +538,20 @@ Raises: StopIteration: If no element satisfies the predicate. +### `prod` + +Return the product of all items in the iterator (1 if empty). NB Consumes the iterator. + +The items must support multiplication (e.g. numbers). + +Returns: + T: The product of all items. + +Example: + >>> Itr([2, 3, 4]).prod() + 24 + + ### `product` @@ -567,6 +632,25 @@ Returns: +### `sorted_by` + +Return an iterator over the items sorted by the given key function. + +This method is **eager**: it consumes and materialises the whole iterator immediately (so it must not be +used on an infinite iterator). The sort is stable: items that compare equal retain their relative order. + +Args: + key (Callable[[T], Any]): A function to extract a comparison key from each item. + reverse (bool): If True, sort in descending order. Defaults to False. + +Returns: + Itr[T]: An iterator over the sorted items. + +Example: + >>> Itr(["ccc", "a", "bb"]).sorted_by(len).collect() + ('a', 'bb', 'ccc') + + ### `starmap` @@ -596,6 +680,20 @@ Returns: +### `sum` + +Return the sum of all items in the iterator (0 if empty). NB Consumes the iterator. + +The items must support addition with int (e.g. numbers; use `reduce` or `fold` for other types). + +Returns: + T: The sum of all items. + +Example: + >>> Itr([1, 2, 3]).sum() + 6 + + ### `take` Return an iterator over the next n items from the iterator. @@ -644,7 +742,7 @@ Notes: that store items produced by the original iterator until all tees have consumed them. If one or more returned iterators lag behind the others, buffered items will be retained and memory usage can grow. -- After calling this method, avoid consuming the original wrapped iterator (`self._it`) +- After calling this method, avoid consuming the original wrapped iterator directly; use the returned Itr objects to prevent surprising interactions with the shared buffer. - Creating the tees is inexpensive, but the memory characteristics depend on how the @@ -703,3 +801,22 @@ Returns: Itr[tuple[T, U]]: An iterator of paired items. + +### `zip_longest` + +Yield pairs of items from this iterator and another iterable, padding the shorter with `fillvalue`. + +Unlike `zip`, iteration continues until the longer input is exhausted, with missing values replaced by +`fillvalue`. + +Args: + other (Iterable[U]): The other iterable. + fillvalue (V | None): The value used to pad the shorter input. Defaults to None. + +Returns: + Itr[tuple[T | V | None, U | V | None]]: An iterator of paired items. + +Example: + >>> Itr([1, 2, 3]).zip_longest("ab", fillvalue="-").collect() + ((1, 'a'), (2, 'b'), (3, '-')) + diff --git a/relnotes.md b/relnotes.md index 53a4477..cdabbed 100644 --- a/relnotes.md +++ b/relnotes.md @@ -4,6 +4,19 @@ - `value_counts` now behaves like pandas' `value_counts`: results are ordered most-common-first (ties by first appearance) instead of sorted by key. Items now only need to be hashable rather than orderable, and counting is O(n) rather than O(n log n). - `tee` now raises `ValueError` when `n < 1`, as its docstring has always stated (previously `tee(0)` silently discarded the iterator and returned an empty tuple). +- `repeat(1)` now returns a new `Itr` and leaves the original exhausted, consistent with every other value of `n` (previously it returned `self`, so consuming the result also consumed the original in that case only). + +### New features + +- `next_if(predicate)`: consume and return the next item only if it satisfies the predicate, otherwise leave it in place and return `None` (like Rust's `Peekable::next_if`). +- `sum()` / `prod()`: terminal aggregations over the remaining items. +- `dedup()`: lazily remove *consecutive* duplicates, keeping the first of each run (like Rust's `dedup`); works on infinite iterators. +- `zip_longest(other, fillvalue=...)`: like `zip`, but continues to the end of the longer input, padding with `fillvalue`. +- `sorted_by(key, reverse=...)`: eager stable sort by a key function. + +### Improvements + +- `peek` now uses a single-item lookahead buffer (like Rust's `Peekable`) instead of copying the iterator with `itertools.tee`, so repeated peeks are O(1) and no longer build up nested tee layers. ### Bug fixes diff --git a/src/itrx/itr.py b/src/itrx/itr.py index cd1ad2e..12f48cf 100644 --- a/src/itrx/itr.py +++ b/src/itrx/itr.py @@ -1,4 +1,5 @@ import itertools +import math from collections import Counter, deque from collections.abc import Callable, Generator, Iterable, Iterator from typing import Any, TypeVar, cast, overload @@ -8,6 +9,20 @@ Predicate = Callable[[T], bool] +_MISSING: Any = object() # sentinel for an empty peek buffer, since None is a valid item + + +def _flushed[U](itr: "Itr[U]") -> Iterator[U]: + """Return the underlying iterator, pushing any peeked item back onto it first. + + Free function rather than a method: ty 0.0.37 mis-infers generics at some call sites when this is a method or + property on Itr. + """ + if itr._peeked is not _MISSING: + itr._inner = itertools.chain((cast("U", itr._peeked),), itr._inner) + itr._peeked = _MISSING + return itr._inner + class Itr[T](Iterator[T]): """A generic iterator adaptor class inspired by Rust's Iterator trait, providing a composable API for @@ -21,7 +36,8 @@ def __init__(self, it: Iterable[T]) -> None: it (Iterable[T]): The iterable to wrap. """ - self._it = iter(it) + self._peeked: Any = _MISSING + self._inner = iter(it) def __iter__(self) -> Iterator[T]: "Implement the iter method of the Iterator protocol" @@ -29,7 +45,11 @@ def __iter__(self) -> Iterator[T]: def __next__(self) -> T: "Implement the next method of the Iterator protocol" - return next(self._it) + if self._peeked is not _MISSING: + item = cast("T", self._peeked) + self._peeked = _MISSING + return item + return next(self._inner) def accumulate(self, func: Callable[[T, T], T] | None = None, *, initial: T | None = None) -> "Itr[T]": """ @@ -50,9 +70,7 @@ def accumulate(self, func: Callable[[T, T], T] | None = None, *, initial: T | No >>> list(Itr([2, 3, 4]).accumulate(lambda x, y: x * y)) [2, 6, 24] """ - return Itr( - itertools.accumulate(self._it, func, initial=initial) - ) # if func else itertools.accumulate(self._it)) + return Itr(itertools.accumulate(_flushed(self), func, initial=initial)) def all(self, predicate: Predicate[T]) -> bool: """Return True if all elements in the iterator satisfy the predicate. @@ -64,7 +82,7 @@ def all(self, predicate: Predicate[T]) -> bool: bool: True if all elements satisfy the predicate, False otherwise. """ - return all(predicate(item) for item in self._it) + return all(predicate(item) for item in _flushed(self)) def any(self, predicate: Predicate[T]) -> bool: """Return True if any element in the iterator satisfies the predicate. @@ -76,7 +94,7 @@ def any(self, predicate: Predicate[T]) -> bool: bool: True if any element satisfies the predicate, False otherwise. """ - return any(predicate(item) for item in self._it) + return any(predicate(item) for item in _flushed(self)) def batched(self, n: int) -> "Itr[tuple[T, ...]]": """ @@ -95,7 +113,7 @@ def batched(self, n: int) -> "Itr[tuple[T, ...]]": >>> list(Itr(range(7)).batched(3)) [(0, 1, 2), (3, 4, 5), (6,)] """ - return cast("Itr[tuple[T, ...]]", Itr(itertools.batched(self._it, n))) + return cast("Itr[tuple[T, ...]]", Itr(itertools.batched(_flushed(self), n))) def chain[U](self, other: Iterable[U]) -> "Itr[T | U]": """Chain this iterator with another iterable, yielding all items from self followed by all items from other. @@ -108,7 +126,7 @@ def chain[U](self, other: Iterable[U]) -> "Itr[T | U]": """ - return cast("Itr[T | U]", Itr(itertools.chain(self._it, other))) + return cast("Itr[T | U]", Itr(itertools.chain(_flushed(self), other))) @overload def collect(self, container: type[tuple[T, ...]] = tuple) -> tuple[T, ...]: ... @@ -126,7 +144,7 @@ def collect(self, container: type[_CollectT] = tuple) -> _CollectT: # ty: ignor _CollectT: The remaining items collected into the given container type. """ - return container(self._it) + return container(_flushed(self)) def consume(self) -> None: """Exhaust the iterator. Useful when only the side effects are required (see inspect) @@ -136,7 +154,7 @@ def consume(self) -> None: Returns: None """ - deque(self._it, 0) + deque(_flushed(self), 0) def copy(self) -> "Itr[T]": """Splits the iterator at its *current state* into two independent iterators. @@ -145,7 +163,7 @@ def copy(self) -> "Itr[T]": Itr[T]: A new Itr instance wrapping one copy of the original iterator. """ - self._it, it = itertools.tee(self._it) + self._inner, it = itertools.tee(_flushed(self)) return Itr(it) def count(self) -> int: @@ -155,7 +173,7 @@ def count(self) -> int: int: The number of remaining items. """ - return sum(1 for _ in self._it) + return sum(1 for _ in _flushed(self)) def cycle(self) -> "Itr[T]": """ @@ -170,7 +188,22 @@ def cycle(self) -> "Itr[T]": >>> cycler.take(5).collect() (1, 2, 3, 1, 2) """ - return Itr(itertools.cycle(self._it)) + return Itr(itertools.cycle(_flushed(self))) + + def dedup(self) -> "Itr[T]": + """Lazily remove *consecutive* duplicate items, keeping the first of each run (like Rust's `dedup`). + + Only adjacent duplicates are removed, so the result may still contain repeated values if they are not + contiguous. Items are compared by equality and do not need to be hashable. Works on infinite iterators. + + Returns: + Itr[T]: An iterator over the items with consecutive duplicates removed. + + Example: + >>> Itr([1, 1, 2, 2, 2, 3, 1]).dedup().collect() + (1, 2, 3, 1) + """ + return Itr(k for k, _ in itertools.groupby(_flushed(self))) def enumerate(self, *, start: int = 0) -> "Itr[tuple[int, T]]": """Yield pairs of (index, item) for each item in the iterator, where index starts at 0 or the value provided @@ -179,7 +212,7 @@ def enumerate(self, *, start: int = 0) -> "Itr[tuple[int, T]]": Itr[tuple[int, T]]: An iterator of (index, item) pairs. """ - return cast("Itr[tuple[int, T]]", Itr(enumerate(self._it, start))) + return cast("Itr[tuple[int, T]]", Itr(enumerate(_flushed(self), start))) def filter(self, predicate: Predicate[T]) -> "Itr[T]": """Yield only items that satisfy the predicate. @@ -191,7 +224,7 @@ def filter(self, predicate: Predicate[T]) -> "Itr[T]": Itr[T]: An iterator of filtered items. """ - return Itr(filter(predicate, self._it)) + return Itr(filter(predicate, _flushed(self))) def find(self, predicate: Predicate[T]) -> T | None: """Return the first item in the iterator that satisfies the predicate, or None if not found. @@ -203,7 +236,7 @@ def find(self, predicate: Predicate[T]) -> T | None: T | None: The first matching item, or None. """ - return next(filter(predicate, self._it), None) + return next(filter(predicate, _flushed(self)), None) def flat_map[U](self, mapper: Callable[[T], Iterable[U]]) -> "Itr[U]": """Map each item to an iterable, then flatten one level. @@ -229,7 +262,7 @@ def flatten[U](self) -> "Itr[U]": Itr[U]: An iterator over the flattened items. """ - return Itr(itertools.chain.from_iterable(cast("Iterable[Iterable[U]]", self._it))) + return Itr(itertools.chain.from_iterable(cast("Iterable[Iterable[U]]", _flushed(self)))) def fold[U](self, init: U, func: Callable[[U, T], U]) -> U: """Reduce the iterator to a single value using a function and an initial value. @@ -243,7 +276,7 @@ def fold[U](self, init: U, func: Callable[[U, T], U]) -> U: """ result = init - for item in self._it: + for item in _flushed(self): result = func(result, item) return result @@ -254,7 +287,7 @@ def for_each(self, func: Callable[[T], None]) -> None: func (Callable[[T], None]): The function to apply. """ - for item in self._it: + for item in _flushed(self): func(item) def chunk_by[U](self, grouper: Callable[[T], U]) -> "Itr[tuple[U, tuple[T, ...]]]": @@ -275,7 +308,7 @@ def chunk_by[U](self, grouper: Callable[[T], U]) -> "Itr[tuple[U, tuple[T, ...]] (1, 2, 3, 1) """ key_fn = cast("Callable[[T], Any]", grouper) - groups = ((k, tuple(v)) for k, v in itertools.groupby(self._it, key=key_fn)) + groups = ((k, tuple(v)) for k, v in itertools.groupby(_flushed(self), key=key_fn)) return cast("Itr[tuple[U, tuple[T, ...]]]", Itr(groups)) def groupby[U](self, grouper: Callable[[T], U]) -> "Itr[tuple[U, tuple[T,...]]]": @@ -297,7 +330,7 @@ def groupby[U](self, grouper: Callable[[T], U]) -> "Itr[tuple[U, tuple[T,...]]]" """ key_fn = cast("Callable[[T], Any]", grouper) - groups = ((k, tuple(v)) for k, v in itertools.groupby(sorted(self._it, key=key_fn), key=key_fn)) + groups = ((k, tuple(v)) for k, v in itertools.groupby(sorted(_flushed(self), key=key_fn), key=key_fn)) return cast("Itr[tuple[U, tuple[T, ...]]]", Itr(groups)) def inspect(self, func: Callable[[T], None]) -> "Itr[T]": @@ -339,10 +372,10 @@ def intersperse[U](self, item: U) -> "Itr[T | U]": def intersperser(item: U) -> Generator[T | U, None, None]: try: - current = next(self._it) + current = next(_flushed(self)) while True: yield current - current = next(self._it) + current = next(_flushed(self)) yield item except StopIteration: return @@ -369,7 +402,7 @@ def interleave[U](self, other: Iterable[U]) -> "Itr[T | U]": _sentinel = object() def interleaver() -> Generator[T | U, None, None]: - for pair in itertools.zip_longest(self._it, other, fillvalue=_sentinel): + for pair in itertools.zip_longest(_flushed(self), other, fillvalue=_sentinel): for item in pair: if item is not _sentinel: yield cast("T | U", item) @@ -386,7 +419,7 @@ def last(self) -> T: ValueError: If the iterator is empty. """ - *_, last_item = self._it + *_, last_item = _flushed(self) return last_item def map[U](self, mapper: Callable[[T], U]) -> "Itr[U]": @@ -399,7 +432,7 @@ def map[U](self, mapper: Callable[[T], U]) -> "Itr[U]": Itr[U]: An iterator of mapped items. """ - return Itr(map(mapper, self._it)) + return Itr(map(mapper, _flushed(self))) def map_dict[U](self, mapper: dict[T, U]) -> "Itr[U]": """Map each item in the iterator using the given dictionary (supports defaultdict). @@ -411,7 +444,7 @@ def map_dict[U](self, mapper: dict[T, U]) -> "Itr[U]": Itr[U]: An iterator of mapped items. """ - return Itr(mapper[m] for m in self._it) + return Itr(mapper[m] for m in _flushed(self)) def map_while[U](self, predicate: Predicate[T], mapper: Callable[[T], U]) -> "Itr[U]": """Map each item in the iterator using the given function, while the predicate remains True. @@ -424,7 +457,7 @@ def map_while[U](self, predicate: Predicate[T], mapper: Callable[[T], U]) -> "It Itr[U]: An iterator of mapped items. """ - return Itr(map(mapper, itertools.takewhile(predicate, self._it))) + return Itr(map(mapper, itertools.takewhile(predicate, _flushed(self)))) def max(self, key: Callable[[T], Any] | None = None) -> T: """ @@ -439,7 +472,7 @@ def max(self, key: Callable[[T], Any] | None = None) -> T: Raises: ValueError: If the iterator is empty. """ - return max(self._it, key=key) + return max(_flushed(self), key=key) def min(self, key: Callable[[T], Any] | None = None) -> T: """ @@ -454,7 +487,7 @@ def min(self, key: Callable[[T], Any] | None = None) -> T: Raises: ValueError: If the iterator is empty. """ - return min(self._it, key=key) + return min(_flushed(self), key=key) def next(self) -> T: """Return the next item from the iterator, if available. Otherwise raises StopIteration @@ -463,7 +496,7 @@ def next(self) -> T: T: The next item. """ - return next(self._it) + return next(self) def next_chunk(self, n: int) -> tuple[T, ...]: """Return a tuple of the next n items from the iterator. @@ -477,6 +510,38 @@ def next_chunk(self, n: int) -> tuple[T, ...]: """ return self.take(n).collect() + def next_if(self, predicate: Predicate[T]) -> T | None: + """Consume and return the next item only if it satisfies the predicate (like Rust's `Peekable::next_if`). + + If the next item fails the predicate it is left in place (retrievable by a subsequent `next` or `peek`), + and None is returned. None is also returned if the iterator is exhausted. + + Args: + predicate (Callable[[T], bool]): A function to test the next item. + + Returns: + T | None: The next item if it satisfies the predicate, otherwise None. + + Example: + >>> it = Itr([1, 2, 3]) + >>> it.next_if(lambda x: x < 3) + 1 + >>> it.next_if(lambda x: x < 3) + 2 + >>> it.next_if(lambda x: x < 3) is None + True + >>> it.next() + 3 + """ + try: + item = self.peek() + except StopIteration: + return None + if predicate(item): + self._peeked = _MISSING + return item + return None + def nth(self, n: int) -> T: """Return the n-th item (0-based) from the iterator, consuming the preceding items. @@ -508,7 +573,7 @@ def pairwise(self) -> "Itr[tuple[T, T]]": Itr[tuple[T, T]]: An iterator over consecutive pairs from the original iterable. """ - return cast("Itr[tuple[T, T]]", Itr(itertools.pairwise(self._it))) + return cast("Itr[tuple[T, T]]", Itr(itertools.pairwise(_flushed(self)))) def partition(self, predicate: Predicate[T]) -> tuple["Itr[T]", "Itr[T]"]: """ @@ -528,14 +593,27 @@ def partition(self, predicate: Predicate[T]) -> tuple["Itr[T]", "Itr[T]"]: def peek(self) -> T: """Returns the next element in the sequence without advancing the iterator. + The element is held in a single-item lookahead buffer (like Rust's `Peekable`), so repeated calls are O(1) + and return the same item until the iterator is advanced. + Returns: T: The next element in the sequence. - Note: - This method creates a copy of the iterator to avoid modifying the original iterator's state. + Raises: + StopIteration: If the iterator is exhausted. + Example: + >>> it = Itr([1, 2]) + >>> it.peek(), it.peek() + (1, 1) + >>> it.next() + 1 + >>> it.peek() + 2 """ - return self.copy().next() + if self._peeked is _MISSING: + self._peeked = next(self._inner) + return cast("T", self._peeked) def position(self, predicate: Predicate[T]) -> int: """ @@ -552,6 +630,20 @@ def position(self, predicate: Predicate[T]) -> int: """ return self.enumerate().skip_while(lambda x: not predicate(x[1])).next()[0] + def prod(self) -> T: + """Return the product of all items in the iterator (1 if empty). NB Consumes the iterator. + + The items must support multiplication (e.g. numbers). + + Returns: + T: The product of all items. + + Example: + >>> Itr([2, 3, 4]).prod() + 24 + """ + return cast("T", math.prod(cast("Iterable[Any]", _flushed(self)))) + def product[U](self, other: Iterable[U]) -> "Itr[tuple[T, U]]": """ Creates a new iterator over the cartesian product of self and the other iterator @@ -562,7 +654,7 @@ def product[U](self, other: Iterable[U]) -> "Itr[tuple[T, U]]": Returns: Itr[tuple[T, U]]: Iterator of 2-tuples with elements from each input iterator. """ - return cast("Itr[tuple[T, U]]", Itr(itertools.product(self._it, other))) + return cast("Itr[tuple[T, U]]", Itr(itertools.product(_flushed(self), other))) def reduce(self, func: Callable[[T, T], T]) -> T: """Reduce the iterator to a single value using a function. @@ -575,7 +667,7 @@ def reduce(self, func: Callable[[T, T], T]) -> T: T: The final reduced value. """ - return self.fold(next(self._it), func) + return self.fold(next(_flushed(self)), func) def repeat(self, n: int) -> "Itr[T]": """ @@ -590,10 +682,8 @@ def repeat(self, n: int) -> "Itr[T]": Note: This implementation creates `n` independent iterators using `itertools.tee`, which may be inefficient for large `n` or large input iterators. """ - if n == 1: - return self # this creates n iterators so may be inefficient - return Itr(itertools.chain(*itertools.tee(self._it, n))) + return Itr(itertools.chain(*itertools.tee(_flushed(self), n))) def rev(self) -> "Itr[T]": """Return a reversed iterator over the remaining items (materializes the sequence). @@ -603,7 +693,7 @@ def rev(self) -> "Itr[T]": """ # it's generally impossible to do this without materialising the entire sequence - return Itr(tuple(self._it)[::-1]) + return Itr(tuple(_flushed(self))[::-1]) def rolling(self, n: int) -> "Itr[tuple[T, ...]]": """ @@ -613,7 +703,7 @@ def rolling(self, n: int) -> "Itr[tuple[T, ...]]": if n < 1: raise ValueError(f"Invalid rolling window {n} (must be at least 1)") - iterators = itertools.tee(self._it, n) + iterators = itertools.tee(_flushed(self), n) shifted_iterators = (itertools.islice(it, i, None) for i, it in enumerate(iterators)) return cast("Itr[tuple[T, ...]]", Itr(zip(*shifted_iterators, strict=False))) @@ -627,7 +717,7 @@ def skip(self, n: int) -> "Itr[T]": Itr[T]: An iterator over the remaining items. """ - return Itr(itertools.islice(self._it, n, None)) + return Itr(itertools.islice(_flushed(self), n, None)) def skip_while(self, predicate: Predicate[T]) -> "Itr[T]": """Skip items in the iterator as long as the predicate is true. @@ -639,7 +729,26 @@ def skip_while(self, predicate: Predicate[T]) -> "Itr[T]": Itr[T]: An iterator over the remaining items once the predicate first fails. """ - return Itr(itertools.dropwhile(predicate, self._it)) + return Itr(itertools.dropwhile(predicate, _flushed(self))) + + def sorted_by(self, key: Callable[[T], Any], *, reverse: bool = False) -> "Itr[T]": + """Return an iterator over the items sorted by the given key function. + + This method is **eager**: it consumes and materialises the whole iterator immediately (so it must not be + used on an infinite iterator). The sort is stable: items that compare equal retain their relative order. + + Args: + key (Callable[[T], Any]): A function to extract a comparison key from each item. + reverse (bool): If True, sort in descending order. Defaults to False. + + Returns: + Itr[T]: An iterator over the sorted items. + + Example: + >>> Itr(["ccc", "a", "bb"]).sorted_by(len).collect() + ('a', 'bb', 'ccc') + """ + return Itr(sorted(_flushed(self), key=key, reverse=reverse)) def starmap[U](self, func: Callable[..., U]) -> "Itr[U]": """ @@ -656,7 +765,7 @@ def starmap[U](self, func: Callable[..., U]) -> "Itr[U]": >>> list(itr.starmap(lambda x, y: x + y)) [3, 7] """ - return Itr(itertools.starmap(func, cast("Iterable[Iterable[Any]]", self._it))) + return Itr(itertools.starmap(func, cast("Iterable[Iterable[Any]]", _flushed(self)))) def step_by(self, n: int) -> "Itr[T]": """Yield every n-th item from the iterator. @@ -668,7 +777,21 @@ def step_by(self, n: int) -> "Itr[T]": Itr[T]: An iterator yielding every n-th item. """ - return Itr(itertools.islice(self._it, 0, None, n)) + return Itr(itertools.islice(_flushed(self), 0, None, n)) + + def sum(self) -> T: + """Return the sum of all items in the iterator (0 if empty). NB Consumes the iterator. + + The items must support addition with int (e.g. numbers; use `reduce` or `fold` for other types). + + Returns: + T: The sum of all items. + + Example: + >>> Itr([1, 2, 3]).sum() + 6 + """ + return cast("T", sum(cast("Iterable[Any]", _flushed(self)))) def take(self, n: int) -> "Itr[T]": """Return an iterator over the next n items from the iterator. @@ -680,7 +803,7 @@ def take(self, n: int) -> "Itr[T]": Itr[T]: An iterator over the next n items. """ - return Itr(itertools.islice(self._it, n)) + return Itr(itertools.islice(_flushed(self), n)) def take_while(self, predicate: Predicate[T]) -> "Itr[T]": """Yield items from the iterator as long as the given predicate is true. @@ -692,7 +815,7 @@ def take_while(self, predicate: Predicate[T]) -> "Itr[T]": Itr[T]: A new Itr yielding items while the predicate is true. """ - return Itr(itertools.takewhile(predicate, self._it)) + return Itr(itertools.takewhile(predicate, _flushed(self))) def tee(self, n: int = 2) -> tuple["Itr[T]", ...]: """ @@ -717,7 +840,7 @@ def tee(self, n: int = 2) -> tuple["Itr[T]", ...]: that store items produced by the original iterator until all tees have consumed them. If one or more returned iterators lag behind the others, buffered items will be retained and memory usage can grow. - - After calling this method, avoid consuming the original wrapped iterator (`self._it`) + - After calling this method, avoid consuming the original wrapped iterator directly; use the returned Itr objects to prevent surprising interactions with the shared buffer. - Creating the tees is inexpensive, but the memory characteristics depend on how the @@ -733,7 +856,7 @@ def tee(self, n: int = 2) -> tuple["Itr[T]", ...]: """ if n < 1: raise ValueError(f"tee requires at least 1 iterator, got {n}") - return tuple(Itr(t) for t in itertools.tee(self._it, n)) + return tuple(Itr(t) for t in itertools.tee(_flushed(self), n)) def unzip[U, V](self: "Itr[tuple[U, V]]") -> tuple["Itr[U]", "Itr[V]"]: """Splits the iterator of pairs into two separate iterators, each containing the elements from one position of @@ -748,7 +871,7 @@ def unzip[U, V](self: "Itr[tuple[U, V]]") -> tuple["Itr[U]", "Itr[V]"]: and then maps over each to extract the respective elements. """ - it1, it2 = itertools.tee(self._it, 2) + it1, it2 = itertools.tee(_flushed(self), 2) return Itr(x[0] for x in it1), Itr(x[1] for x in it2) def value_counts(self) -> "Itr[tuple[T, int]]": @@ -767,7 +890,7 @@ def value_counts(self) -> "Itr[tuple[T, int]]": >>> Itr("abracadabra").value_counts().collect() (('a', 5), ('b', 2), ('r', 2), ('c', 1), ('d', 1)) """ - return cast("Itr[tuple[T, int]]", Itr(Counter(self._it).most_common())) + return cast("Itr[tuple[T, int]]", Itr(Counter(_flushed(self)).most_common())) def zip[U](self, other: Iterable[U]) -> "Itr[tuple[T, U]]": """Yield pairs of items from this iterator and another iterable. @@ -779,4 +902,28 @@ def zip[U](self, other: Iterable[U]) -> "Itr[tuple[T, U]]": Itr[tuple[T, U]]: An iterator of paired items. """ - return cast("Itr[tuple[T, U]]", Itr(zip(self._it, other, strict=False))) + return cast("Itr[tuple[T, U]]", Itr(zip(_flushed(self), other, strict=False))) + + def zip_longest[U, V]( + self, other: Iterable[U], *, fillvalue: V | None = None + ) -> "Itr[tuple[T | V | None, U | V | None]]": + """Yield pairs of items from this iterator and another iterable, padding the shorter with `fillvalue`. + + Unlike `zip`, iteration continues until the longer input is exhausted, with missing values replaced by + `fillvalue`. + + Args: + other (Iterable[U]): The other iterable. + fillvalue (V | None): The value used to pad the shorter input. Defaults to None. + + Returns: + Itr[tuple[T | V | None, U | V | None]]: An iterator of paired items. + + Example: + >>> Itr([1, 2, 3]).zip_longest("ab", fillvalue="-").collect() + ((1, 'a'), (2, 'b'), (3, '-')) + """ + return cast( + "Itr[tuple[T | V | None, U | V | None]]", + Itr(itertools.zip_longest(_flushed(self), other, fillvalue=fillvalue)), + ) diff --git a/src/test/test_aggregation.py b/src/test/test_aggregation.py index ba1ec49..5848fe1 100644 --- a/src/test/test_aggregation.py +++ b/src/test/test_aggregation.py @@ -185,3 +185,32 @@ def test_value_counts_consumes_iterator() -> None: _ = itr.value_counts() with pytest.raises(StopIteration): itr.next() + + +def test_sum() -> None: + assert Itr([1, 2, 3]).sum() == 6 + assert Itr([1.5, 2.5]).sum() == 4.0 + + +def test_sum_empty() -> None: + assert Itr[int]([]).sum() == 0 + + +def test_sum_consumes_iterator() -> None: + it = Itr([1, 2, 3]) + assert it.sum() == 6 + with pytest.raises(StopIteration): + it.next() + + +def test_prod() -> None: + assert Itr([2, 3, 4]).prod() == 24 + assert Itr([0.5, 4.0]).prod() == 2.0 + + +def test_prod_empty() -> None: + assert Itr[int]([]).prod() == 1 + + +def test_prod_with_zero() -> None: + assert Itr([1, 0, 5]).prod() == 0 diff --git a/src/test/test_collection.py b/src/test/test_collection.py index f655a5a..be018d8 100644 --- a/src/test/test_collection.py +++ b/src/test/test_collection.py @@ -87,3 +87,69 @@ def test_peek_on_empty_iterator_raises() -> None: it = Itr[bool]([]) with pytest.raises(StopIteration): it.peek() + + +def test_peek_repeated_is_stable() -> None: + it = Itr([1, 2]) + assert it.peek() == 1 + assert it.peek() == 1 + assert it.next() == 1 + assert it.peek() == 2 + + +def test_peek_does_not_deepen_iterator() -> None: + # peek is O(1): it must not wrap the underlying iterator in a new layer on each call + it = Itr([1, 2]) + for _ in range(10000): + assert it.peek() == 1 + assert it.collect() == (1, 2) + + +def test_peek_handles_none_values() -> None: + it = Itr([None, 1]) + assert it.peek() is None + assert it.next() is None + assert it.next() == 1 + + +def test_peeked_item_seen_by_adaptors() -> None: + # the buffered item must be pushed back before any adaptor consumes the underlying iterator + it = Itr([1, 2, 3]) + assert it.peek() == 1 + assert it.map(lambda x: x * 10).collect() == (10, 20, 30) + + +def test_peeked_item_seen_by_copy() -> None: + it = Itr([1, 2, 3]) + assert it.peek() == 1 + copied = it.copy() + assert copied.collect() == (1, 2, 3) + assert it.collect() == (1, 2, 3) + + +def test_peeked_item_seen_by_for_loop() -> None: + it = Itr([1, 2, 3]) + assert it.peek() == 1 + assert list(it) == [1, 2, 3] + + +def test_next_if_consumes_on_match() -> None: + it = Itr([1, 2, 3]) + assert it.next_if(lambda x: x < 3) == 1 + assert it.next_if(lambda x: x < 3) == 2 + assert it.next_if(lambda x: x < 3) is None + # the non-matching item is not consumed + assert it.next() == 3 + + +def test_next_if_exhausted_returns_none() -> None: + it = Itr[int]([]) + assert it.next_if(lambda _: True) is None + + +def test_next_if_after_peek() -> None: + it = Itr([5, 6]) + assert it.peek() == 5 + assert it.next_if(lambda x: x == 5) == 5 + assert it.next_if(lambda x: x == 5) is None + assert it.peek() == 6 diff --git a/src/test/test_combine_split.py b/src/test/test_combine_split.py index f0d5209..f7fbb61 100644 --- a/src/test/test_combine_split.py +++ b/src/test/test_combine_split.py @@ -170,6 +170,15 @@ def test_repeat_one() -> None: assert it.collect() == (4, 5) +def test_repeat_one_returns_new_itr() -> None: + # repeat(1) behaves like any other n: a new Itr is returned and the original is left exhausted + it = Itr([4, 5]) + repeated = it.repeat(1) + assert repeated is not it + assert repeated.collect() == (4, 5) + assert it.collect() == () + + def test_repeat_empty_iterable() -> None: it: Itr[int] = Itr([]).repeat(3) # Repeating an empty iterable should yield nothing @@ -422,3 +431,19 @@ def test_tee_iterators_are_distinct_objects() -> None: # ensure both still yield the same items assert list(a) == [7, 8, 9] assert list(b) == [7, 8, 9] + + +def test_zip_longest_equal_length() -> None: + assert Itr([1, 2]).zip_longest("ab").collect() == ((1, "a"), (2, "b")) + + +def test_zip_longest_left_longer() -> None: + assert Itr([1, 2, 3]).zip_longest("a").collect() == ((1, "a"), (2, None), (3, None)) + + +def test_zip_longest_right_longer_with_fillvalue() -> None: + assert Itr([1]).zip_longest("abc", fillvalue=0).collect() == ((1, "a"), (0, "b"), (0, "c")) + + +def test_zip_longest_both_empty() -> None: + assert Itr[int]([]).zip_longest([]).collect() == () diff --git a/src/test/test_transform_filter.py b/src/test/test_transform_filter.py index 5eab93d..61bd33a 100644 --- a/src/test/test_transform_filter.py +++ b/src/test/test_transform_filter.py @@ -206,3 +206,41 @@ def test_chunk_by_lazy_on_infinite() -> None: # chunk_by is lazy, so it works on unbounded iterators counts = Itr(itertools.count()).chunk_by(lambda n: n // 2).take(3).collect() assert counts == ((0, (0, 1)), (1, (2, 3)), (2, (4, 5))) + + +def test_dedup() -> None: + assert Itr([1, 1, 2, 2, 2, 3, 1]).dedup().collect() == (1, 2, 3, 1) + + +def test_dedup_no_duplicates() -> None: + assert Itr([1, 2, 3]).dedup().collect() == (1, 2, 3) + + +def test_dedup_empty() -> None: + assert Itr[int]([]).dedup().collect() == () + + +def test_dedup_unhashable_items() -> None: + assert Itr([[1], [1], [2]]).dedup().collect() == ([1], [2]) + + +def test_dedup_lazy_on_infinite() -> None: + it = Itr(itertools.count()).flat_map(lambda x: (x, x)).dedup() + assert it.take(3).collect() == (0, 1, 2) + + +def test_sorted_by() -> None: + assert Itr(["ccc", "a", "bb"]).sorted_by(len).collect() == ("a", "bb", "ccc") + + +def test_sorted_by_reverse() -> None: + assert Itr([1, 3, 2]).sorted_by(lambda x: x, reverse=True).collect() == (3, 2, 1) + + +def test_sorted_by_is_stable() -> None: + data = [(1, "b"), (0, "a"), (1, "a"), (0, "b")] + assert Itr(data).sorted_by(lambda x: x[0]).collect() == ((0, "a"), (0, "b"), (1, "b"), (1, "a")) + + +def test_sorted_by_empty() -> None: + assert Itr[int]([]).sorted_by(lambda x: x).collect() == () From 11c8202b117885def2d5db81b37c53f77437ad6b Mon Sep 17 00:00:00 2001 From: virgesmith Date: Sun, 5 Jul 2026 12:34:31 +0100 Subject: [PATCH 2/6] Replace ty workaround with _Peekable wrapper; unpin dev tools The peek buffer now lives in a private _Peekable iterator wrapper (mirroring Rust, where Peekable is its own adapter type) instead of on Itr itself. Since the buffered item is yielded by _Peekable.__next__, adaptors consuming self._it automatically see the full sequence and no flush accessor is needed - removing the module-level _flushed() helper that existed only because ty mis-infers generics when the accessor is a method or property (still broken in ty 0.0.56, but now irrelevant). Dev deps are ranges again instead of the ty==0.0.37 pin: ty>=0.0.56, ruff>=0.15.20, pytest>=9.1.1. All gates pass on the new versions. Also ignore .claude/ in .gitignore. Co-Authored-By: Claude Fable 5 --- .gitignore | 3 +- pyproject.toml | 6 +- src/itrx/itr.py | 147 +++++++++++++++++++++++++----------------------- uv.lock | 96 +++++++++++++++---------------- 4 files changed, 129 insertions(+), 123 deletions(-) diff --git a/.gitignore b/.gitignore index b18f122..16ac5e5 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,5 @@ coverage.info .python-version .coverage -htmlcov/ \ No newline at end of file +htmlcov/ +.claude \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 1ddd434..90265b3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,10 +27,10 @@ build-backend = "hatchling.build" [dependency-groups] dev = [ "pre-commit>=4.5.1", - "pytest>=8.4.1", + "pytest>=9.1.1", "pytest-cov>=6.2.1", - "ruff>=0.15.13", - "ty==0.0.37", + "ruff>=0.15.20", + "ty>=0.0.56", ] [project.optional-dependencies] diff --git a/src/itrx/itr.py b/src/itrx/itr.py index 12f48cf..c41c1f5 100644 --- a/src/itrx/itr.py +++ b/src/itrx/itr.py @@ -12,16 +12,28 @@ _MISSING: Any = object() # sentinel for an empty peek buffer, since None is a valid item -def _flushed[U](itr: "Itr[U]") -> Iterator[U]: - """Return the underlying iterator, pushing any peeked item back onto it first. +class _Peekable[U](Iterator[U]): + """An iterator with a single-item lookahead buffer (like Rust's `Peekable`). - Free function rather than a method: ty 0.0.37 mis-infers generics at some call sites when this is a method or - property on Itr. + A peeked item stays part of the iteration: `__next__` yields it before drawing from the underlying iterator, + so anything consuming this iterator sees the full sequence. """ - if itr._peeked is not _MISSING: - itr._inner = itertools.chain((cast("U", itr._peeked),), itr._inner) - itr._peeked = _MISSING - return itr._inner + + def __init__(self, it: Iterable[U]) -> None: + self._it = iter(it) + self._peeked: Any = _MISSING + + def __next__(self) -> U: + if self._peeked is not _MISSING: + item = cast("U", self._peeked) + self._peeked = _MISSING + return item + return next(self._it) + + def peek(self) -> U: + if self._peeked is _MISSING: + self._peeked = next(self._it) + return cast("U", self._peeked) class Itr[T](Iterator[T]): @@ -36,8 +48,7 @@ def __init__(self, it: Iterable[T]) -> None: it (Iterable[T]): The iterable to wrap. """ - self._peeked: Any = _MISSING - self._inner = iter(it) + self._it = _Peekable(it) def __iter__(self) -> Iterator[T]: "Implement the iter method of the Iterator protocol" @@ -45,11 +56,7 @@ def __iter__(self) -> Iterator[T]: def __next__(self) -> T: "Implement the next method of the Iterator protocol" - if self._peeked is not _MISSING: - item = cast("T", self._peeked) - self._peeked = _MISSING - return item - return next(self._inner) + return next(self._it) def accumulate(self, func: Callable[[T, T], T] | None = None, *, initial: T | None = None) -> "Itr[T]": """ @@ -70,7 +77,7 @@ def accumulate(self, func: Callable[[T, T], T] | None = None, *, initial: T | No >>> list(Itr([2, 3, 4]).accumulate(lambda x, y: x * y)) [2, 6, 24] """ - return Itr(itertools.accumulate(_flushed(self), func, initial=initial)) + return Itr(itertools.accumulate(self._it, func, initial=initial)) def all(self, predicate: Predicate[T]) -> bool: """Return True if all elements in the iterator satisfy the predicate. @@ -82,7 +89,7 @@ def all(self, predicate: Predicate[T]) -> bool: bool: True if all elements satisfy the predicate, False otherwise. """ - return all(predicate(item) for item in _flushed(self)) + return all(predicate(item) for item in self._it) def any(self, predicate: Predicate[T]) -> bool: """Return True if any element in the iterator satisfies the predicate. @@ -94,7 +101,7 @@ def any(self, predicate: Predicate[T]) -> bool: bool: True if any element satisfies the predicate, False otherwise. """ - return any(predicate(item) for item in _flushed(self)) + return any(predicate(item) for item in self._it) def batched(self, n: int) -> "Itr[tuple[T, ...]]": """ @@ -113,7 +120,7 @@ def batched(self, n: int) -> "Itr[tuple[T, ...]]": >>> list(Itr(range(7)).batched(3)) [(0, 1, 2), (3, 4, 5), (6,)] """ - return cast("Itr[tuple[T, ...]]", Itr(itertools.batched(_flushed(self), n))) + return cast("Itr[tuple[T, ...]]", Itr(itertools.batched(self._it, n))) def chain[U](self, other: Iterable[U]) -> "Itr[T | U]": """Chain this iterator with another iterable, yielding all items from self followed by all items from other. @@ -126,7 +133,7 @@ def chain[U](self, other: Iterable[U]) -> "Itr[T | U]": """ - return cast("Itr[T | U]", Itr(itertools.chain(_flushed(self), other))) + return cast("Itr[T | U]", Itr(itertools.chain(self._it, other))) @overload def collect(self, container: type[tuple[T, ...]] = tuple) -> tuple[T, ...]: ... @@ -144,7 +151,7 @@ def collect(self, container: type[_CollectT] = tuple) -> _CollectT: # ty: ignor _CollectT: The remaining items collected into the given container type. """ - return container(_flushed(self)) + return container(self._it) def consume(self) -> None: """Exhaust the iterator. Useful when only the side effects are required (see inspect) @@ -154,7 +161,7 @@ def consume(self) -> None: Returns: None """ - deque(_flushed(self), 0) + deque(self._it, 0) def copy(self) -> "Itr[T]": """Splits the iterator at its *current state* into two independent iterators. @@ -163,8 +170,9 @@ def copy(self) -> "Itr[T]": Itr[T]: A new Itr instance wrapping one copy of the original iterator. """ - self._inner, it = itertools.tee(_flushed(self)) - return Itr(it) + it1, it2 = itertools.tee(self._it) + self._it = _Peekable(it1) + return Itr(it2) def count(self) -> int: """Count the number of remaining items in the iterator. NB Consumes the iterator. @@ -173,7 +181,7 @@ def count(self) -> int: int: The number of remaining items. """ - return sum(1 for _ in _flushed(self)) + return sum(1 for _ in self._it) def cycle(self) -> "Itr[T]": """ @@ -188,7 +196,7 @@ def cycle(self) -> "Itr[T]": >>> cycler.take(5).collect() (1, 2, 3, 1, 2) """ - return Itr(itertools.cycle(_flushed(self))) + return Itr(itertools.cycle(self._it)) def dedup(self) -> "Itr[T]": """Lazily remove *consecutive* duplicate items, keeping the first of each run (like Rust's `dedup`). @@ -203,7 +211,7 @@ def dedup(self) -> "Itr[T]": >>> Itr([1, 1, 2, 2, 2, 3, 1]).dedup().collect() (1, 2, 3, 1) """ - return Itr(k for k, _ in itertools.groupby(_flushed(self))) + return Itr(k for k, _ in itertools.groupby(self._it)) def enumerate(self, *, start: int = 0) -> "Itr[tuple[int, T]]": """Yield pairs of (index, item) for each item in the iterator, where index starts at 0 or the value provided @@ -212,7 +220,7 @@ def enumerate(self, *, start: int = 0) -> "Itr[tuple[int, T]]": Itr[tuple[int, T]]: An iterator of (index, item) pairs. """ - return cast("Itr[tuple[int, T]]", Itr(enumerate(_flushed(self), start))) + return cast("Itr[tuple[int, T]]", Itr(enumerate(self._it, start))) def filter(self, predicate: Predicate[T]) -> "Itr[T]": """Yield only items that satisfy the predicate. @@ -224,7 +232,7 @@ def filter(self, predicate: Predicate[T]) -> "Itr[T]": Itr[T]: An iterator of filtered items. """ - return Itr(filter(predicate, _flushed(self))) + return Itr(filter(predicate, self._it)) def find(self, predicate: Predicate[T]) -> T | None: """Return the first item in the iterator that satisfies the predicate, or None if not found. @@ -236,7 +244,7 @@ def find(self, predicate: Predicate[T]) -> T | None: T | None: The first matching item, or None. """ - return next(filter(predicate, _flushed(self)), None) + return next(filter(predicate, self._it), None) def flat_map[U](self, mapper: Callable[[T], Iterable[U]]) -> "Itr[U]": """Map each item to an iterable, then flatten one level. @@ -262,7 +270,7 @@ def flatten[U](self) -> "Itr[U]": Itr[U]: An iterator over the flattened items. """ - return Itr(itertools.chain.from_iterable(cast("Iterable[Iterable[U]]", _flushed(self)))) + return Itr(itertools.chain.from_iterable(cast("Iterable[Iterable[U]]", self._it))) def fold[U](self, init: U, func: Callable[[U, T], U]) -> U: """Reduce the iterator to a single value using a function and an initial value. @@ -276,7 +284,7 @@ def fold[U](self, init: U, func: Callable[[U, T], U]) -> U: """ result = init - for item in _flushed(self): + for item in self._it: result = func(result, item) return result @@ -287,7 +295,7 @@ def for_each(self, func: Callable[[T], None]) -> None: func (Callable[[T], None]): The function to apply. """ - for item in _flushed(self): + for item in self._it: func(item) def chunk_by[U](self, grouper: Callable[[T], U]) -> "Itr[tuple[U, tuple[T, ...]]]": @@ -308,7 +316,7 @@ def chunk_by[U](self, grouper: Callable[[T], U]) -> "Itr[tuple[U, tuple[T, ...]] (1, 2, 3, 1) """ key_fn = cast("Callable[[T], Any]", grouper) - groups = ((k, tuple(v)) for k, v in itertools.groupby(_flushed(self), key=key_fn)) + groups = ((k, tuple(v)) for k, v in itertools.groupby(self._it, key=key_fn)) return cast("Itr[tuple[U, tuple[T, ...]]]", Itr(groups)) def groupby[U](self, grouper: Callable[[T], U]) -> "Itr[tuple[U, tuple[T,...]]]": @@ -330,7 +338,7 @@ def groupby[U](self, grouper: Callable[[T], U]) -> "Itr[tuple[U, tuple[T,...]]]" """ key_fn = cast("Callable[[T], Any]", grouper) - groups = ((k, tuple(v)) for k, v in itertools.groupby(sorted(_flushed(self), key=key_fn), key=key_fn)) + groups = ((k, tuple(v)) for k, v in itertools.groupby(sorted(self._it, key=key_fn), key=key_fn)) return cast("Itr[tuple[U, tuple[T, ...]]]", Itr(groups)) def inspect(self, func: Callable[[T], None]) -> "Itr[T]": @@ -372,10 +380,10 @@ def intersperse[U](self, item: U) -> "Itr[T | U]": def intersperser(item: U) -> Generator[T | U, None, None]: try: - current = next(_flushed(self)) + current = next(self._it) while True: yield current - current = next(_flushed(self)) + current = next(self._it) yield item except StopIteration: return @@ -402,7 +410,7 @@ def interleave[U](self, other: Iterable[U]) -> "Itr[T | U]": _sentinel = object() def interleaver() -> Generator[T | U, None, None]: - for pair in itertools.zip_longest(_flushed(self), other, fillvalue=_sentinel): + for pair in itertools.zip_longest(self._it, other, fillvalue=_sentinel): for item in pair: if item is not _sentinel: yield cast("T | U", item) @@ -419,7 +427,7 @@ def last(self) -> T: ValueError: If the iterator is empty. """ - *_, last_item = _flushed(self) + *_, last_item = self._it return last_item def map[U](self, mapper: Callable[[T], U]) -> "Itr[U]": @@ -432,7 +440,7 @@ def map[U](self, mapper: Callable[[T], U]) -> "Itr[U]": Itr[U]: An iterator of mapped items. """ - return Itr(map(mapper, _flushed(self))) + return Itr(map(mapper, self._it)) def map_dict[U](self, mapper: dict[T, U]) -> "Itr[U]": """Map each item in the iterator using the given dictionary (supports defaultdict). @@ -444,7 +452,7 @@ def map_dict[U](self, mapper: dict[T, U]) -> "Itr[U]": Itr[U]: An iterator of mapped items. """ - return Itr(mapper[m] for m in _flushed(self)) + return Itr(mapper[m] for m in self._it) def map_while[U](self, predicate: Predicate[T], mapper: Callable[[T], U]) -> "Itr[U]": """Map each item in the iterator using the given function, while the predicate remains True. @@ -457,7 +465,7 @@ def map_while[U](self, predicate: Predicate[T], mapper: Callable[[T], U]) -> "It Itr[U]: An iterator of mapped items. """ - return Itr(map(mapper, itertools.takewhile(predicate, _flushed(self)))) + return Itr(map(mapper, itertools.takewhile(predicate, self._it))) def max(self, key: Callable[[T], Any] | None = None) -> T: """ @@ -472,7 +480,7 @@ def max(self, key: Callable[[T], Any] | None = None) -> T: Raises: ValueError: If the iterator is empty. """ - return max(_flushed(self), key=key) + return max(self._it, key=key) def min(self, key: Callable[[T], Any] | None = None) -> T: """ @@ -487,7 +495,7 @@ def min(self, key: Callable[[T], Any] | None = None) -> T: Raises: ValueError: If the iterator is empty. """ - return min(_flushed(self), key=key) + return min(self._it, key=key) def next(self) -> T: """Return the next item from the iterator, if available. Otherwise raises StopIteration @@ -496,7 +504,7 @@ def next(self) -> T: T: The next item. """ - return next(self) + return next(self._it) def next_chunk(self, n: int) -> tuple[T, ...]: """Return a tuple of the next n items from the iterator. @@ -534,12 +542,11 @@ def next_if(self, predicate: Predicate[T]) -> T | None: 3 """ try: - item = self.peek() + item = self._it.peek() except StopIteration: return None if predicate(item): - self._peeked = _MISSING - return item + return next(self._it) return None def nth(self, n: int) -> T: @@ -573,7 +580,7 @@ def pairwise(self) -> "Itr[tuple[T, T]]": Itr[tuple[T, T]]: An iterator over consecutive pairs from the original iterable. """ - return cast("Itr[tuple[T, T]]", Itr(itertools.pairwise(_flushed(self)))) + return cast("Itr[tuple[T, T]]", Itr(itertools.pairwise(self._it))) def partition(self, predicate: Predicate[T]) -> tuple["Itr[T]", "Itr[T]"]: """ @@ -611,9 +618,7 @@ def peek(self) -> T: >>> it.peek() 2 """ - if self._peeked is _MISSING: - self._peeked = next(self._inner) - return cast("T", self._peeked) + return self._it.peek() def position(self, predicate: Predicate[T]) -> int: """ @@ -642,7 +647,7 @@ def prod(self) -> T: >>> Itr([2, 3, 4]).prod() 24 """ - return cast("T", math.prod(cast("Iterable[Any]", _flushed(self)))) + return cast("T", math.prod(cast("Iterable[Any]", self._it))) def product[U](self, other: Iterable[U]) -> "Itr[tuple[T, U]]": """ @@ -654,7 +659,7 @@ def product[U](self, other: Iterable[U]) -> "Itr[tuple[T, U]]": Returns: Itr[tuple[T, U]]: Iterator of 2-tuples with elements from each input iterator. """ - return cast("Itr[tuple[T, U]]", Itr(itertools.product(_flushed(self), other))) + return cast("Itr[tuple[T, U]]", Itr(itertools.product(self._it, other))) def reduce(self, func: Callable[[T, T], T]) -> T: """Reduce the iterator to a single value using a function. @@ -667,7 +672,7 @@ def reduce(self, func: Callable[[T, T], T]) -> T: T: The final reduced value. """ - return self.fold(next(_flushed(self)), func) + return self.fold(next(self._it), func) def repeat(self, n: int) -> "Itr[T]": """ @@ -683,7 +688,7 @@ def repeat(self, n: int) -> "Itr[T]": This implementation creates `n` independent iterators using `itertools.tee`, which may be inefficient for large `n` or large input iterators. """ # this creates n iterators so may be inefficient - return Itr(itertools.chain(*itertools.tee(_flushed(self), n))) + return Itr(itertools.chain(*itertools.tee(self._it, n))) def rev(self) -> "Itr[T]": """Return a reversed iterator over the remaining items (materializes the sequence). @@ -693,7 +698,7 @@ def rev(self) -> "Itr[T]": """ # it's generally impossible to do this without materialising the entire sequence - return Itr(tuple(_flushed(self))[::-1]) + return Itr(tuple(self._it)[::-1]) def rolling(self, n: int) -> "Itr[tuple[T, ...]]": """ @@ -703,7 +708,7 @@ def rolling(self, n: int) -> "Itr[tuple[T, ...]]": if n < 1: raise ValueError(f"Invalid rolling window {n} (must be at least 1)") - iterators = itertools.tee(_flushed(self), n) + iterators = itertools.tee(self._it, n) shifted_iterators = (itertools.islice(it, i, None) for i, it in enumerate(iterators)) return cast("Itr[tuple[T, ...]]", Itr(zip(*shifted_iterators, strict=False))) @@ -717,7 +722,7 @@ def skip(self, n: int) -> "Itr[T]": Itr[T]: An iterator over the remaining items. """ - return Itr(itertools.islice(_flushed(self), n, None)) + return Itr(itertools.islice(self._it, n, None)) def skip_while(self, predicate: Predicate[T]) -> "Itr[T]": """Skip items in the iterator as long as the predicate is true. @@ -729,7 +734,7 @@ def skip_while(self, predicate: Predicate[T]) -> "Itr[T]": Itr[T]: An iterator over the remaining items once the predicate first fails. """ - return Itr(itertools.dropwhile(predicate, _flushed(self))) + return Itr(itertools.dropwhile(predicate, self._it)) def sorted_by(self, key: Callable[[T], Any], *, reverse: bool = False) -> "Itr[T]": """Return an iterator over the items sorted by the given key function. @@ -748,7 +753,7 @@ def sorted_by(self, key: Callable[[T], Any], *, reverse: bool = False) -> "Itr[T >>> Itr(["ccc", "a", "bb"]).sorted_by(len).collect() ('a', 'bb', 'ccc') """ - return Itr(sorted(_flushed(self), key=key, reverse=reverse)) + return Itr(sorted(self._it, key=key, reverse=reverse)) def starmap[U](self, func: Callable[..., U]) -> "Itr[U]": """ @@ -765,7 +770,7 @@ def starmap[U](self, func: Callable[..., U]) -> "Itr[U]": >>> list(itr.starmap(lambda x, y: x + y)) [3, 7] """ - return Itr(itertools.starmap(func, cast("Iterable[Iterable[Any]]", _flushed(self)))) + return Itr(itertools.starmap(func, cast("Iterable[Iterable[Any]]", self._it))) def step_by(self, n: int) -> "Itr[T]": """Yield every n-th item from the iterator. @@ -777,7 +782,7 @@ def step_by(self, n: int) -> "Itr[T]": Itr[T]: An iterator yielding every n-th item. """ - return Itr(itertools.islice(_flushed(self), 0, None, n)) + return Itr(itertools.islice(self._it, 0, None, n)) def sum(self) -> T: """Return the sum of all items in the iterator (0 if empty). NB Consumes the iterator. @@ -791,7 +796,7 @@ def sum(self) -> T: >>> Itr([1, 2, 3]).sum() 6 """ - return cast("T", sum(cast("Iterable[Any]", _flushed(self)))) + return cast("T", sum(cast("Iterable[Any]", self._it))) def take(self, n: int) -> "Itr[T]": """Return an iterator over the next n items from the iterator. @@ -803,7 +808,7 @@ def take(self, n: int) -> "Itr[T]": Itr[T]: An iterator over the next n items. """ - return Itr(itertools.islice(_flushed(self), n)) + return Itr(itertools.islice(self._it, n)) def take_while(self, predicate: Predicate[T]) -> "Itr[T]": """Yield items from the iterator as long as the given predicate is true. @@ -815,7 +820,7 @@ def take_while(self, predicate: Predicate[T]) -> "Itr[T]": Itr[T]: A new Itr yielding items while the predicate is true. """ - return Itr(itertools.takewhile(predicate, _flushed(self))) + return Itr(itertools.takewhile(predicate, self._it)) def tee(self, n: int = 2) -> tuple["Itr[T]", ...]: """ @@ -856,7 +861,7 @@ def tee(self, n: int = 2) -> tuple["Itr[T]", ...]: """ if n < 1: raise ValueError(f"tee requires at least 1 iterator, got {n}") - return tuple(Itr(t) for t in itertools.tee(_flushed(self), n)) + return tuple(Itr(t) for t in itertools.tee(self._it, n)) def unzip[U, V](self: "Itr[tuple[U, V]]") -> tuple["Itr[U]", "Itr[V]"]: """Splits the iterator of pairs into two separate iterators, each containing the elements from one position of @@ -871,7 +876,7 @@ def unzip[U, V](self: "Itr[tuple[U, V]]") -> tuple["Itr[U]", "Itr[V]"]: and then maps over each to extract the respective elements. """ - it1, it2 = itertools.tee(_flushed(self), 2) + it1, it2 = itertools.tee(self._it, 2) return Itr(x[0] for x in it1), Itr(x[1] for x in it2) def value_counts(self) -> "Itr[tuple[T, int]]": @@ -890,7 +895,7 @@ def value_counts(self) -> "Itr[tuple[T, int]]": >>> Itr("abracadabra").value_counts().collect() (('a', 5), ('b', 2), ('r', 2), ('c', 1), ('d', 1)) """ - return cast("Itr[tuple[T, int]]", Itr(Counter(_flushed(self)).most_common())) + return cast("Itr[tuple[T, int]]", Itr(Counter(self._it).most_common())) def zip[U](self, other: Iterable[U]) -> "Itr[tuple[T, U]]": """Yield pairs of items from this iterator and another iterable. @@ -902,7 +907,7 @@ def zip[U](self, other: Iterable[U]) -> "Itr[tuple[T, U]]": Itr[tuple[T, U]]: An iterator of paired items. """ - return cast("Itr[tuple[T, U]]", Itr(zip(_flushed(self), other, strict=False))) + return cast("Itr[tuple[T, U]]", Itr(zip(self._it, other, strict=False))) def zip_longest[U, V]( self, other: Iterable[U], *, fillvalue: V | None = None @@ -925,5 +930,5 @@ def zip_longest[U, V]( """ return cast( "Itr[tuple[T | V | None, U | V | None]]", - Itr(itertools.zip_longest(_flushed(self), other, fillvalue=fillvalue)), + Itr(itertools.zip_longest(self._it, other, fillvalue=fillvalue)), ) diff --git a/uv.lock b/uv.lock index e227335..5a1d11e 100644 --- a/uv.lock +++ b/uv.lock @@ -347,10 +347,10 @@ provides-extras = ["examples"] [package.metadata.requires-dev] dev = [ { name = "pre-commit", specifier = ">=4.5.1" }, - { name = "pytest", specifier = ">=8.4.1" }, + { name = "pytest", specifier = ">=9.1.1" }, { name = "pytest-cov", specifier = ">=6.2.1" }, - { name = "ruff", specifier = ">=0.15.13" }, - { name = "ty", specifier = "==0.0.37" }, + { name = "ruff", specifier = ">=0.15.20" }, + { name = "ty", specifier = ">=0.0.56" }, ] [[package]] @@ -566,7 +566,7 @@ wheels = [ [[package]] name = "pytest" -version = "9.0.3" +version = "9.1.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, @@ -575,9 +575,9 @@ dependencies = [ { name = "pluggy" }, { name = "pygments" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" }, + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, ] [[package]] @@ -710,27 +710,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.13" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" }, - { url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" }, - { url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" }, - { url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" }, - { url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" }, - { url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" }, - { url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" }, - { url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" }, - { url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" }, - { url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" }, - { url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" }, - { url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" }, - { url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" }, - { url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" }, - { url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" }, - { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, +version = "0.15.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/dc/35b341fc554ba02f217fc10da57d1a75168cfbcf75b0ef2202176d4c4f2d/ruff-0.15.20.tar.gz", hash = "sha256:1416eb04349192646b54de98f146c4f59afe37d0decfc02c3cbbf396f3a28566", size = 4755489, upload-time = "2026-06-25T17:20:37.578Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/d9/2d5014f0253ba541d2061d9fa7193f48e941c8b21bb88a7ff9bbe0bd0596/ruff-0.15.20-py3-none-linux_armv6l.whl", hash = "sha256:00e188c53e499c3c1637f73c91dcf2fb56d576cab76ce1be50a27c4e80e37078", size = 10839665, upload-time = "2026-06-25T17:19:44.702Z" }, + { url = "https://files.pythonhosted.org/packages/c6/d3/ac1798ba64f670698867fcfc591d50e7e421bef137db564858f619a30fcf/ruff-0.15.20-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:9ebd1fd9b9c95fc0bd7b2761aebec1f030013d2e193a2901b224af68fe47251b", size = 11208649, upload-time = "2026-06-25T17:19:48.787Z" }, + { url = "https://files.pythonhosted.org/packages/47/47/d3ac899991202095dfcf3d5176be4272642be3cf981a2f1a30f72a2afb95/ruff-0.15.20-py3-none-macosx_11_0_arm64.whl", hash = "sha256:c5b16cdd67ca108185cd36dce98c576350c03b1660a751de725fb049193a0632", size = 10622638, upload-time = "2026-06-25T17:19:51.354Z" }, + { url = "https://files.pythonhosted.org/packages/33/13/4e043fe30aa94d4ff5213a9881fc296d12960f5971b234a5263fdc225312/ruff-0.15.20-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3413bb3c3d2ca6a8208f1f4809cd2dca3c6de6d0b491c0e70847672bde6e6efd", size = 10984227, upload-time = "2026-06-25T17:19:54.044Z" }, + { url = "https://files.pythonhosted.org/packages/76/e6/92e7bf40388bc5800073b96564f56264f7e48bfd1a498f5ced6ae6d5a769/ruff-0.15.20-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bd7ec42b3bb3da066488db093308a69c4ac5ee6d2af333a86ba6e2eb2e7dd44b", size = 10622882, upload-time = "2026-06-25T17:19:57.037Z" }, + { url = "https://files.pythonhosted.org/packages/13/7a/43460be3f24495a3aa46d4b16873e2c4941b3b5f0b00cf88c03b7b94b339/ruff-0.15.20-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e1a36ad0eb77fba9aabfb69ede54de6f376d04ac18ebea022847046d340a8267", size = 11474808, upload-time = "2026-06-25T17:20:00.357Z" }, + { url = "https://files.pythonhosted.org/packages/27/a0/f37077884873221c6b33b4ab49eb18f9f88e54a16a25a5bca59bef46dd66/ruff-0.15.20-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b6df3b1e4610432f0386dba04d853b5f08cbbc903410c6fcc02f620f05aff53c", size = 12293094, upload-time = "2026-06-25T17:20:03.446Z" }, + { url = "https://files.pythonhosted.org/packages/a6/74/165545b60256a9704c21ac0ec4a0d07933b320812f9584836c9f4aca4292/ruff-0.15.20-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e89f198a1ea6ef0d727c1cf16088bc91a6cb0ab947dedc966715691647186eae", size = 11526176, upload-time = "2026-06-25T17:20:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/86/b1/a976a136d40ade83ce743578399865f57001003a409acadc0ecbb3051082/ruff-0.15.20-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309809086c2acb67624950a3c8133e80f32d0d3e27106c0cd60ff26657c9f24b", size = 11520767, upload-time = "2026-06-25T17:20:09.191Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/f032696cb01c9b54c0263fa393474d7758f1cdc021a01b04e3cbc2500999/ruff-0.15.20-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2d2374caa2f2c2f9e2b7da0a50802cfb8b79f55a9b5e49379f564544fbf56487", size = 11500132, upload-time = "2026-06-25T17:20:13.602Z" }, + { url = "https://files.pythonhosted.org/packages/4b/f4/51b1a14bc69e8c224b15dab9cce8e99b425e0455d462caa2b3c9be2b6a8e/ruff-0.15.20-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a1ed17b65293e0c2f22fc387bc13198a5de94bf4429589b0ff6946b0feaf21a3", size = 10943828, upload-time = "2026-06-25T17:20:16.635Z" }, + { url = "https://files.pythonhosted.org/packages/71/4b/fe267640783cd02bf6c5cc290b1df1051be2ec294c678b5c15fe19e52343/ruff-0.15.20-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f701305e66b38ea6c91882490eb73459796808e4c6362a1b765255e0cdcd4053", size = 10645418, upload-time = "2026-06-25T17:20:19.4Z" }, + { url = "https://files.pythonhosted.org/packages/b0/c0/a65aa4ec2f5e87a1df32dc3ec1fede434fe3dfd5cbcf3b503cafc676ab54/ruff-0.15.20-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b9c0c367ad8e5d0d5b5b8537864c469a0a0e55417aadfbeca41fa61333be9f4", size = 11211770, upload-time = "2026-06-25T17:20:22.033Z" }, + { url = "https://files.pythonhosted.org/packages/5a/a4/0caa331d954ae2723d729d351c989cb4ca8b6077d5c6c2cb6de75e98c041/ruff-0.15.20-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:01cc00dd58f0df339d0e902219dd53990ea99996a0344e5d9cc8d45d5307e460", size = 11618698, upload-time = "2026-06-25T17:20:25.259Z" }, + { url = "https://files.pythonhosted.org/packages/10/9b/5f14927848d2fd4aa891fd88d883788c5a7baba561c7874732364045708c/ruff-0.15.20-py3-none-win32.whl", hash = "sha256:ed65ef510e43a137207e0f01cfcf998aeddb1aeeda5c9d35023e910284d7cf21", size = 10857322, upload-time = "2026-06-25T17:20:28.612Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/fe47c501f9dea92a26d788ff98bb5d92ed4cb4c88792c5c88af6b697dc8e/ruff-0.15.20-py3-none-win_amd64.whl", hash = "sha256:a525c81c70fb0380344dd1d8745d8cc1c890b7fc94a58d5a07bd8eb9557b8415", size = 11993274, upload-time = "2026-06-25T17:20:31.871Z" }, + { url = "https://files.pythonhosted.org/packages/d7/2b/9555445e1201d92b3195f45cdb153a0b68f24e0a4273f6e3d5ab46e212bb/ruff-0.15.20-py3-none-win_arm64.whl", hash = "sha256:2f5b2a6d614e8700388806a14996c40fab2c47b819ef57d790a34878858ed9ca", size = 11343498, upload-time = "2026-06-25T17:20:35.03Z" }, ] [[package]] @@ -784,27 +784,27 @@ wheels = [ [[package]] name = "ty" -version = "0.0.37" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c7/c3/60bc4829e0c1a8ff80b592067e1185a7b5ea64608acb0c676c44d5137d52/ty-0.0.37.tar.gz", hash = "sha256:f873f69627bd7f4ef8d57f716c63e5c63d7d1b7327ab3de185c7287a75223011", size = 5655422, upload-time = "2026-05-16T05:57:21.315Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b8/fe/180dd6914f9db33ad0200fbeaa429dd1fb0a4e6d98320dc1775f100a91af/ty-0.0.37-py3-none-linux_armv6l.whl", hash = "sha256:66cf7310189856e15f690559ddf37735476d2644db917d92f7cef13e5c834adf", size = 11246028, upload-time = "2026-05-16T05:57:41.744Z" }, - { url = "https://files.pythonhosted.org/packages/ef/a2/fa0cfd31467ad99b2db8c81ee9e2b4574589974a3eb9723be825e15b300c/ty-0.0.37-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:2048f3c44ee6c7dde6e0ca064f99c6cada8f6de8ccdcfad2d856a429f8a4ac82", size = 11001460, upload-time = "2026-05-16T05:57:35.27Z" }, - { url = "https://files.pythonhosted.org/packages/10/3f/db60ba9be8b95a464ece0ba103e534047c34b49fee12f5e101f83f8d66db/ty-0.0.37-py3-none-macosx_11_0_arm64.whl", hash = "sha256:32c7b9b5b626aacdec334b44a2698e5f7b80df55bf7338267084d00d4b9546b3", size = 10446549, upload-time = "2026-05-16T05:57:37.252Z" }, - { url = "https://files.pythonhosted.org/packages/56/6f/11dd7174b20ebcb37a3d3b68f60b3940e37e4356e0accd03e2d7f9f70690/ty-0.0.37-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e9fba1bebccf1e656bc5e3787acc5a191c491041ee4d12fe8fe2eff64e7b190d", size = 10961016, upload-time = "2026-05-16T05:57:16.394Z" }, - { url = "https://files.pythonhosted.org/packages/65/dd/3c17ce2860c525817c42c82d7075391b1f5615d36c03aa2d26647a224e8a/ty-0.0.37-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f987c5fb59aa5017ee8e8c5b57a07390f584e58e572255acd0fa44b3e0b238df", size = 11022093, upload-time = "2026-05-16T05:57:32.741Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a8/e7a40b0b57660921dd3482d219add963973b52ae8507abd88f48439704b5/ty-0.0.37-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f4168f53146e7a3f52560ff433f238352591c9b1a9ed09397fbb776ddef4f89c", size = 11486333, upload-time = "2026-05-16T05:57:18.839Z" }, - { url = "https://files.pythonhosted.org/packages/da/5f/2c406b98244bc1ad42afdd35f466bcef88664210957dcbb5172254ff2462/ty-0.0.37-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:11e487eafdb80a48223ce68a01f9287528216ffe0126d1629ff11e4f7c1dd3cf", size = 12093526, upload-time = "2026-05-16T05:57:04.456Z" }, - { url = "https://files.pythonhosted.org/packages/d3/3c/5c492a38e1b21a26370727dd4b77a53f05262e53e3be232047f22e7fa1b3/ty-0.0.37-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b49f388d063668676daaa7eef57385089d1b844279c0185bd84d4dbc3bcede6", size = 11725957, upload-time = "2026-05-16T05:57:23.356Z" }, - { url = "https://files.pythonhosted.org/packages/b2/00/8a3d9ba265cd0582342c14e4980cc0351aaaa45c6305712d398c9e2446c7/ty-0.0.37-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b96bfc1cc725d9d859abef4e3aa32a6da0f7472eaaafae2d9a6cffd729c7c61", size = 11610336, upload-time = "2026-05-16T05:57:27.888Z" }, - { url = "https://files.pythonhosted.org/packages/91/4b/6ee172935cb842f5c1553b0d37215b45e9dde05a4c74fdb47fd271907122/ty-0.0.37-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:c55f39b519107cf234b794718793e11793c055e89028a282a309f690def48117", size = 11797856, upload-time = "2026-05-16T05:57:11.109Z" }, - { url = "https://files.pythonhosted.org/packages/34/ef/75a7425bf9fe74483404ff11a8cbe3aa307354e0801697d6063384157776/ty-0.0.37-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c79204350de060a077bff7f027a1d53e216cad147d826ec9862be0af2f9c3c1e", size = 10941848, upload-time = "2026-05-16T05:57:30.653Z" }, - { url = "https://files.pythonhosted.org/packages/e0/2c/7ea9dccd55961375067f99ed00fb8eabb491f6a06d0e5f09c797d2b900a6/ty-0.0.37-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:49a21b4dcb2cd94cd0298c96dfb71a2dd25f08bf7e6eefd0c33c519d058908c6", size = 11058248, upload-time = "2026-05-16T05:57:01.785Z" }, - { url = "https://files.pythonhosted.org/packages/98/d7/848fde96c6610b2b1fd75823d44d8977a4525c4397f27332f054ccd6cf9c/ty-0.0.37-py3-none-musllinux_1_2_i686.whl", hash = "sha256:119332095c5974fe1dabfe4fd00c6759eeec5b99f7d7a80b2833feee5a58abdb", size = 11168423, upload-time = "2026-05-16T05:57:39.297Z" }, - { url = "https://files.pythonhosted.org/packages/29/11/c1613ac4b64357b9067df68bac97bcb458cc426cd468a2782847238c539b/ty-0.0.37-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:ac5dc593675414f68862c2f71cc04912b0e5ec5520a9c49fc71ed79205b95c33", size = 11698565, upload-time = "2026-05-16T05:57:14.206Z" }, - { url = "https://files.pythonhosted.org/packages/5f/ac/961205863903881996adb5a6f9cfe570c132882922ac226540346f15df20/ty-0.0.37-py3-none-win32.whl", hash = "sha256:33b57e4095179f06c2ae01c334833645cad94bf7d7467e073cdc3aaabea565d3", size = 10518308, upload-time = "2026-05-16T05:57:25.824Z" }, - { url = "https://files.pythonhosted.org/packages/39/cd/f308edd0cd86e402fe3a1b5c54e0a0dfa0177d80c1557c4849510bb2a147/ty-0.0.37-py3-none-win_amd64.whl", hash = "sha256:3b159351e99cf6eed7aacfb69ae8437725d15599ac4f21c8b2e909b300498b6c", size = 11607159, upload-time = "2026-05-16T05:57:06.76Z" }, - { url = "https://files.pythonhosted.org/packages/1a/ed/5ec4b501479bc5dad55467e2fe72e797cb9c178468c0d1a514536872ebc5/ty-0.0.37-py3-none-win_arm64.whl", hash = "sha256:6c3c2b997f68c71e14242b96d48cba3c086439556af02bb4613aa458950d5c23", size = 10958817, upload-time = "2026-05-16T05:57:08.907Z" }, +version = "0.0.56" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/55/07/fb29aea5235b0aa8ecfc4d1cc6ddf9fba8b863d67d96c6d345694d644c43/ty-0.0.56.tar.gz", hash = "sha256:84d114dc3796361c0fc72945016eabd74d46b9ee64f198cb0e485719704681e5", size = 6050123, upload-time = "2026-07-01T16:44:56.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/48/bce79e7ca5c1cc529d3e0d37ddd1121aea4b68a4f749974ad1cc77161871/ty-0.0.56-py3-none-linux_armv6l.whl", hash = "sha256:186d4a53e15747c947e1ec3d7eec8e345d8e40a1ca10e634c585db52497e87dd", size = 11643066, upload-time = "2026-07-01T16:44:18.374Z" }, + { url = "https://files.pythonhosted.org/packages/80/d1/22555d8a1d719661f10050f3865d877bbf497da908961c75fe22217dd18a/ty-0.0.56-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aae1a980fd9535da0469b7ba2b2e1b54a907743a5e0f442dd57eee9f5bfd034c", size = 11407487, upload-time = "2026-07-01T16:44:20.956Z" }, + { url = "https://files.pythonhosted.org/packages/cf/2d/b3b7a74ce8bc59ef48843ad80179bb0d9598bbd6cfc0d11d519bdf6b1352/ty-0.0.56-py3-none-macosx_11_0_arm64.whl", hash = "sha256:afd3058c0a6c5f241e814734f133008c93ee805f61c9cf4ce7412b8822b5d9ad", size = 10962270, upload-time = "2026-07-01T16:44:22.959Z" }, + { url = "https://files.pythonhosted.org/packages/64/ac/6c2fd7de0304a8a7218a756af74f7e62a5e8540fdb175e0a869e51042345/ty-0.0.56-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:058b52f7a823ac13aae3cae30809dd6b5145794b64d8478f9ef38c75d79b4483", size = 11471406, upload-time = "2026-07-01T16:44:25.327Z" }, + { url = "https://files.pythonhosted.org/packages/50/b6/11d861156861c03c7726b74558f9a0e0092661aff83a4fda1279df28c425/ty-0.0.56-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c66e00c1522add1f2bbdd2e45828c953b35c306b7bef03ec9169c75a63699a0", size = 11445612, upload-time = "2026-07-01T16:44:27.531Z" }, + { url = "https://files.pythonhosted.org/packages/fb/ba/09df108582090f3c0770ec4bc8675affed60248f6793a78d909be16211d9/ty-0.0.56-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40903d71c669a30691b5a5d5728056c7877a1bd6be4f233a38883a8b28cf34d7", size = 12093889, upload-time = "2026-07-01T16:44:29.548Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f7/dbb4b4ccb69cd64c209ae55b1ab788ace8222c2bc1f6845be9e7cbedbf25/ty-0.0.56-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63fe3947fe0c46c69a7d950e6832ee70a9ec17321fefbff3d2e3c20baf9e5bd0", size = 12666337, upload-time = "2026-07-01T16:44:31.586Z" }, + { url = "https://files.pythonhosted.org/packages/86/e9/73f903fe4a3d9ea02f26f57c1eb07e3b1029ec92b0e8c2364718893440e3/ty-0.0.56-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71a0c1a72f9854532e710e119b6871ffe4542c8a65146f1f65dcd78fecd885b4", size = 12280247, upload-time = "2026-07-01T16:44:33.637Z" }, + { url = "https://files.pythonhosted.org/packages/d6/90/cebd222495832f1a00dcd321ba25f3cab804221a4991b992c2178bec68ee/ty-0.0.56-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70d1665596494e24d8ebd198438872b5a56ec3cae5f2bcf6c673be797acc4e3c", size = 11991107, upload-time = "2026-07-01T16:44:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/b7/07/8f7337a07250f42d975cdb6decf47fc5b421e6c7da5e3e7be1e85f63a7e5/ty-0.0.56-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:778f99e51558afc1dbbe48ee38ab6aae7b31390ed8c1a1ef1499b295e9f1e82f", size = 12298970, upload-time = "2026-07-01T16:44:38.243Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b9/a52cd59034a48f5f18c6b155cc2cc36861d874b6d0af204b12c898024c3d/ty-0.0.56-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:867bc5708e0066bb4ff6c7db524bd5deea2676c62bfe71d3303138b3be850af0", size = 11425683, upload-time = "2026-07-01T16:44:40.473Z" }, + { url = "https://files.pythonhosted.org/packages/1d/2e/48e42d33357d52eefb695c0c3fcfc96879b73668a7447d1d1e0ad774fedc/ty-0.0.56-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a6012f4189c928edb330a37deb9930f982380bd4aa7c4b8e0428eec9651c7551", size = 11469258, upload-time = "2026-07-01T16:44:42.513Z" }, + { url = "https://files.pythonhosted.org/packages/d5/01/ad1b4138be1e3fa97863af3925aa2134f17a593240c35dc38c3429fb5ad1/ty-0.0.56-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8ee83de1a7ff4cc32837ec06134ce391d441bc5b35ecd8d3cfe053f120f3e4c1", size = 11758736, upload-time = "2026-07-01T16:44:44.567Z" }, + { url = "https://files.pythonhosted.org/packages/09/34/9d81967ff240eaa57e9249728ef7b7790747cf6d3c9a98ec86b2cfdcc8ee/ty-0.0.56-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:62619b3b0e2c6248ef30d3f0e2f2217ae9893040585be07f32324242f197cd6f", size = 12100242, upload-time = "2026-07-01T16:44:46.584Z" }, + { url = "https://files.pythonhosted.org/packages/c3/36/f51d4666d2de6cf33c1f3a1fcc4bb6b70b197dd6ceaa491eef71d78fe8e8/ty-0.0.56-py3-none-win32.whl", hash = "sha256:b30687bb5cd9729d34c889a289edf32770388d9bb05243e534e723fb45e0381b", size = 11093759, upload-time = "2026-07-01T16:44:49.171Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b4/8fb5d4acfa4afb152245b20fa263069a7547bd1f8e4bfca4eda280c897d7/ty-0.0.56-py3-none-win_amd64.whl", hash = "sha256:ad4c8c47b6f4e3f9ed3fc0b1a5d650088d229e17dd8f63c1826d6bbe94cc3235", size = 12100327, upload-time = "2026-07-01T16:44:51.26Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fc/6a183e71edde90d0c35c2303f23f7a45b6891d1a2c45daf7b8f869831e19/ty-0.0.56-py3-none-win_arm64.whl", hash = "sha256:57538f273d444a5f1293fa7860e967178afe3917611fc5eff16b64e1204fe0d6", size = 11538780, upload-time = "2026-07-01T16:44:53.8Z" }, ] [[package]] From 8699437df21066146362fcb7549afeb4b045fa63 Mon Sep 17 00:00:00 2001 From: virgesmith Date: Sun, 5 Jul 2026 13:38:35 +0100 Subject: [PATCH 3/6] Wrap in _Peekable lazily and add unpeek() to avoid iteration overhead Wrapping every Itr's source in _Peekable unconditionally put a Python-level __next__ frame between every stage of a pipeline, costing 2.5-7.5x on bulk operations vs the raw C iterator path. The buffer is now installed only on the first peek()/next_if() call, restoring parity with the pre-_Peekable performance for pipelines that never peek. For iterators that have been peeked, the new unpeek() method removes the buffer again (prepending any pending item via itertools.chain, so nothing is lost), recovering full iteration speed when no further lookahead is needed. _Peekable also gains __slots__. Co-Authored-By: Claude Fable 5 --- doc/apidoc.md | 19 +++++++++++++ relnotes.md | 2 ++ src/itrx/itr.py | 55 +++++++++++++++++++++++++++++++++---- src/test/test_collection.py | 25 +++++++++++++++++ 4 files changed, 95 insertions(+), 6 deletions(-) diff --git a/doc/apidoc.md b/doc/apidoc.md index 44353ff..776af26 100644 --- a/doc/apidoc.md +++ b/doc/apidoc.md @@ -757,6 +757,25 @@ Examples: [0, 1, 2] +### `unpeek` + +Remove the lookahead buffer installed by `peek` or `next_if`, restoring direct iteration. + +The buffer adds a small per-item overhead to all subsequent iteration of this Itr, so removing it when no +further lookahead is needed can speed up bulk consumption. Any pending peeked item is retained, and this +is a no-op if nothing has been peeked. Peeking again afterwards is safe: the buffer is simply reinstated. + +Returns: + Itr[T]: self. + +Example: + >>> it = Itr([1, 2, 3]) + >>> it.peek() + 1 + >>> it.unpeek().collect() + (1, 2, 3) + + ### `unzip` Splits the iterator of pairs into two separate iterators, each containing the elements from one position of diff --git a/relnotes.md b/relnotes.md index cdabbed..0b90101 100644 --- a/relnotes.md +++ b/relnotes.md @@ -13,10 +13,12 @@ - `dedup()`: lazily remove *consecutive* duplicates, keeping the first of each run (like Rust's `dedup`); works on infinite iterators. - `zip_longest(other, fillvalue=...)`: like `zip`, but continues to the end of the longer input, padding with `fillvalue`. - `sorted_by(key, reverse=...)`: eager stable sort by a key function. +- `unpeek()`: remove the lookahead buffer installed by `peek`/`next_if` (retaining any pending item), restoring direct iteration speed when no further lookahead is needed. ### Improvements - `peek` now uses a single-item lookahead buffer (like Rust's `Peekable`) instead of copying the iterator with `itertools.tee`, so repeated peeks are O(1) and no longer build up nested tee layers. +- The lookahead buffer is now installed lazily, on the first call to `peek`/`next_if`, rather than wrapping every `Itr` unconditionally. Pipelines that never peek keep the previous C-fast-path iteration speed (the unconditional wrapper cost 2.5-7.5x on bulk operations). ### Bug fixes diff --git a/src/itrx/itr.py b/src/itrx/itr.py index c41c1f5..e035ed8 100644 --- a/src/itrx/itr.py +++ b/src/itrx/itr.py @@ -19,6 +19,8 @@ class _Peekable[U](Iterator[U]): so anything consuming this iterator sees the full sequence. """ + __slots__ = ("_it", "_peeked") + def __init__(self, it: Iterable[U]) -> None: self._it = iter(it) self._peeked: Any = _MISSING @@ -35,6 +37,17 @@ def peek(self) -> U: self._peeked = next(self._it) return cast("U", self._peeked) + def unwrap(self) -> Iterator[U]: + """Return an iterator over the remaining items without the lookahead buffer. + + Any buffered item is prepended, so no items are lost. + """ + if self._peeked is _MISSING: + return self._it + item = cast("U", self._peeked) + self._peeked = _MISSING + return itertools.chain((item,), self._it) + class Itr[T](Iterator[T]): """A generic iterator adaptor class inspired by Rust's Iterator trait, providing a composable API for @@ -48,7 +61,7 @@ def __init__(self, it: Iterable[T]) -> None: it (Iterable[T]): The iterable to wrap. """ - self._it = _Peekable(it) + self._it = iter(it) def __iter__(self) -> Iterator[T]: "Implement the iter method of the Iterator protocol" @@ -58,6 +71,16 @@ def __next__(self) -> T: "Implement the next method of the Iterator protocol" return next(self._it) + def _peekable(self) -> _Peekable[T]: + """Wrap the underlying iterator in a lookahead buffer on first use. + + Wrapping lazily keeps bulk iteration on the C fast path: pipelines that never peek pay no per-item cost + for the buffer. + """ + if not isinstance(self._it, _Peekable): + self._it = _Peekable(self._it) + return cast("_Peekable[T]", self._it) + def accumulate(self, func: Callable[[T, T], T] | None = None, *, initial: T | None = None) -> "Itr[T]": """ Return an iterator over the accumulated results of applying the function (or sum by default) to the items. Does @@ -170,9 +193,8 @@ def copy(self) -> "Itr[T]": Itr[T]: A new Itr instance wrapping one copy of the original iterator. """ - it1, it2 = itertools.tee(self._it) - self._it = _Peekable(it1) - return Itr(it2) + self._it, it = itertools.tee(self._it) + return Itr(it) def count(self) -> int: """Count the number of remaining items in the iterator. NB Consumes the iterator. @@ -542,7 +564,7 @@ def next_if(self, predicate: Predicate[T]) -> T | None: 3 """ try: - item = self._it.peek() + item = self._peekable().peek() except StopIteration: return None if predicate(item): @@ -618,7 +640,7 @@ def peek(self) -> T: >>> it.peek() 2 """ - return self._it.peek() + return self._peekable().peek() def position(self, predicate: Predicate[T]) -> int: """ @@ -863,6 +885,27 @@ def tee(self, n: int = 2) -> tuple["Itr[T]", ...]: raise ValueError(f"tee requires at least 1 iterator, got {n}") return tuple(Itr(t) for t in itertools.tee(self._it, n)) + def unpeek(self) -> "Itr[T]": + """Remove the lookahead buffer installed by `peek` or `next_if`, restoring direct iteration. + + The buffer adds a small per-item overhead to all subsequent iteration of this Itr, so removing it when no + further lookahead is needed can speed up bulk consumption. Any pending peeked item is retained, and this + is a no-op if nothing has been peeked. Peeking again afterwards is safe: the buffer is simply reinstated. + + Returns: + Itr[T]: self. + + Example: + >>> it = Itr([1, 2, 3]) + >>> it.peek() + 1 + >>> it.unpeek().collect() + (1, 2, 3) + """ + if isinstance(self._it, _Peekable): + self._it = cast("Iterator[T]", self._it.unwrap()) + return self + def unzip[U, V](self: "Itr[tuple[U, V]]") -> tuple["Itr[U]", "Itr[V]"]: """Splits the iterator of pairs into two separate iterators, each containing the elements from one position of the pairs. diff --git a/src/test/test_collection.py b/src/test/test_collection.py index be018d8..a5c23e5 100644 --- a/src/test/test_collection.py +++ b/src/test/test_collection.py @@ -133,6 +133,31 @@ def test_peeked_item_seen_by_for_loop() -> None: assert list(it) == [1, 2, 3] +def test_unpeek_retains_pending_item() -> None: + it = Itr([1, 2, 3]) + assert it.peek() == 1 + assert it.unpeek().collect() == (1, 2, 3) + + +def test_unpeek_with_empty_buffer() -> None: + it = Itr([1, 2, 3]) + assert it.peek() == 1 + assert it.next() == 1 # drains the buffer + assert it.unpeek().collect() == (2, 3) + + +def test_unpeek_without_peek_is_noop() -> None: + it = Itr([1, 2, 3]) + assert it.unpeek().collect() == (1, 2, 3) + + +def test_peek_after_unpeek() -> None: + it = Itr([1, 2, 3]) + assert it.peek() == 1 + assert it.unpeek().peek() == 1 + assert it.collect() == (1, 2, 3) + + def test_next_if_consumes_on_match() -> None: it = Itr([1, 2, 3]) assert it.next_if(lambda x: x < 3) == 1 From 702f2e7408adbb3ac2d47a91873d00129fe1151e Mon Sep 17 00:00:00 2001 From: virgesmith Date: Mon, 6 Jul 2026 08:13:56 +0100 Subject: [PATCH 4/6] Revert peek lookahead buffer entirely; peek returns to tee-based copy Supersedes both the eager _Peekable wrapper and the lazy-wrap/unpeek variant, following the benchmark discussion on the PR: the premise of issue #18 item 2 is false in CPython, because itertools.tee() re-tees an already-teed iterator via its cheap __copy__ rather than nesting. Repeated peeks on the tee-based implementation are already flat and ~constant time (measured linear at ~0.22us/peek over 160k peeks), so the buffer only bought ~2x on rare peek-heavy loops while adding machinery, a public unpeek() API, and (in the eager form) a 2.5-7.5x penalty on ordinary pipelines. peek() is copy().next() again, next_if is built on peek + next with identical semantics, and unpeek()/_Peekable/_MISSING are removed. All other #18 enhancements are unchanged. Co-Authored-By: Claude Fable 5 --- README.md | 2 +- doc/apidoc.md | 26 ++---------- relnotes.md | 6 --- src/itrx/itr.py | 81 +++---------------------------------- src/test/test_collection.py | 29 +------------ 5 files changed, 13 insertions(+), 131 deletions(-) diff --git a/README.md b/README.md index facb3cc..b7cdbaa 100644 --- a/README.md +++ b/README.md @@ -102,7 +102,7 @@ However, some methods are **eager consumers**. These methods iterate over and co When working with `Itr`, keep these points in mind: * **Single-Pass Iterators:** Like all Python iterators, `Itr` instances (and their underlying iterators) can generally only be consumed once. If you need to process the same sequence multiple times, use methods like `copy()`, `cycle()`, or `repeat()` as necessary. -* **No Rewinding:** It's not possible to rewind an `Itr` to an earlier state. You can "preview" the next value using the `peek()` method, which holds the value in a single-item lookahead buffer (so repeated peeks are cheap), and conditionally consume it with `next_if()`. +* **No Rewinding:** It's not possible to rewind an `Itr` to an earlier state. You can "preview" the next value using the `peek()` method, and conditionally consume it with `next_if()`. * **Infinite Iterators:** Be cautious with open-ended iterators (e.g., those from `itertools.count()` or custom generators). Eager evaluation methods (like `collect()`, `count()`, `reduce()`) will attempt to consume the entire sequence, potentially leading to infinite loops or out-of-memory errors if applied to an infinite source. ## API Reference diff --git a/doc/apidoc.md b/doc/apidoc.md index 776af26..3d88198 100644 --- a/doc/apidoc.md +++ b/doc/apidoc.md @@ -504,15 +504,16 @@ Returns: Returns the next element in the sequence without advancing the iterator. -The element is held in a single-item lookahead buffer (like Rust's `Peekable`), so repeated calls are O(1) -and return the same item until the iterator is advanced. - Returns: T: The next element in the sequence. Raises: StopIteration: If the iterator is exhausted. +Note: + This method copies the iterator to avoid modifying the original iterator's state. The copy is cheap + even for repeated peeks: `itertools.tee` re-tees an already-teed iterator without adding a layer. + Example: >>> it = Itr([1, 2]) >>> it.peek(), it.peek() @@ -757,25 +758,6 @@ Examples: [0, 1, 2] -### `unpeek` - -Remove the lookahead buffer installed by `peek` or `next_if`, restoring direct iteration. - -The buffer adds a small per-item overhead to all subsequent iteration of this Itr, so removing it when no -further lookahead is needed can speed up bulk consumption. Any pending peeked item is retained, and this -is a no-op if nothing has been peeked. Peeking again afterwards is safe: the buffer is simply reinstated. - -Returns: - Itr[T]: self. - -Example: - >>> it = Itr([1, 2, 3]) - >>> it.peek() - 1 - >>> it.unpeek().collect() - (1, 2, 3) - - ### `unzip` Splits the iterator of pairs into two separate iterators, each containing the elements from one position of diff --git a/relnotes.md b/relnotes.md index 0b90101..2504750 100644 --- a/relnotes.md +++ b/relnotes.md @@ -13,12 +13,6 @@ - `dedup()`: lazily remove *consecutive* duplicates, keeping the first of each run (like Rust's `dedup`); works on infinite iterators. - `zip_longest(other, fillvalue=...)`: like `zip`, but continues to the end of the longer input, padding with `fillvalue`. - `sorted_by(key, reverse=...)`: eager stable sort by a key function. -- `unpeek()`: remove the lookahead buffer installed by `peek`/`next_if` (retaining any pending item), restoring direct iteration speed when no further lookahead is needed. - -### Improvements - -- `peek` now uses a single-item lookahead buffer (like Rust's `Peekable`) instead of copying the iterator with `itertools.tee`, so repeated peeks are O(1) and no longer build up nested tee layers. -- The lookahead buffer is now installed lazily, on the first call to `peek`/`next_if`, rather than wrapping every `Itr` unconditionally. Pipelines that never peek keep the previous C-fast-path iteration speed (the unconditional wrapper cost 2.5-7.5x on bulk operations). ### Bug fixes diff --git a/src/itrx/itr.py b/src/itrx/itr.py index e035ed8..b56f243 100644 --- a/src/itrx/itr.py +++ b/src/itrx/itr.py @@ -9,45 +9,6 @@ Predicate = Callable[[T], bool] -_MISSING: Any = object() # sentinel for an empty peek buffer, since None is a valid item - - -class _Peekable[U](Iterator[U]): - """An iterator with a single-item lookahead buffer (like Rust's `Peekable`). - - A peeked item stays part of the iteration: `__next__` yields it before drawing from the underlying iterator, - so anything consuming this iterator sees the full sequence. - """ - - __slots__ = ("_it", "_peeked") - - def __init__(self, it: Iterable[U]) -> None: - self._it = iter(it) - self._peeked: Any = _MISSING - - def __next__(self) -> U: - if self._peeked is not _MISSING: - item = cast("U", self._peeked) - self._peeked = _MISSING - return item - return next(self._it) - - def peek(self) -> U: - if self._peeked is _MISSING: - self._peeked = next(self._it) - return cast("U", self._peeked) - - def unwrap(self) -> Iterator[U]: - """Return an iterator over the remaining items without the lookahead buffer. - - Any buffered item is prepended, so no items are lost. - """ - if self._peeked is _MISSING: - return self._it - item = cast("U", self._peeked) - self._peeked = _MISSING - return itertools.chain((item,), self._it) - class Itr[T](Iterator[T]): """A generic iterator adaptor class inspired by Rust's Iterator trait, providing a composable API for @@ -71,16 +32,6 @@ def __next__(self) -> T: "Implement the next method of the Iterator protocol" return next(self._it) - def _peekable(self) -> _Peekable[T]: - """Wrap the underlying iterator in a lookahead buffer on first use. - - Wrapping lazily keeps bulk iteration on the C fast path: pipelines that never peek pay no per-item cost - for the buffer. - """ - if not isinstance(self._it, _Peekable): - self._it = _Peekable(self._it) - return cast("_Peekable[T]", self._it) - def accumulate(self, func: Callable[[T, T], T] | None = None, *, initial: T | None = None) -> "Itr[T]": """ Return an iterator over the accumulated results of applying the function (or sum by default) to the items. Does @@ -564,7 +515,7 @@ def next_if(self, predicate: Predicate[T]) -> T | None: 3 """ try: - item = self._peekable().peek() + item = self.peek() except StopIteration: return None if predicate(item): @@ -622,15 +573,16 @@ def partition(self, predicate: Predicate[T]) -> tuple["Itr[T]", "Itr[T]"]: def peek(self) -> T: """Returns the next element in the sequence without advancing the iterator. - The element is held in a single-item lookahead buffer (like Rust's `Peekable`), so repeated calls are O(1) - and return the same item until the iterator is advanced. - Returns: T: The next element in the sequence. Raises: StopIteration: If the iterator is exhausted. + Note: + This method copies the iterator to avoid modifying the original iterator's state. The copy is cheap + even for repeated peeks: `itertools.tee` re-tees an already-teed iterator without adding a layer. + Example: >>> it = Itr([1, 2]) >>> it.peek(), it.peek() @@ -640,7 +592,7 @@ def peek(self) -> T: >>> it.peek() 2 """ - return self._peekable().peek() + return self.copy().next() def position(self, predicate: Predicate[T]) -> int: """ @@ -885,27 +837,6 @@ def tee(self, n: int = 2) -> tuple["Itr[T]", ...]: raise ValueError(f"tee requires at least 1 iterator, got {n}") return tuple(Itr(t) for t in itertools.tee(self._it, n)) - def unpeek(self) -> "Itr[T]": - """Remove the lookahead buffer installed by `peek` or `next_if`, restoring direct iteration. - - The buffer adds a small per-item overhead to all subsequent iteration of this Itr, so removing it when no - further lookahead is needed can speed up bulk consumption. Any pending peeked item is retained, and this - is a no-op if nothing has been peeked. Peeking again afterwards is safe: the buffer is simply reinstated. - - Returns: - Itr[T]: self. - - Example: - >>> it = Itr([1, 2, 3]) - >>> it.peek() - 1 - >>> it.unpeek().collect() - (1, 2, 3) - """ - if isinstance(self._it, _Peekable): - self._it = cast("Iterator[T]", self._it.unwrap()) - return self - def unzip[U, V](self: "Itr[tuple[U, V]]") -> tuple["Itr[U]", "Itr[V]"]: """Splits the iterator of pairs into two separate iterators, each containing the elements from one position of the pairs. diff --git a/src/test/test_collection.py b/src/test/test_collection.py index a5c23e5..55b02e7 100644 --- a/src/test/test_collection.py +++ b/src/test/test_collection.py @@ -97,8 +97,8 @@ def test_peek_repeated_is_stable() -> None: assert it.peek() == 2 -def test_peek_does_not_deepen_iterator() -> None: - # peek is O(1): it must not wrap the underlying iterator in a new layer on each call +def test_peek_repeated_does_not_degrade() -> None: + # tee re-tees an already-teed iterator without nesting, so many peeks stay cheap and lossless it = Itr([1, 2]) for _ in range(10000): assert it.peek() == 1 @@ -133,31 +133,6 @@ def test_peeked_item_seen_by_for_loop() -> None: assert list(it) == [1, 2, 3] -def test_unpeek_retains_pending_item() -> None: - it = Itr([1, 2, 3]) - assert it.peek() == 1 - assert it.unpeek().collect() == (1, 2, 3) - - -def test_unpeek_with_empty_buffer() -> None: - it = Itr([1, 2, 3]) - assert it.peek() == 1 - assert it.next() == 1 # drains the buffer - assert it.unpeek().collect() == (2, 3) - - -def test_unpeek_without_peek_is_noop() -> None: - it = Itr([1, 2, 3]) - assert it.unpeek().collect() == (1, 2, 3) - - -def test_peek_after_unpeek() -> None: - it = Itr([1, 2, 3]) - assert it.peek() == 1 - assert it.unpeek().peek() == 1 - assert it.collect() == (1, 2, 3) - - def test_next_if_consumes_on_match() -> None: it = Itr([1, 2, 3]) assert it.next_if(lambda x: x < 3) == 1 From 0e3192c1eff9e5a7e541c71d140440a3e45dd74a Mon Sep 17 00:00:00 2001 From: virgesmith Date: Mon, 6 Jul 2026 08:20:00 +0100 Subject: [PATCH 5/6] update precommit hooks --- .pre-commit-config.yaml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1887c5a..503b792 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,17 +1,17 @@ repos: - repo: https://github.com/astral-sh/uv-pre-commit # uv version. - rev: 0.11.5 + rev: 0.11.26 hooks: - id: uv-lock - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.15.9 + rev: v0.15.20 hooks: - id: ruff-check args: [--fix] - id: ruff-format - repo: https://github.com/astral-sh/ty-pre-commit # ty version. - rev: v0.0.49 + rev: v0.0.56 hooks: - id: ty From 1c0627de8f28d2378a210b0ed63938c7d52f711e Mon Sep 17 00:00:00 2001 From: virgesmith Date: Mon, 6 Jul 2026 08:30:15 +0100 Subject: [PATCH 6/6] Release 0.4.0: bump version, finalise relnotes Co-Authored-By: Claude Fable 5 --- doc/apidoc.md | 2 +- pyproject.toml | 2 +- relnotes.md | 2 +- uv.lock | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/doc/apidoc.md b/doc/apidoc.md index 3d88198..85b9a6b 100644 --- a/doc/apidoc.md +++ b/doc/apidoc.md @@ -1,4 +1,4 @@ -# `Itr` v0.3.0 class documentation +# `Itr` v0.4.0 class documentation A generic iterator adaptor class inspired by Rust's Iterator trait, providing a composable API for functional-style iteration and transformation over Python iterables. ## Public methods diff --git a/pyproject.toml b/pyproject.toml index 90265b3..081b916 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "itrx" -version = "0.3.0" +version = "0.4.0" description = "A chainable iterator adapter" readme = "README.md" authors = [ diff --git a/relnotes.md b/relnotes.md index 2504750..a8b5af8 100644 --- a/relnotes.md +++ b/relnotes.md @@ -1,4 +1,4 @@ -## Unreleased +## 0.4.0 ### Breaking changes diff --git a/uv.lock b/uv.lock index 5a1d11e..2207d13 100644 --- a/uv.lock +++ b/uv.lock @@ -323,7 +323,7 @@ wheels = [ [[package]] name = "itrx" -version = "0.3.0" +version = "0.4.0" source = { editable = "." } [package.optional-dependencies]