Skip to content

Add itertools adaptors: takewhile, dropwhile, filterfalse, starmap - #657

Open
rewitt94 wants to merge 10 commits into
pydantic:mainfrom
rewitt94:itertools-batch-two
Open

Add itertools adaptors: takewhile, dropwhile, filterfalse, starmap#657
rewitt94 wants to merge 10 commits into
pydantic:mainfrom
rewitt94:itertools-batch-two

Conversation

@rewitt94

@rewitt94 rewitt94 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Four more itertools callables on the source-wrapping foundation from #635 (now merged, so this is rebased onto main): takewhile, dropwhile, filterfalse and starmap. All four apply a user callable per item, which pairwise/compress/islice/chain/cycle do not — that is the new capability and the main thing to review.

Applying the callable re-enters the VM. next calls evaluate_function, which can raise or allocate, so the item under test is held in a DropGuard across the call: it is yielded on one answer and dropped on the other. The callable is also a second GC edge alongside the source, so for_each_child_id and py_dec_ref_ids trace both.

Latching differs per adaptor and is user-visible. takewhile latches done on the first rejection, so neither the predicate nor the source is touched again. dropwhile clears dropping on the first failure and yields everything after it untested. filterfalse and starmap have no spent flag at all — source exhaustion is their only end condition.

filterfalse accepts None. It shares the one-argument application in predicate.rs with the two while adaptors, plus the truth-test path filter(None, ...) uses.

Spreading is arity-sensitive. starmap packs each item into ArgValues::Empty/One/Two rather than ArgsKargs, because extractors such as get_one_arg match the shape structurally — a one-element ArgsKargs is rejected as the wrong shape (abs() takes exactly one argument) even though the arity is right.

A callable that suspends is rejected, not paused. evaluate_function runs a frame to completion and cannot yield to the host, so a callable reaching an external function or an os operation raises NotImplementedError where CPython would simply call it — the same restriction as __init__/__next__/__repr__. Documented in limitations/itertools.md and covered by a Rust-side test.

Heap footprint. The four new variants are 32–40 bytes, under Islice/Chain's 56, so ItertoolsIter stays at 64 and HeapData at 72 — inside the 80-byte ceiling from #636.

Not implemented: accumulate, batched, combinations, combinations_with_replacement, groupby, permutations, product, tee, zip_longest.

Compatible dump change: new StaticStrings, Type, MontyType and ItertoolsIter variants are appended, so only the fingerprint moves.


Summary by cubic

Adds itertools adaptors takewhile, dropwhile, filterfalse, and starmap, and enforces max_duration in native adaptor loops and eager drains. Previously, discarding loops and drains could spin; now they poll the tracker. The new adaptors execute a per-item callable and are classified as concrete iterators.

  • Per-item callables: executed via evaluate_function; external/host callables raise NotImplementedError naming the adaptor. filterfalse(None, iterable) keeps falsy items. starmap spreads each item into arguments; a non-iterable item raises TypeError. All four take exactly two positional-only args and resolve the iterable eagerly; typeshed stubs updated.
  • Semantics: takewhile latches on first predicate failure, releases its predicate and source, and does not latch on transient StopIteration. dropwhile stops dropping after the first failure, yields the rest untested, and keeps its predicate owned until destruction. filterfalse and starmap never latch and re-drive past transient StopIteration.
  • Time limits: native discard loops in dropwhile/filterfalse and in chain/compress/islice poll the tracker; eager drains, including starmap and shared iterator drains, check time per yield. Overhead is amortized; behavior otherwise unchanged.
  • Internal: shared per-item step logic for the predicate-driven adaptors; added predicate::call_predicate and routed filter() through it. New Type/MontyType/StaticStrings/ItertoolsIter variants added; adaptors treated as iterator types; dump fingerprint bumped. The ItertoolsIter size-budget assertion is limited to 64‑bit hosts.

Written for commit e865ed0. Summary will update on new commits.

Review in cubic

@codspeed-hq

codspeed-hq Bot commented Aug 4, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 36 untouched benchmarks
⏩ 16 skipped benchmarks1


Comparing rewitt94:itertools-batch-two (e865ed0) with main (85299f1)

Open in CodSpeed

Footnotes

  1. 16 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 19 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="crates/monty/src/types/itertools/predicate.rs">

