koskari.itertools

Module: koskari.itertools

Imports: import-from koskari.itertools accumulate batched chain chain-from-iterable compress cycle dropwhile filterfalse groupby islice pairwise starmap takewhile zip-longest

Use-case: The iterator combinators, in a form that accepts koskari functions and koskari sources. Python’s itertools does not: it calls a predicate through the python calling convention, where a koskari function returns a coroutine rather than an answer, and it pulls its source through the synchronous iterator protocol, which an async iterator does not answer to. Every combinator here takes a koskari callable directly and accepts a sync iterable, an async iterable, or a koskari generator as its source.

map, filter and zip are not in this module. They are bound bare in the builtins namespace and already behave this way; python’s remain available as py-map, py-filter and py-zip.

Why not python’s itertools

Two things go wrong when a koskari function or a koskari source meets itertools, and only one of them is loud.

A koskari callable handed to python’s higher-order functions produces a coroutine per call rather than a value. Nothing raises – coroutines are truthy, so a predicate reads as always true:

(import-from itertools takewhile)

(list (takewhile (lambda [x] (< x 3)) #[1 2 3 4 1]))
; #[1 2 3 4 1] -- every element "passed"
(import-from koskari.itertools takewhile)

(list (takewhile (lambda [x] (< x 3)) #[1 2 3 4 1]))
; #[1 2]

The first form is silently wrong: no error, plausible-looking output. Wrapping the callable in demand restores the verdict, but forces every call to run to completion, so the predicate can no longer suspend.

The source is the other half. A koskari generator that never suspends does reach python’s itertools, through the demand adapter, which forces each step to completion. A source that suspends does not: an async iterator raises TypeError at construction, and a generator that awaits cannot be stepped to completion at all.

These combinators drive their source through the evaluator, so a suspending source works wherever the surrounding code can await it:

import-from asyncio sleep
import-from koskari.itertools islice

def slow-numbers []:
  for-each [i #[1 2 3 4]]:
    (sleep 0)
    yield i

def first-two []:
  var out #[]
  for-each [x (islice (slow-numbers) 2)]:
    out.append x
  out

Note the consumer. for-each drives through the evaluator and awaits in place; list is python’s, so it drives synchronously and cannot take a source that suspends – that is a property of the consumer, not of the combinator.

accumulate

Syntax: (accumulate ITERABLE), (accumulate ITERABLE FUNC), (accumulate ITERABLE initial: SEED)

Yield running totals. The first element is emitted as it stands and becomes the first total; each later element is folded into it. With no FUNC the fold is addition, so anything + accepts works. A SEED is emitted before the source is read, so a seeded accumulate over an empty source still yields once.

(list (accumulate #[1 2 3 4]))
; #[1 3 6 10]

(list (accumulate #[1 2 3] initial: 10))
; #[10 11 13 16]

batched

Syntax: (batched ITERABLE N)

Yield tuples of N consecutive elements. The final batch is short when the source does not divide evenly. N must be at least one.

(list (batched #[0 1 2 3 4 5 6] 3))
; #[#(0 1 2) #(3 4 5) #(6)]

chain

Syntax: (chain ITERABLE ...), (chain-from-iterable ITERABLES)

Yield every element of each source in turn. A source is acquired only when the one before it runs out, so a source never reached is never touched – which matters when acquiring one has effects, such as calling a generator function or opening a file in __iter__.

chain-from-iterable takes the series from a single iterable rather than from the argument list.

(list (chain #[1 2] "ab"))
; #[1 2 "a" "b"]

compress

Syntax: (compress DATA SELECTORS)

Yield the elements of DATA whose matching selector is truthy. Ends when either source runs out.

(list (compress "ABCDEF" #[1 0 1 0 1 1]))
; #["A" "C" "E" "F"]

cycle

Syntax: (cycle ITERABLE)

Repeat the source endlessly. The first pass keeps a copy of each element as it yields it, and every later lap reads that copy, so the source is walked exactly once. An empty source yields nothing rather than looping forever.

(list (islice (cycle "ABC") 8))
; #["A" "B" "C" "A" "B" "C" "A" "B"]

dropwhile

Syntax: (dropwhile PRED ITERABLE)

Drop elements while PRED holds, then yield the rest. The predicate is retired after its first rejection and never consulted again, so later elements that would satisfy it are still yielded.

(list (dropwhile (lambda [x] (< x 3)) #[1 2 3 4 1 2]))
; #[3 4 1 2]

filterfalse

Syntax: (filterfalse PRED ITERABLE)

Yield the elements filter would drop. A None predicate keeps the falsey elements. The result is a filter carrying an inverted verdict, so filter? answers true for it.

(list (filterfalse (lambda [x] (% x 2)) #[1 2 3 4]))
; #[2 4]

groupby

Syntax: (groupby ITERABLE), (groupby ITERABLE KEY)

Yield #(key group) for each run of consecutive elements sharing a key, where KEY defaults to the element itself. Non-adjacent runs of the same key are separate groups.

The group is a list, not a view onto the source. Python’s groupby hands back a sub-iterator sharing one cursor with its parent, valid only until the parent advances; here each run is materialized, so a group stays usable for as long as you hold it.

(list (groupby "AAABBBCCD"))
; #[#("A" #["A" "A" "A"]) #("B" #["B" "B" "B"])
;   #("C" #["C" "C"]) #("D" #["D"])]

islice

Syntax: (islice ITERABLE STOP), (islice ITERABLE START STOP), (islice ITERABLE START STOP STEP)

Yield a slice of the source by position. Bounds count forward only: START and STOP must be non-negative, STEP at least one, and STOP may be None for an open end. Skipped elements are consumed, and a spent slice leaves the rest of the source unread.

(list (islice #[0 1 2 3 4 5 6 7 8 9] 2 7 2))
; #[2 4 6]

pairwise

Syntax: (pairwise ITERABLE)

Yield overlapping pairs of consecutive elements. A source of fewer than two elements yields nothing.

(list (pairwise "ABCD"))
; #[#("A" "B") #("B" "C") #("C" "D")]

starmap

Syntax: (starmap FUNC ITERABLE)

Call FUNC with each element unpacked as its arguments, rather than as a single argument. Each element must be iterable.

(list (starmap (lambda [a b] (+ a b)) #[#(1 2) #(3 4)]))
; #[3 7]

takewhile

Syntax: (takewhile PRED ITERABLE)

Yield elements while PRED holds and stop at the first that fails. That element is consumed and dropped; the source is left open, so whatever holds it can carry on from the element after.

(list (takewhile (lambda [x] (< x 3)) #[1 2 3 4 1]))
; #[1 2]

zip-longest

Syntax: (zip-longest ITERABLE ...), (zip-longest ITERABLE ... fillvalue: FILL)

Pair elements positionally, continuing until every source is spent rather than stopping at the first. A spent source contributes FILL, which defaults to None.

(list (zip-longest #[1 2 3] "ab"))
; #[#(1 "a") #(2 "b") #(3 None)]

(list (zip-longest #[1 2 3] "ab" fillvalue: 0))
; #[#(1 "a") #(2 "b") #(3 0)]

See also

  • map and filter for the two that are bare in the builtins namespace, and their py- counterparts

  • koskari.solve for another module built on the evaluator’s own machinery