Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions .github/workflows/lint-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,26 +16,27 @@ jobs:
strategy:
fail-fast: false
matrix:
python-version: ["3.12", "3.13", "3.14"]
python-version: ["3.13", "3.14", "3.15"]
os: [ubuntu-latest, windows-latest, macos-latest]
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: "Setup uv ${{ matrix.python-version }} / ${{ matrix.os }}"
uses: astral-sh/setup-uv@v8.1.0
uses: astral-sh/setup-uv@v10.2.0
with:
python-version: ${{ matrix.python-version }}
- name: Build
run: uv sync --dev
run: uv sync --dev --extra examples
- name: Lint
run: |
uv run ruff check
uv run ty check src
uv run ruff format --check
uv run ty check src doc
- name: Test
run: |
uv run pytest
- name: Upload coverage report
uses: actions/upload-artifact@v7
if: ${{ (runner.os == 'Linux') && (matrix.python-version == '3.13') }}
if: ${{ (runner.os == 'Linux') && (matrix.python-version == '3.14') }}
with:
name: coverage-html-${{ matrix.os }}-${{ matrix.python-version }}
path: htmlcov/
4 changes: 2 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ jobs:
id-token: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Install prerequisites
uses: astral-sh/setup-uv@v8.1.0
uses: astral-sh/setup-uv@v10.2.0
- name: Install project
run: uv sync --all-groups
- name: Build wheel
Expand Down
6 changes: 3 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
repos:
- repo: https://github.com/astral-sh/uv-pre-commit
# uv version.
rev: 0.11.26
rev: 0.12.23
hooks:
- id: uv-lock
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.20
rev: v0.16.10
hooks:
- id: ruff-check
args: [--fix]
- id: ruff-format
- repo: https://github.com/astral-sh/ty-pre-commit
# ty version.
rev: v0.0.56
rev: v0.0.85
hooks:
- id: ty
8 changes: 6 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,15 @@ When reviewing a PR or diff, check:
## QA Rules

- Run the full gate suite (`ruff check`, `ruff format --check`, `ty check`, `pytest`) before declaring any task done.
- CI runs the matrix: Python 3.12, 3.13, 3.14 × ubuntu, windows, macos. Flag anything that might be platform- or version-specific.
- CI runs the matrix: Python 3.13, 3.14, 3.15 × ubuntu, windows, macos. Flag anything that might be platform- or version-specific.
- If a test is skipped or marked `xfail`, leave a comment explaining why and when it can be removed.
- Coverage HTML is uploaded as a CI artefact for Linux/Python 3.13. Check it for any uncovered lines after adding new code.
- Coverage HTML is uploaded as a CI artefact for Linux/Python 3.14. Check it for any uncovered lines after adding new code.
- Doctests in [README.md](README.md) are executed by pytest (`--doctest-glob=README.md`). Keep them runnable.

## Maintenance

- Periodically check that the GitHub Actions in [.github/workflows/](.github/workflows/) are pinned to their latest releases, and do this check whenever you edit a workflow. Find the latest release of each action with `gh release view --repo <owner>/<action> --json tagName`, e.g. `gh release view --repo astral-sh/setup-uv --json tagName`. A major-version tag (e.g. `@v7`) already tracks minor and patch releases, so only bump it for a new major version. Bump any outdated pins in both `lint-test.yml` and `publish.yml`, read the release notes for breaking changes (especially on major version bumps), and confirm CI passes on the PR.

## Repository Layout

```
Expand Down
1 change: 0 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,6 @@ for item in filter(
map(lambda x: x * x, islice(islice(reversed(range(100)), None, None, 4), 10, None)),
):
print(item)

```

### Outputs
Expand Down
17 changes: 5 additions & 12 deletions doc/examples.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
},
{
"cell_type": "code",
"execution_count": 1,
"execution_count": null,
"id": "8b2541fb",
"metadata": {
"execution": {
Expand All @@ -29,14 +29,7 @@
}
},
"outputs": [],
"source": [
"import contextlib\n",
"from collections import defaultdict\n",
"from collections.abc import Generator, Iterator\n",
"from typing import Self\n",
"\n",
"from itrx import Itr"
]
"source": "import contextlib\nfrom collections import defaultdict\nfrom collections.abc import Generator, Iterator\nfrom typing import Self\n\nfrom IPython.display import display\n\nfrom itrx import Itr"
},
{
"cell_type": "code",
Expand All @@ -52,7 +45,7 @@
},
"outputs": [],
"source": [
"def fibonacci() -> Generator[int, None, None]:\n",
"def fibonacci() -> Generator[int]:\n",
" \"Generator implementation of Fibonacci\"\n",
" a, b = 0, 1\n",
" while True:\n",
Expand Down Expand Up @@ -102,7 +95,7 @@
},
"outputs": [],
"source": [
"def collatz(n: int) -> Generator[int, None, None]:\n",
"def collatz(n: int) -> Generator[int]:\n",
" \"\"\"Yield the Collatz sequence for the given input\"\"\"\n",
" assert n > 0\n",
" while n != 1:\n",
Expand Down Expand Up @@ -2108,4 +2101,4 @@
},
"nbformat": 4,
"nbformat_minor": 5
}
}
8 changes: 4 additions & 4 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ authors = [
]
license = "MIT"
license-files = ["LICENCE.md"]
requires-python = ">=3.12"
requires-python = ">=3.13"
classifiers = [
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Programming Language :: Python :: 3.15",
"Operating System :: OS Independent",
]