<violation number="1" location="crates/monty/src/types/itertools/predicate.rs:16">
P3: Predicate invocation now has two semantically identical ownership and evaluation paths, so future fixes to callable errors or heap cleanup can diverge between `filter()` and these adaptors. Centralizing this operation and reusing it from `builtin_filter` would keep the behavior consistent.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread crates/monty/src/types/type.rs
Comment thread crates/monty/test_cases/itertools__adaptors.py Outdated
Comment thread crates/monty/src/types/itertools/dropwhile.rs Outdated
Comment thread crates/monty/src/types/itertools/takewhile.rs Outdated
/// (an external function, an `os` call) is rejected rather than paused; `ctx`
/// names the adaptor in that error.
pub(super) fn call_predicate(predicate: &Value, item: &Value, ctx: &'static str, vm: &mut VM<'_>) -> RunResult<bool> {
let arg = item.clone_with_heap(vm.heap);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Predicate invocation now has two semantically identical ownership and evaluation paths, so future fixes to callable errors or heap cleanup can diverge between filter() and these adaptors. Centralizing this operation and reusing it from builtin_filter would keep the behavior consistent.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At crates/monty/src/types/itertools/predicate.rs, line 16:

<comment>Predicate invocation now has two semantically identical ownership and evaluation paths, so future fixes to callable errors or heap cleanup can diverge between `filter()` and these adaptors. Centralizing this operation and reusing it from `builtin_filter` would keep the behavior consistent.</comment>

<file context>
@@ -0,0 +1,21 @@
+/// (an external function, an `os` call) is rejected rather than paused; `ctx`
+/// names the adaptor in that error.
+pub(super) fn call_predicate(predicate: &Value, item: &Value, ctx: &'static str, vm: &mut VM<'_>) -> RunResult<bool> {
+    let arg = item.clone_with_heap(vm.heap);
+    let result = vm.evaluate_function(ctx, predicate, ArgValues::One(arg))?;
+    let truthy = result.py_bool(vm);
</file context>

@rewitt94 rewitt94 Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — builtin_filter now calls the shared helper. predicate.rs moves to the crate root (crate::predicate) and call_predicate becomes pub(crate), so builtins/ does not reach into types::itertools::.

@rewitt94
rewitt94 force-pushed the itertools-batch-two branch from d9b18dc to 97af3ef Compare August 4, 2026 17:06
rewitt94 added a commit to rewitt94/monty that referenced this pull request Aug 4, 2026
`Type::is_iterator` lists every other concrete iterator, including the seven
earlier `itertools` ones, and the four new variants were not added to it. The
protocol check for a user-defined `__iter__` consults it, so returning one of
them from `__iter__` was rejected:

    class Wrapped:
        def __iter__(self):
            return itertools.takewhile(lambda x: x < 3, [1, 2, 3])

    list(Wrapped())
    # CPython: [1, 2]
    # Monty:   TypeError: iter() returned non-iterator of type 'itertools.takewhile'

`pairwise` in the same position already worked, which is what makes it a
classification gap rather than anything to do with these adaptors' behaviour.
Reported by cubic on pydantic#657.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rewitt94 added a commit to rewitt94/monty that referenced this pull request Aug 4, 2026
The two guard assertions stopped at the first item, before the source's
temporary `StopIteration`, so they passed whether or not either adaptor
treated exhaustion as terminal — which is the regression they exist to catch.
Both are now driven through the exception and asserted on the item after it.
Reported by cubic on pydantic#657.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 19 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread crates/monty/test_cases/itertools__adaptors.py
Comment thread crates/monty/test_cases/itertools__adaptors.py
Comment thread crates/monty/src/types/itertools/filterfalse.rs
rewitt94 added a commit to rewitt94/monty that referenced this pull request Aug 4, 2026
Two gaps cubic found on pydantic#657, both cases of a test that does not check what
the code claims:

`starmap` has no spent flag, so a transient `StopIteration` from its source
must not finish it — nothing covered that, and an implementation that latched
would have passed. `StutteringPairs` drives it through the exception and
asserts the item after it.

`takewhile`'s latch is documented as stopping the source being touched, not
just the predicate being called, and only the predicate half was asserted.
`Counting` records how often it was asked, so the second drain proves the
source is left alone rather than merely yielding nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rewitt94 added a commit to rewitt94/monty that referenced this pull request Aug 6, 2026
`Type::is_iterator` lists every other concrete iterator, including the seven
earlier `itertools` ones, and the four new variants were not added to it. The
protocol check for a user-defined `__iter__` consults it, so returning one of
them from `__iter__` was rejected:

    class Wrapped:
        def __iter__(self):
            return itertools.takewhile(lambda x: x < 3, [1, 2, 3])

    list(Wrapped())
    # CPython: [1, 2]
    # Monty:   TypeError: iter() returned non-iterator of type 'itertools.takewhile'

`pairwise` in the same position already worked, which is what makes it a
classification gap rather than anything to do with these adaptors' behaviour.
Reported by cubic on pydantic#657.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rewitt94 added a commit to rewitt94/monty that referenced this pull request Aug 6, 2026
The two guard assertions stopped at the first item, before the source's
temporary `StopIteration`, so they passed whether or not either adaptor
treated exhaustion as terminal — which is the regression they exist to catch.
Both are now driven through the exception and asserted on the item after it.
Reported by cubic on pydantic#657.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rewitt94 added a commit to rewitt94/monty that referenced this pull request Aug 6, 2026
Two gaps cubic found on pydantic#657, both cases of a test that does not check what
the code claims:

`starmap` has no spent flag, so a transient `StopIteration` from its source
must not finish it — nothing covered that, and an implementation that latched
would have passed. `StutteringPairs` drives it through the exception and
asserts the item after it.

`takewhile`'s latch is documented as stopping the source being touched, not
just the predicate being called, and only the predicate half was asserted.
`Counting` records how often it was asked, so the second drain proves the
source is left alone rather than merely yielding nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rewitt94 added a commit to rewitt94/monty that referenced this pull request Aug 6, 2026
cubic asked for this on pydantic#657: `call_predicate` — new in this PR, and used by
`takewhile`, `dropwhile` and `filterfalse` — duplicates the predicate call
already in `builtin_filter`, so a future fix to callable errors or heap
cleanup could diverge between the two.

`builtin_filter` is pre-existing and otherwise untouched by this branch, so
this diff is purely that retrofit. It is behaviour-neutral: the hand-rolled
`py_bool` -> `drop_with` -> `is_truthy?` and `call_predicate`'s `defer_drop!`
release the result on exactly the same paths, and `evaluate_function` consumes
the `ArgValues` either way. `predicate.rs` moves to the crate root so
`builtins/` need not reach into `types::itertools::`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rewitt94
rewitt94 force-pushed the itertools-batch-two branch from cd8b45f to e5dc968 Compare August 6, 2026 09:35
let is_truthy = result.py_bool(vm);
result.drop_with(vm);
is_truthy?
call_predicate(function, item, "filter()", vm)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This diff is here because cubic asked for it (see the thread on predicate.rs). builtin_filter is pre-existing and otherwise untouched by this branch — the change routes it through call_predicate, the helper this PR adds for takewhile/dropwhile/filterfalse, so the two predicate-call paths cannot drift apart. Behaviour-neutral.

rewitt94 added a commit to rewitt94/monty that referenced this pull request Aug 6, 2026
cubic asked for this on pydantic#657: releasing the predicate on its first rejection
diverged from CPython, which holds `lz->func` for the iterator's whole life.
Nothing in sandboxed Python can observe the difference today — there is no
`__del__`, no weakrefs, no `getrefcount` — but the divergence would become
visible the moment any finalizer lands, so match CPython now.

`DropWhile` swaps `predicate: Option<Value>` for `predicate: Value` plus a
`dropping` flag; the predicate is cloned only while it is still consulted, and
the rejection branch clears the flag rather than releasing.
`refcount__itertools_adaptors.py` asserted the old lifetime explicitly, so its
`drop_pred` expectation moves from 1 to 2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
// TODO: when this fails, box the offending variant (`GroupBy(Box<GroupBy>)`),
// not the enum and not at the `HeapData` boundary.
#[cfg(target_pointer_width = "64")]
const _: () = assert!(mem::size_of::<ItertoolsIter>() <= mem::size_of::<Dict>());

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We will need to box ItertoolsIter soon as per #636 - this is just a compile check to remind

rewitt94 added a commit to rewitt94/monty that referenced this pull request Aug 6, 2026
cubic asked for this on pydantic#657: `takewhile`, `dropwhile` and `filterfalse` each
opened `next` with the same clone-guard-fetch-guard scaffolding, so the
refcount-critical part — guarding the item across a test that may raise — was
maintained in three copies.

`step::next_tested` now owns the predicate and source clones, the fetch and the
item guard, runs the caller's test inside the guarded region and hands the item
back owned; `step::next_item` covers `dropwhile`'s untested tail. Each adaptor
keeps only its own decision, which is all that actually differed.

The `Value::None` truth test stays at `filterfalse`'s call site deliberately:
folding it into the shared helper would make `takewhile(None, ...)` silently
truth-test rather than raise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rewitt94 added a commit to rewitt94/monty that referenced this pull request Aug 6, 2026
`Type::is_iterator` lists every other concrete iterator, including the seven
earlier `itertools` ones, and the four new variants were not added to it. The
protocol check for a user-defined `__iter__` consults it, so returning one of
them from `__iter__` was rejected:

    class Wrapped:
        def __iter__(self):
            return itertools.takewhile(lambda x: x < 3, [1, 2, 3])

    list(Wrapped())
    # CPython: [1, 2]
    # Monty:   TypeError: iter() returned non-iterator of type 'itertools.takewhile'

`pairwise` in the same position already worked, which is what makes it a
classification gap rather than anything to do with these adaptors' behaviour.
Reported by cubic on pydantic#657.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rewitt94 added a commit to rewitt94/monty that referenced this pull request Aug 6, 2026
The two guard assertions stopped at the first item, before the source's
temporary `StopIteration`, so they passed whether or not either adaptor
treated exhaustion as terminal — which is the regression they exist to catch.
Both are now driven through the exception and asserted on the item after it.
Reported by cubic on pydantic#657.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rewitt94
rewitt94 force-pushed the itertools-batch-two branch from 74d9c0d to 594a863 Compare August 6, 2026 15:13
rewitt94 added a commit to rewitt94/monty that referenced this pull request Aug 6, 2026
Two gaps cubic found on pydantic#657, both cases of a test that does not check what
the code claims:

`starmap` has no spent flag, so a transient `StopIteration` from its source
must not finish it — nothing covered that, and an implementation that latched
would have passed. `StutteringPairs` drives it through the exception and
asserts the item after it.

`takewhile`'s latch is documented as stopping the source being touched, not
just the predicate being called, and only the predicate half was asserted.
`Counting` records how often it was asked, so the second drain proves the
source is left alone rather than merely yielding nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rewitt94 added a commit to rewitt94/monty that referenced this pull request Aug 6, 2026
cubic asked for this on pydantic#657: `call_predicate` — new in this PR, and used by
`takewhile`, `dropwhile` and `filterfalse` — duplicates the predicate call
already in `builtin_filter`, so a future fix to callable errors or heap
cleanup could diverge between the two.

`builtin_filter` is pre-existing and otherwise untouched by this branch, so
this diff is purely that retrofit. It is behaviour-neutral: the hand-rolled
`py_bool` -> `drop_with` -> `is_truthy?` and `call_predicate`'s `defer_drop!`
release the result on exactly the same paths, and `evaluate_function` consumes
the `ArgValues` either way. `predicate.rs` moves to the crate root so
`builtins/` need not reach into `types::itertools::`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rewitt94 added a commit to rewitt94/monty that referenced this pull request Aug 6, 2026
cubic asked for this on pydantic#657: releasing the predicate on its first rejection
diverged from CPython, which holds `lz->func` for the iterator's whole life.
Nothing in sandboxed Python can observe the difference today — there is no
`__del__`, no weakrefs, no `getrefcount` — but the divergence would become
visible the moment any finalizer lands, so match CPython now.

`DropWhile` swaps `predicate: Option<Value>` for `predicate: Value` plus a
`dropping` flag; the predicate is cloned only while it is still consulted, and
the rejection branch clears the flag rather than releasing.
`refcount__itertools_adaptors.py` asserted the old lifetime explicitly, so its
`drop_pred` expectation moves from 1 to 2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rewitt94 added a commit to rewitt94/monty that referenced this pull request Aug 6, 2026
cubic asked for this on pydantic#657: `takewhile`, `dropwhile` and `filterfalse` each
opened `next` with the same clone-guard-fetch-guard scaffolding, so the
refcount-critical part — guarding the item across a test that may raise — was
maintained in three copies.

`step::next_tested` now owns the predicate and source clones, the fetch and the
item guard, runs the caller's test inside the guarded region and hands the item
back owned; `step::next_item` covers `dropwhile`'s untested tail. Each adaptor
keeps only its own decision, which is all that actually differed.

The `Value::None` truth test stays at `filterfalse`'s call site deliberately:
folding it into the shared helper would make `takewhile(None, ...)` silently
truth-test rather than raise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rewitt94 rewitt94 closed this Aug 7, 2026
@rewitt94 rewitt94 reopened this Aug 7, 2026
@rewitt94

rewitt94 commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Close and open to re-trigger crashed CI

rewitt94 added a commit to rewitt94/monty that referenced this pull request Aug 12, 2026
`Type::is_iterator` lists every other concrete iterator, including the seven
earlier `itertools` ones, and the four new variants were not added to it. The
protocol check for a user-defined `__iter__` consults it, so returning one of
them from `__iter__` was rejected:

    class Wrapped:
        def __iter__(self):
            return itertools.takewhile(lambda x: x < 3, [1, 2, 3])

    list(Wrapped())
    # CPython: [1, 2]
    # Monty:   TypeError: iter() returned non-iterator of type 'itertools.takewhile'

`pairwise` in the same position already worked, which is what makes it a
classification gap rather than anything to do with these adaptors' behaviour.
Reported by cubic on pydantic#657.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rewitt94 added a commit to rewitt94/monty that referenced this pull request Aug 12, 2026
The two guard assertions stopped at the first item, before the source's
temporary `StopIteration`, so they passed whether or not either adaptor
treated exhaustion as terminal — which is the regression they exist to catch.
Both are now driven through the exception and asserted on the item after it.
Reported by cubic on pydantic#657.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rewitt94 added a commit to rewitt94/monty that referenced this pull request Aug 12, 2026
Two gaps cubic found on pydantic#657, both cases of a test that does not check what
the code claims:

`starmap` has no spent flag, so a transient `StopIteration` from its source
must not finish it — nothing covered that, and an implementation that latched
would have passed. `StutteringPairs` drives it through the exception and
asserts the item after it.

`takewhile`'s latch is documented as stopping the source being touched, not
just the predicate being called, and only the predicate half was asserted.
`Counting` records how often it was asked, so the second drain proves the
source is left alone rather than merely yielding nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rewitt94 added a commit to rewitt94/monty that referenced this pull request Aug 12, 2026
cubic asked for this on pydantic#657: `call_predicate` — new in this PR, and used by
`takewhile`, `dropwhile` and `filterfalse` — duplicates the predicate call
already in `builtin_filter`, so a future fix to callable errors or heap
cleanup could diverge between the two.

`builtin_filter` is pre-existing and otherwise untouched by this branch, so
this diff is purely that retrofit. It is behaviour-neutral: the hand-rolled
`py_bool` -> `drop_with` -> `is_truthy?` and `call_predicate`'s `defer_drop!`
release the result on exactly the same paths, and `evaluate_function` consumes
the `ArgValues` either way. `predicate.rs` moves to the crate root so
`builtins/` need not reach into `types::itertools::`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rewitt94
rewitt94 force-pushed the itertools-batch-two branch from 594a863 to 2bd9f37 Compare August 12, 2026 13:33
rewitt94 added a commit to rewitt94/monty that referenced this pull request Aug 12, 2026
cubic asked for this on pydantic#657: releasing the predicate on its first rejection
diverged from CPython, which holds `lz->func` for the iterator's whole life.
Nothing in sandboxed Python can observe the difference today — there is no
`__del__`, no weakrefs, no `getrefcount` — but the divergence would become
visible the moment any finalizer lands, so match CPython now.

`DropWhile` swaps `predicate: Option<Value>` for `predicate: Value` plus a
`dropping` flag; the predicate is cloned only while it is still consulted, and
the rejection branch clears the flag rather than releasing.
`refcount__itertools_adaptors.py` asserted the old lifetime explicitly, so its
`drop_pred` expectation moves from 1 to 2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rewitt94 added a commit to rewitt94/monty that referenced this pull request Aug 12, 2026
cubic asked for this on pydantic#657: `takewhile`, `dropwhile` and `filterfalse` each
opened `next` with the same clone-guard-fetch-guard scaffolding, so the
refcount-critical part — guarding the item across a test that may raise — was
maintained in three copies.

`step::next_tested` now owns the predicate and source clones, the fetch and the
item guard, runs the caller's test inside the guarded region and hands the item
back owned; `step::next_item` covers `dropwhile`'s untested tail. Each adaptor
keeps only its own decision, which is all that actually differed.

The `Value::None` truth test stays at `filterfalse`'s call site deliberately:
folding it into the shared helper would make `takewhile(None, ...)` silently
truth-test rather than raise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread crates/monty/src/types/itertools/dropwhile.rs
@veria-ai

veria-ai Bot commented Aug 12, 2026

Copy link
Copy Markdown

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Security review

No open security issues remain on this pull request.

Fixed/addressed: 1 · PR risk: 0/10

@samuelcolvin samuelcolvin mentioned this pull request Aug 14, 2026
rewitt94 and others added 10 commits August 17, 2026 12:05
…tarmap`

Four more callables on the source-wrapping foundation from pydantic#635. All four
apply a **user callable per item**, which none of the earlier adaptors do —
`next` re-enters the VM through `evaluate_function`, so the item under test is
held in a `DropGuard` across the call and the callable is a second GC edge
alongside the source.

Latching differs per adaptor and is user-visible. `takewhile` latches on the
first rejection — and only that: CPython's `takewhile_next` sets `stop` when
the predicate fails, while an exhausted source leaves it free to be driven
again, which a hand-written `__next__` that raises `StopIteration` and then
resumes can observe. `dropwhile` clears its dropping state on the first
failure and yields the rest untested. `filterfalse` and `starmap` never latch
at all.

Each adaptor releases what it can no longer reach at that point rather than at
destruction, as `pairwise`/`islice` do: `takewhile` drops both predicate and
source when it latches, `dropwhile` drops the predicate it will never call
again. `filterfalse` and `starmap` consult both on every `next`, so neither
releases early.

`filterfalse` shares the one-argument application in `predicate.rs` with the
two `while` adaptors and adds the truth-test path `filter(None, ...)` uses.
`starmap` packs each item into `ArgValues::Empty`/`One`/`Two` rather than
`ArgsKargs`, because extractors such as `get_one_arg` match the shape
structurally.

A callable that suspends is rejected, not paused: `evaluate_function` runs a
frame to completion and cannot yield to the host, so one reaching an external
function raises `NotImplementedError`. Documented in `limitations/itertools.md`
and covered Rust-side for both call sites — the shared predicate helper and
`starmap`'s own.

`ItertoolsIter` gains a compile-time budget against `Dict`, the widest
`HeapData` payload: the enum is memcpy'd inline on every allocate and free, and
against `Islice`/`Chain`'s 56, so the enum stays at 64.

Compatible dump change: new `StaticStrings`, `Type`, `MontyType` and
`ItertoolsIter` variants are appended, so only the fingerprint moves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Type::is_iterator` lists every other concrete iterator, including the seven
earlier `itertools` ones, and the four new variants were not added to it. The
protocol check for a user-defined `__iter__` consults it, so returning one of
them from `__iter__` was rejected:

    class Wrapped:
        def __iter__(self):
            return itertools.takewhile(lambda x: x < 3, [1, 2, 3])

    list(Wrapped())
    # CPython: [1, 2]
    # Monty:   TypeError: iter() returned non-iterator of type 'itertools.takewhile'

`pairwise` in the same position already worked, which is what makes it a
classification gap rather than anything to do with these adaptors' behaviour.
Reported by cubic on pydantic#657.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two guard assertions stopped at the first item, before the source's
temporary `StopIteration`, so they passed whether or not either adaptor
treated exhaustion as terminal — which is the regression they exist to catch.
Both are now driven through the exception and asserted on the item after it.
Reported by cubic on pydantic#657.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two gaps cubic found on pydantic#657, both cases of a test that does not check what
the code claims:

`starmap` has no spent flag, so a transient `StopIteration` from its source
must not finish it — nothing covered that, and an implementation that latched
would have passed. `StutteringPairs` drives it through the exception and
asserts the item after it.

`takewhile`'s latch is documented as stopping the source being touched, not
just the predicate being called, and only the predicate half was asserted.
`Counting` records how often it was asked, so the second drain proves the
source is left alone rather than merely yielding nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The assertion broke the wasm build: `Dict` is the widest `HeapData` payload on
64-bit, but on `wasm32-wasip1` it halves to 36 bytes while the adaptors' `i64`
fields do not, leaving `ItertoolsIter` at 48 — over a budget that no longer
means anything there. `HeapData` is 64 bytes on that target and set by other
variants, so the family is not what it has to stay under.

Gate the assertion (and the `Dict` import it needs) on 64-bit, where pydantic#636's
layout work applies and where the hosts that run the heap hot path live.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cubic asked for this on pydantic#657: `call_predicate` — new in this PR, and used by
`takewhile`, `dropwhile` and `filterfalse` — duplicates the predicate call
already in `builtin_filter`, so a future fix to callable errors or heap
cleanup could diverge between the two.

`builtin_filter` is pre-existing and otherwise untouched by this branch, so
this diff is purely that retrofit. It is behaviour-neutral: the hand-rolled
`py_bool` -> `drop_with` -> `is_truthy?` and `call_predicate`'s `defer_drop!`
release the result on exactly the same paths, and `evaluate_function` consumes
the `ArgValues` either way. `predicate.rs` moves to the crate root so
`builtins/` need not reach into `types::itertools::`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cubic asked for this on pydantic#657: releasing the predicate on its first rejection
diverged from CPython, which holds `lz->func` for the iterator's whole life.
Nothing in sandboxed Python can observe the difference today — there is no
`__del__`, no weakrefs, no `getrefcount` — but the divergence would become
visible the moment any finalizer lands, so match CPython now.

`DropWhile` swaps `predicate: Option<Value>` for `predicate: Value` plus a
`dropping` flag; the predicate is cloned only while it is still consulted, and
the rejection branch clears the flag rather than releasing.
`refcount__itertools_adaptors.py` asserted the old lifetime explicitly, so its
`drop_pred` expectation moves from 1 to 2.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cubic asked for this on pydantic#657: `takewhile`, `dropwhile` and `filterfalse` each
opened `next` with the same clone-guard-fetch-guard scaffolding, so the
refcount-critical part — guarding the item across a test that may raise — was
maintained in three copies.

`step::next_tested` now owns the predicate and source clones, the fetch and the
item guard, runs the caller's test inside the guarded region and hands the item
back owned; `step::next_item` covers `dropwhile`'s untested tail. Each adaptor
keeps only its own decision, which is all that actually differed.

The `Value::None` truth test stays at `filterfalse`'s call site deliberately:
folding it into the shared helper would make `takewhile(None, ...)` silently
truth-test rather than raise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rebase fallout from pydantic#668, which moved memory accounting to the global
allocator: `Heap::allocate` is now infallible, so the four constructors drop
their `?`, and `ItertoolsIter::buffered_size` goes with the rest of the
`py_estimate_size` machinery — `chain` and `cycle` no longer charge their
buffers here because the allocator meters them directly.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`next(itertools.dropwhile(bool, itertools.count(1)))` ran forever with
`max_duration` set. The VM's dispatch checkpoint is per-`run()`, so a
native loop driving a native source reaches none — and a user-defined
predicate is no safer, since a body under `CHECK_INTERVAL` instructions
restarts the countdown rather than reaching it. `VM::run` already states
the contract: a native loop calling a shorter callback "must poll the
tracker itself".

Adds an amortized `check_time_every` to every adaptor loop that can
discard without yielding: `dropwhile` and `filterfalse` here, and
`compress`, `islice` and `chain`, which have the same hole today.
`starmap` spreads its item through the shared iterator drain, which
checked size per item but never time, so that gains a poll too — fixing
every other eager drain with it.

`takewhile` needs nothing: it tests one item per call and returns, so
the dispatch checkpoint still bounds it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@rewitt94
rewitt94 force-pushed the itertools-batch-two branch from 0734a85 to e865ed0 Compare August 17, 2026 11:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant