Lazily split Iterable on condition

Viewed 241

I'm having problems trying to create a function that lazily separates an iterable into sub-iterables given a condition.

Here's what I have so far

def split_on_condition(
        seq: t.Iterable[T], condition: t.Callable[[T], bool]
) -> t.Iterable[t.Iterable[T]]:
    curr = []
    for i in seq:
        if condition(i):
            curr.append(i)
        else:
            yield curr
            curr = []
    yield curr


foo = ('a', 'b', 'c', '', '...rest')
print(list(split_on_condition(foo, bool)))

# [['a', 'b', 'c'], ['...rest']]

So this works, which is nice; but the problem is that curr is a list, rather I want it to be lazy evaluation so that I could use an arbitrarily large sequence as input.

from itertools import repeat

for _ in split_on_condition(repeat('oops, infinite loop'), bool):
    pass
    # Program will crash here :(

3 Answers

For subscriptable iterable that implements __getitem__, save range then return a generator that iterates given range.

def split_on_condition_gen(seq, condition):
    def inner_gen(range_: range):
        for n in range_:
            yield seq[n]

    last = 0
    current = 0

    for item in seq:
        if condition(item):
            current += 1
        else:
            yield inner_gen(range(last, current))
            last = current

As it checks until condition breaks, it's not completely lazy, but at least it can handle large input as it won't consume extra memory until you really need those.

>>> from this import s as seq_
~~ ZEN OF PYTHON GOES HERE ~~

>>> condition = lambda x: x != ','
>>> output = split_on_condition_gen(seq_, condition)
>>> output
<generator object split_on_condition_gen at 0x000002602C1ECEB0>

>>> list_output = list(output)
>>> list_output[2]
<generator object split_on_condition_gen.<locals>.inner_gen at 0x000002602C1ECEB0>

>>> list(list_output[2])
['l', ',', ' ', 'e', 'r', 's', 'h', 'f', 'r', ' ', ...]

However, for iterables without __getitem__ implemented - for ones like this:

class NonIndexAble:
    def __init__(self, initial: str):
        self.source = initial
        self.list_ = list(initial)

    def __iter__(self):
        return self

    def __next__(self):
        try:
            return self.list_.pop(0)
        except IndexError:
            raise StopIteration()

Will raise errors like this:

>>> non_index_able = NonIndexAble('aaabaaaaba')
>>> condition = lambda x: x == "b"

>>> for part in split_on_condition_gen(non_index_able, condition):
...     for item in part:
...         pass

Traceback (most recent call last):
  File "<input>", line 2, in <module>
  File "<input>", line 11, in inner_gen
TypeError: 'NonIndexAble' object is not subscriptable

First I thought we could use itertools.islice, but found out that it consumes iterator passed, shown like this:

except StopIteration:
    # Consume to *stop*.
    for i, element in zip(range(i + 1, stop), iterable):
        pass

Rather, as most use-case of this generator would be nested for loop, it will run inner-generators in sequence they've created.

For that case, we could just have single generator instance and have counter variable for 'cursor' location. Then we can get values by skipping from counter to start and yield from start to end index and leave generator as-is, not consuming completely like islice does so next 'block' could continue with same generator instance.

counter = 0

def inner_gen(seq_, start, end):
    nonlocal counter

    for _ in range(counter, start):  # Skip from counter to start
        next(seq_)

    for _ in range(start, end):  # yield from start to end
        yield next(seq_)

        counter = end

Full implementation:

def split_on_condition_gen(seq, condition):
    seq_copy, seq_current = tee(seq)
    counter = 0

    def inner_gen(seq_, start, end):
        nonlocal counter

        for _ in range(counter, start):  # Skip from counter to start
            next(seq_)

        for _ in range(start, end):  # yield from start to end
            yield next(seq_)

            counter = end

    last = 0

    for idx, item in enumerate(seq_current):
        if condition(item):
            continue

        yield inner_gen(seq_copy, last, idx)
        last = idx + 1

    yield inner_gen(seq_copy, last, idx + 1)

Test:

>>> non_index_able = NonIndexAble('asdf adsfa ds sdfdf adf')
>>> condition = lambda x: x != ' '

>>> for part in split_on_condition_gen(non_index_able, condition):
...     print("Part start")
...     for item in part:
...         print(item, end=' ')
...     print("\nEnd")
Part start
a s d f
End
Part start
a d s f a
End
Part start
d s
End
Part start
s d f d f
End
Part start
a d f
End
import typing as t
from itertools import islice, tee

T = t.TypeVar("T")

def split_on_condition_gen(
    seq: t.Iterable[T],
    condition: t.Callable[[T], bool],
    keep_condition: bool = False,
) -> t.Iterable[t.Iterable[T]]:
    itr1, itr2 = tee(seq)
    current = 0
    for item in itr2:
        if not condition(item):
            yield islice(itr1, 0, current)
            if not keep_condition:
                # Toss out the item that gave failed the condition
                # We default to None to avoid a StopIterationException
                next(itr1, None)
            current = 0
        current += 1
    yield islice(itr1, 0, current)

The tee function, which returns a tuple of n independent iterators is important, as some iterables are generators, meaning that once you call their __next__ they're exhausted, and wouldn't work with islice.

This version doesn't assume the input is indexable, althought it will still consume the seq if it's a generator.

>>> from this import s as seq_
>>> condition = lambda x: x != ","
>>> indexable = ["".join(i) for i in split_on_condition_gen(seq_, condition)]
>>> indexable
['Gur Mra bs Clguba', " ol Gvz Crgref\n\nOrnhgvshy vf orggre guna htyl.\nRkcyvpvg vf orggre guna vzcyvpvg.\nFvzcyr vf orggre guna pbzcyrk.\nPbzcyrk vf orggre guna pbzcyvpngrq.\nSyng vf orggre guna arfgrq.\nFcnefr vf orggre guna qrafr.\nErnqnovyvgl pbhagf.\nFcrpvny pnfrf nera'g fcrpvny rabhtu gb oernx gur ehyrf.\nNygubhtu cenpgvpnyvgl orngf chevgl.\nReebef fubhyq arire cnff fvyragyl.\nHayrff rkcyvpvgyl fvyraprq.\nVa gur snpr bs nzovthvgl,", "ershfr gur grzcgngvba gb thrff.\nGurer fubhyq or bar-- naq cersrenoyl bayl bar --boivbhf jnl gb qb vg.\nNygubhtu gung jnl znl abg or boivbhf ng svefg hayrff lbh'er Qhgpu.\nAbj vf orggre guna arire.\nNygubhtu arire vf bsgra orggre guna *evtug* abj.\nVs gur vzcyrzragngvba vf uneq gb rkcynva, ", "g'f n onq vqrn.\nVs gur vzcyrzragngvba vf rnfl gb rkcynva, v", " znl or n tbbq vqrn.\nAnzrfcnprf ner bar ubaxvat terng vqrn -- yrg'f qb zber bs gubfr!"]
>>> generator = [
...     "".join(i) for i in split_on_condition_gen((x for x in seq_), condition)
... ]
>>> assert indexable == generator

If you are allowed to use external modules you might harness more_itertools.split_at. Consider following example, which takes subsequent integers and split on multiplies of 10:

import itertools
import more_itertools
blocks = more_itertools.split_at(itertools.count(1),lambda x:x%10==0)
block1 = next(blocks)
block2 = next(blocks)
block3 = next(blocks)
print(list(block1))
print(list(block2))
print(list(block3))

Output:

[1, 2, 3, 4, 5, 6, 7, 8, 9]
[11, 12, 13, 14, 15, 16, 17, 18, 19]
[21, 22, 23, 24, 25, 26, 27, 28, 29]

more-itertools is available through PyPI

Related