Expand All @@ -32,8 +32,8 @@ dev = [
"pre-commit>=4.5.1",
"pytest>=9.1.1",
"pytest-cov>=6.2.1",
"ruff>=0.15.20",
"ty>=0.0.56",
"ruff>=0.16.10",
"ty>=0.0.85",
]

[project.optional-dependencies]
Expand Down
6 changes: 6 additions & 0 deletions relnotes.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## Unreleased

### Breaking changes

- Python 3.12 is no longer supported. Supported versions are now 3.13, 3.14 and 3.15.

## 0.5.0

### Breaking changes
Expand Down
44 changes: 22 additions & 22 deletions src/itrx/itr.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,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 Itr(itertools.batched(self._it, n, strict=False))

def chain[U](self, *others: Iterable[U]) -> "Itr[T | U]":
"""Chain this iterator with one or more other iterables, yielding all items from self followed by all items from
Expand All @@ -139,7 +139,7 @@ def collect(self, container: type[set[T]]) -> set[T]: ...
@overload
def collect[K, V](self, container: type[dict[K, V]]) -> dict[K, V]: ...

def collect(self, container: type[_CollectT] = tuple) -> _CollectT: # ty: ignore[invalid-parameter-default]
def collect(self, container: Callable[[Iterable[T]], _CollectT] = tuple) -> _CollectT:
"""Collect all remaining items from the iterator into a container (tuple by default).

Returns:
Expand Down Expand Up @@ -240,7 +240,7 @@ def dedup_with_count(self) -> "Itr[tuple[T, int]]":
((4, 2), (2, 1), (3, 2), (1, 1))
"""
# note the (item, count) ordering matches value_counts, and is the reverse of Rust's dedup_with_count
return cast("Itr[tuple[T, int]]", Itr((k, sum(1 for _ in g)) for k, g in itertools.groupby(self._it)))
return Itr((k, sum(1 for _ in g)) for k, g 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
Expand All @@ -255,7 +255,7 @@ def enumerate(self, *, start: int = 0) -> "Itr[tuple[int, T]]":
>>> Itr("ab").enumerate(start=1).collect()
((1, 'a'), (2, 'b'))
"""
return cast("Itr[tuple[int, T]]", Itr(enumerate(self._it, start)))
return Itr(enumerate(self._it, start))

def eq(self, other: Iterable[Any]) -> bool:
"""Compare the remaining items with another iterable, element by element (like Rust's `Iterator::eq`).
Expand All @@ -277,6 +277,7 @@ def eq(self, other: Iterable[Any]) -> bool:
>>> Itr([1, 2, 3]).eq([1, 2])
False
"""
# TODO(py3.15): when 3.15 is the minimum, use a PEP 661 sentinel("UNEQUAL") here for a readable repr
unequal = object()
return all(a == b for a, b in itertools.zip_longest(self._it, other, fillvalue=unequal))

Expand Down Expand Up @@ -407,7 +408,7 @@ def chunk_by[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(self._it, key=key_fn))
return cast("Itr[tuple[U, tuple[T, ...]]]", Itr(groups))
return Itr(groups)

def groupby[U](self, grouper: Callable[[T], U], *, sort: bool = True) -> "Itr[tuple[U, tuple[T,...]]]":
"""
Expand Down Expand Up @@ -446,7 +447,7 @@ def groupby[U](self, grouper: Callable[[T], U], *, sort: bool = True) -> "Itr[tu
for item in self._it:
groups_by_key.setdefault(grouper(item), []).append(item)
groups = ((k, tuple(v)) for k, v in groups_by_key.items())
return cast("Itr[tuple[U, tuple[T, ...]]]", Itr(groups))
return Itr(groups)

def inspect(self, func: Callable[[T], None]) -> "Itr[T]":
"""
Expand Down Expand Up @@ -485,7 +486,7 @@ def intersperse[U](self, item: U) -> "Itr[T | U]":

"""

def intersperser(item: U) -> Generator[T | U, None, None]:
def intersperser(item: U) -> Generator[T | U]:
try:
current = next(self._it)
while True:
Expand All @@ -495,7 +496,7 @@ def intersperser(item: U) -> Generator[T | U, None, None]:
except StopIteration:
return

return cast("Itr[T | U]", Itr(intersperser(item)))
return Itr(intersperser(item))

def interleave[U](self, *others: Iterable[U]) -> "Itr[T | U]":
"""
Expand All @@ -517,15 +518,17 @@ def interleave[U](self, *others: Iterable[U]) -> "Itr[T | U]":
>>> Itr([1, 4, 7]).interleave([2, 5], [3]).collect()
(1, 2, 3, 4, 5, 7)
"""
# TODO(py3.15): when 3.15 is the minimum, use a PEP 661 sentinel("MISSING") here: ty can narrow
# `item is not MISSING` on a declared sentinel (it can't on object()), so the cast below can be dropped
_sentinel = object()

def interleaver() -> Generator[T | U, None, None]:
def interleaver() -> Generator[T | U]:
for row in itertools.zip_longest(self._it, *others, fillvalue=_sentinel):
for item in row:
if item is not _sentinel:
yield cast("T | U", item)

return cast("Itr[T | U]", Itr(interleaver()))
return Itr(interleaver())

def is_sorted(self, key: Callable[[T], Any] | None = None, *, reverse: bool = False) -> bool:
"""Check whether the remaining items are in sorted order (like Rust's `Iterator::is_sorted`).
Expand Down Expand Up @@ -758,7 +761,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 Itr(itertools.pairwise(self._it))

def partition(self, predicate: Predicate[T]) -> tuple["Itr[T]", "Itr[T]"]:
"""
Expand All @@ -784,7 +787,7 @@ def partition(self, predicate: Predicate[T]) -> tuple["Itr[T]", "Itr[T]"]:
matching: deque[T] = deque()
non_matching: deque[T] = deque()

def side(mine: deque[T]) -> Generator[T, None, None]:
def side(mine: deque[T]) -> Generator[T]:
while True:
while not mine:
try:
Expand Down Expand Up @@ -871,7 +874,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 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, taking the first item as the initial value.
Expand Down Expand Up @@ -932,7 +935,7 @@ def rolling(self, n: int) -> "Itr[tuple[T, ...]]":

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)))
return Itr(zip(*shifted_iterators, strict=False))

def scan[S, U](self, init: S, func: Callable[[S, T], tuple[S, U] | None]) -> "Itr[U]":
"""Lazily map items through a running state, optionally stopping early (like Rust's `Iterator::scan`).
Expand All @@ -956,7 +959,7 @@ def scan[S, U](self, init: S, func: Callable[[S, T], tuple[S, U] | None]) -> "It
(1, 3)
"""

def gen() -> Generator[U, None, None]:
def gen() -> Generator[U]:
state = init
for item in self._it:
result = func(state, item)
Expand Down Expand Up @@ -1172,7 +1175,7 @@ def unique(self, key: Callable[[T], Any] | None = None) -> "Itr[T]":
('apple', 'banana')
"""

def gen() -> Generator[T, None, None]:
def gen() -> Generator[T]:
seen: set[Any] = set()
for item in self._it:
k = item if key is None else key(item)
Expand Down Expand Up @@ -1214,7 +1217,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 Itr(Counter(self._it).most_common())

def zip[U](self, other: Iterable[U], *, strict: bool = False) -> "Itr[tuple[T, U]]":
"""Yield pairs of items from this iterator and another iterable, stopping at the end of the shorter one.
Expand All @@ -1231,7 +1234,7 @@ def zip[U](self, other: Iterable[U], *, strict: bool = False) -> "Itr[tuple[T, U
>>> Itr([1, 2, 3]).zip("ab").collect()
((1, 'a'), (2, 'b'))
"""
return cast("Itr[tuple[T, U]]", Itr(zip(self._it, other, strict=strict)))
return Itr(zip(self._it, other, strict=strict))

def zip_longest[U, V](
self, other: Iterable[U], *, fillvalue: V | None = None
Expand All @@ -1252,7 +1255,4 @@ def zip_longest[U, V](
>>> 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(self._it, other, fillvalue=fillvalue)),
)
return Itr(itertools.zip_longest(self._it, other, fillvalue=fillvalue))
Loading
Loading