streamish 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
streamish/__init__.py ADDED
@@ -0,0 +1,63 @@
1
+ """Streamish - Iterator and async iterator utilities."""
2
+
3
+ from collections.abc import AsyncIterable, Iterable
4
+
5
+ from streamish.ops import (
6
+ batch,
7
+ chain,
8
+ chain_async,
9
+ distinct,
10
+ distinct_by,
11
+ enumerate,
12
+ filter,
13
+ flat_map,
14
+ flatten,
15
+ interleave,
16
+ map,
17
+ map_async,
18
+ merge,
19
+ partition,
20
+ partition_async,
21
+ scan,
22
+ skip,
23
+ skip_while,
24
+ take,
25
+ take_while,
26
+ window,
27
+ zip,
28
+ zip_async,
29
+ )
30
+ from streamish.stream import Stream
31
+
32
+ __all__ = [
33
+ "stream",
34
+ "Stream",
35
+ "map",
36
+ "map_async",
37
+ "filter",
38
+ "take",
39
+ "skip",
40
+ "take_while",
41
+ "skip_while",
42
+ "distinct",
43
+ "distinct_by",
44
+ "flatten",
45
+ "flat_map",
46
+ "enumerate",
47
+ "scan",
48
+ "batch",
49
+ "window",
50
+ "partition",
51
+ "partition_async",
52
+ "zip",
53
+ "zip_async",
54
+ "chain",
55
+ "chain_async",
56
+ "interleave",
57
+ "merge",
58
+ ]
59
+
60
+
61
+ def stream[T](source: Iterable[T] | AsyncIterable[T]) -> Stream[T]:
62
+ """Create a Stream from an iterable or async iterable."""
63
+ return Stream(source)
streamish/_util.py ADDED
@@ -0,0 +1,38 @@
1
+ """Internal utility helpers."""
2
+
3
+ import inspect
4
+ from collections.abc import (
5
+ AsyncGenerator,
6
+ AsyncIterable,
7
+ AsyncIterator,
8
+ Callable,
9
+ Iterable,
10
+ Iterator,
11
+ )
12
+ from typing import Any, TypeGuard
13
+
14
+ __all__ = ["is_async_iterable", "is_awaitable", "ensure_async_iterator"]
15
+
16
+
17
+ def is_async_iterable[T](
18
+ obj: Iterable[T] | AsyncIterable[T],
19
+ ) -> TypeGuard[AsyncIterable[T]]:
20
+ """Check if object is an async iterable."""
21
+ return hasattr(obj, "__aiter__")
22
+
23
+
24
+ def is_awaitable(fn: Callable[..., Any]) -> bool:
25
+ """Check if function is async (returns awaitable)."""
26
+ return inspect.iscoroutinefunction(fn)
27
+
28
+
29
+ async def ensure_async_iterator[T](
30
+ it: Iterator[T] | AsyncIterator[T],
31
+ ) -> AsyncGenerator[T]:
32
+ """Convert sync iterator to async iterator if needed."""
33
+ if isinstance(it, AsyncIterator):
34
+ async for item in it:
35
+ yield item
36
+ else:
37
+ for item in it:
38
+ yield item
@@ -0,0 +1,43 @@
1
+ """Stream operations."""
2
+
3
+ from streamish.ops.combine import chain, chain_async, interleave, merge, zip_async
4
+ from streamish.ops.combine import zip_ as zip
5
+ from streamish.ops.filter import (
6
+ distinct,
7
+ distinct_by,
8
+ skip,
9
+ skip_while,
10
+ take,
11
+ take_while,
12
+ )
13
+ from streamish.ops.group import batch, partition, partition_async, window
14
+ from streamish.ops.transform import enumerate_ as enumerate
15
+ from streamish.ops.transform import filter_ as filter
16
+ from streamish.ops.transform import flat_map, flatten, map_async, scan
17
+ from streamish.ops.transform import map_ as map
18
+
19
+ __all__ = [
20
+ "map",
21
+ "filter",
22
+ "take",
23
+ "skip",
24
+ "take_while",
25
+ "skip_while",
26
+ "distinct",
27
+ "distinct_by",
28
+ "enumerate",
29
+ "scan",
30
+ "flatten",
31
+ "flat_map",
32
+ "batch",
33
+ "window",
34
+ "partition",
35
+ "partition_async",
36
+ "zip",
37
+ "zip_async",
38
+ "chain",
39
+ "chain_async",
40
+ "interleave",
41
+ "merge",
42
+ "map_async",
43
+ ]
@@ -0,0 +1,101 @@
1
+ """Combine operations."""
2
+
3
+ import asyncio
4
+ from collections.abc import AsyncIterable, AsyncIterator, Iterable, Iterator
5
+
6
+ from streamish._util import ensure_async_iterator, is_async_iterable
7
+
8
+ __all__ = ["zip_", "zip_async", "chain", "chain_async", "interleave", "merge"]
9
+
10
+
11
+ def zip_[T](*iterables: Iterable[T]) -> Iterator[tuple[T, ...]]:
12
+ """Zip iterables together."""
13
+ iters = [iter(it) for it in iterables]
14
+ while True:
15
+ result: list[T] = []
16
+ for it in iters:
17
+ try:
18
+ result.append(next(it))
19
+ except StopIteration:
20
+ return
21
+ yield tuple(result)
22
+
23
+
24
+ async def zip_async[T](
25
+ *iterables: AsyncIterable[T] | Iterable[T],
26
+ ) -> AsyncIterator[tuple[T, ...]]:
27
+ """Zip async iterables together."""
28
+ aiters: list[AsyncIterator[T]] = [
29
+ it.__aiter__() if is_async_iterable(it) else ensure_async_iterator(iter(it)) # type: ignore[union-attr, arg-type]
30
+ for it in iterables
31
+ ]
32
+ while True:
33
+ try:
34
+ results: list[T] = []
35
+ for ait in aiters:
36
+ results.append(await ait.__anext__())
37
+ yield tuple(results)
38
+ except StopAsyncIteration:
39
+ break
40
+
41
+
42
+ def chain[T](*iterables: Iterable[T]) -> Iterator[T]:
43
+ """Chain iterables together."""
44
+ for it in iterables:
45
+ yield from it
46
+
47
+
48
+ async def chain_async[T](
49
+ *iterables: AsyncIterable[T] | Iterable[T],
50
+ ) -> AsyncIterator[T]:
51
+ """Chain async iterables together."""
52
+ for it in iterables:
53
+ if is_async_iterable(it):
54
+ async for item in it: # type: ignore[union-attr]
55
+ yield item
56
+ else:
57
+ for item in it: # type: ignore[union-attr]
58
+ yield item
59
+
60
+
61
+ def interleave[T](*iterables: Iterable[T]) -> Iterator[T]:
62
+ """Alternate elements from iterables (round-robin)."""
63
+ iters: list[Iterator[T]] = [iter(it) for it in iterables]
64
+ while iters:
65
+ next_iters: list[Iterator[T]] = []
66
+ for it in iters:
67
+ try:
68
+ yield next(it)
69
+ next_iters.append(it)
70
+ except StopIteration:
71
+ pass
72
+ iters = next_iters
73
+
74
+
75
+ async def merge[T](*iterables: AsyncIterable[T]) -> AsyncIterator[T]:
76
+ """Merge async iterables - emit as items arrive."""
77
+ pending: set[asyncio.Task[tuple[int, T | None, bool]]] = set()
78
+ aiters: dict[int, AsyncIterator[T]] = {}
79
+
80
+ for i, it in enumerate(iterables):
81
+ aiters[i] = it.__aiter__()
82
+ task = asyncio.create_task(_fetch_next(i, aiters[i]))
83
+ pending.add(task)
84
+
85
+ while pending:
86
+ done, pending = await asyncio.wait(pending, return_when=asyncio.FIRST_COMPLETED)
87
+ for task in done:
88
+ idx, value, exhausted = task.result()
89
+ if exhausted:
90
+ continue
91
+ yield value # type: ignore[misc]
92
+ new_task = asyncio.create_task(_fetch_next(idx, aiters[idx]))
93
+ pending.add(new_task)
94
+
95
+
96
+ async def _fetch_next[T](idx: int, ait: AsyncIterator[T]) -> tuple[int, T | None, bool]:
97
+ try:
98
+ value = await ait.__anext__()
99
+ return (idx, value, False)
100
+ except StopAsyncIteration:
101
+ return (idx, None, True)
@@ -0,0 +1,235 @@
1
+ """Filter operations."""
2
+
3
+ from collections.abc import (
4
+ AsyncIterable,
5
+ AsyncIterator,
6
+ Callable,
7
+ Hashable,
8
+ Iterable,
9
+ Iterator,
10
+ )
11
+ from typing import overload
12
+
13
+ from streamish._util import is_async_iterable
14
+
15
+ __all__ = ["take", "skip", "take_while", "skip_while", "distinct", "distinct_by"]
16
+
17
+
18
+ @overload
19
+ def take[T](n: int, it: Iterable[T]) -> Iterator[T]: ...
20
+
21
+
22
+ @overload
23
+ def take[T](n: int, it: AsyncIterable[T]) -> AsyncIterator[T]: ...
24
+
25
+
26
+ def take[T](
27
+ n: int, it: Iterable[T] | AsyncIterable[T]
28
+ ) -> Iterator[T] | AsyncIterator[T]:
29
+ """Take first n elements."""
30
+ if is_async_iterable(it):
31
+ return _take_async(n, it) # type: ignore[arg-type]
32
+ return _take_sync(n, it) # type: ignore[arg-type, return-value]
33
+
34
+
35
+ def _take_sync[T](n: int, it: Iterable[T]) -> Iterator[T]:
36
+ count = 0
37
+ for item in it:
38
+ if count >= n:
39
+ break
40
+ yield item
41
+ count += 1
42
+
43
+
44
+ async def _take_async[T](n: int, it: AsyncIterable[T]) -> AsyncIterator[T]:
45
+ count = 0
46
+ async for item in it:
47
+ if count >= n:
48
+ break
49
+ yield item
50
+ count += 1
51
+
52
+
53
+ @overload
54
+ def skip[T](n: int, it: Iterable[T]) -> Iterator[T]: ...
55
+
56
+
57
+ @overload
58
+ def skip[T](n: int, it: AsyncIterable[T]) -> AsyncIterator[T]: ...
59
+
60
+
61
+ def skip[T](
62
+ n: int, it: Iterable[T] | AsyncIterable[T]
63
+ ) -> Iterator[T] | AsyncIterator[T]:
64
+ """Skip first n elements."""
65
+ if is_async_iterable(it):
66
+ return _skip_async(n, it) # type: ignore[arg-type]
67
+ return _skip_sync(n, it) # type: ignore[arg-type, return-value]
68
+
69
+
70
+ def _skip_sync[T](n: int, it: Iterable[T]) -> Iterator[T]:
71
+ count = 0
72
+ for item in it:
73
+ if count < n:
74
+ count += 1
75
+ continue
76
+ yield item
77
+
78
+
79
+ async def _skip_async[T](n: int, it: AsyncIterable[T]) -> AsyncIterator[T]:
80
+ count = 0
81
+ async for item in it:
82
+ if count < n:
83
+ count += 1
84
+ continue
85
+ yield item
86
+
87
+
88
+ @overload
89
+ def take_while[T](pred: Callable[[T], bool], it: Iterable[T]) -> Iterator[T]: ...
90
+
91
+
92
+ @overload
93
+ def take_while[T](
94
+ pred: Callable[[T], bool], it: AsyncIterable[T]
95
+ ) -> AsyncIterator[T]: ...
96
+
97
+
98
+ def take_while[T](
99
+ pred: Callable[[T], bool], it: Iterable[T] | AsyncIterable[T]
100
+ ) -> Iterator[T] | AsyncIterator[T]:
101
+ """Take elements while predicate is true."""
102
+ if is_async_iterable(it):
103
+ return _take_while_async(pred, it) # type: ignore[arg-type]
104
+ return _take_while_sync(pred, it) # type: ignore[arg-type, return-value]
105
+
106
+
107
+ def _take_while_sync[T](pred: Callable[[T], bool], it: Iterable[T]) -> Iterator[T]:
108
+ for item in it:
109
+ if not pred(item):
110
+ break
111
+ yield item
112
+
113
+
114
+ async def _take_while_async[T](
115
+ pred: Callable[[T], bool], it: AsyncIterable[T]
116
+ ) -> AsyncIterator[T]:
117
+ async for item in it:
118
+ if not pred(item):
119
+ break
120
+ yield item
121
+
122
+
123
+ @overload
124
+ def skip_while[T](pred: Callable[[T], bool], it: Iterable[T]) -> Iterator[T]: ...
125
+
126
+
127
+ @overload
128
+ def skip_while[T](
129
+ pred: Callable[[T], bool], it: AsyncIterable[T]
130
+ ) -> AsyncIterator[T]: ...
131
+
132
+
133
+ def skip_while[T](
134
+ pred: Callable[[T], bool], it: Iterable[T] | AsyncIterable[T]
135
+ ) -> Iterator[T] | AsyncIterator[T]:
136
+ """Skip elements while predicate is true."""
137
+ if is_async_iterable(it):
138
+ return _skip_while_async(pred, it) # type: ignore[arg-type]
139
+ return _skip_while_sync(pred, it) # type: ignore[arg-type, return-value]
140
+
141
+
142
+ def _skip_while_sync[T](pred: Callable[[T], bool], it: Iterable[T]) -> Iterator[T]:
143
+ skipping = True
144
+ for item in it:
145
+ if skipping and pred(item):
146
+ continue
147
+ skipping = False
148
+ yield item
149
+
150
+
151
+ async def _skip_while_async[T](
152
+ pred: Callable[[T], bool], it: AsyncIterable[T]
153
+ ) -> AsyncIterator[T]:
154
+ skipping = True
155
+ async for item in it:
156
+ if skipping and pred(item):
157
+ continue
158
+ skipping = False
159
+ yield item
160
+
161
+
162
+ @overload
163
+ def distinct[T: Hashable](it: Iterable[T]) -> Iterator[T]: ...
164
+
165
+
166
+ @overload
167
+ def distinct[T: Hashable](it: AsyncIterable[T]) -> AsyncIterator[T]: ...
168
+
169
+
170
+ def distinct[T: Hashable](
171
+ it: Iterable[T] | AsyncIterable[T],
172
+ ) -> Iterator[T] | AsyncIterator[T]:
173
+ """Remove duplicates."""
174
+ if is_async_iterable(it):
175
+ return _distinct_async(it) # type: ignore[arg-type]
176
+ return _distinct_sync(it) # type: ignore[arg-type, return-value]
177
+
178
+
179
+ def _distinct_sync[T: Hashable](it: Iterable[T]) -> Iterator[T]:
180
+ seen: set[T] = set()
181
+ for item in it:
182
+ if item not in seen:
183
+ seen.add(item)
184
+ yield item
185
+
186
+
187
+ async def _distinct_async[T: Hashable](it: AsyncIterable[T]) -> AsyncIterator[T]:
188
+ seen: set[T] = set()
189
+ async for item in it:
190
+ if item not in seen:
191
+ seen.add(item)
192
+ yield item
193
+
194
+
195
+ @overload
196
+ def distinct_by[T, K: Hashable](
197
+ key_fn: Callable[[T], K], it: Iterable[T]
198
+ ) -> Iterator[T]: ...
199
+
200
+
201
+ @overload
202
+ def distinct_by[T, K: Hashable](
203
+ key_fn: Callable[[T], K], it: AsyncIterable[T]
204
+ ) -> AsyncIterator[T]: ...
205
+
206
+
207
+ def distinct_by[T, K: Hashable](
208
+ key_fn: Callable[[T], K], it: Iterable[T] | AsyncIterable[T]
209
+ ) -> Iterator[T] | AsyncIterator[T]:
210
+ """Remove duplicates by key function."""
211
+ if is_async_iterable(it):
212
+ return _distinct_by_async(key_fn, it) # type: ignore[arg-type]
213
+ return _distinct_by_sync(key_fn, it) # type: ignore[arg-type, return-value]
214
+
215
+
216
+ def _distinct_by_sync[T, K: Hashable](
217
+ key_fn: Callable[[T], K], it: Iterable[T]
218
+ ) -> Iterator[T]:
219
+ seen: set[K] = set()
220
+ for item in it:
221
+ key = key_fn(item)
222
+ if key not in seen:
223
+ seen.add(key)
224
+ yield item
225
+
226
+
227
+ async def _distinct_by_async[T, K: Hashable](
228
+ key_fn: Callable[[T], K], it: AsyncIterable[T]
229
+ ) -> AsyncIterator[T]:
230
+ seen: set[K] = set()
231
+ async for item in it:
232
+ key = key_fn(item)
233
+ if key not in seen:
234
+ seen.add(key)
235
+ yield item
streamish/ops/group.py ADDED
@@ -0,0 +1,183 @@
1
+ """Group operations."""
2
+
3
+ import asyncio
4
+ from collections import deque
5
+ from collections.abc import AsyncIterable, AsyncIterator, Callable, Iterable, Iterator
6
+ from typing import overload
7
+
8
+ from streamish._util import ensure_async_iterator, is_async_iterable
9
+
10
+ __all__ = ["batch", "window", "partition", "partition_async"]
11
+
12
+
13
+ @overload
14
+ def batch[T](
15
+ size: int, it: Iterable[T], *, timeout: float | None = None
16
+ ) -> Iterator[list[T]]: ...
17
+
18
+
19
+ @overload
20
+ def batch[T](
21
+ size: int, it: AsyncIterable[T], *, timeout: float | None = None
22
+ ) -> AsyncIterator[list[T]]: ...
23
+
24
+
25
+ def batch[T](
26
+ size: int,
27
+ it: Iterable[T] | AsyncIterable[T],
28
+ *,
29
+ timeout: float | None = None,
30
+ ) -> Iterator[list[T]] | AsyncIterator[list[T]]:
31
+ """Group elements into batches by size or timeout."""
32
+ if size <= 0:
33
+ raise ValueError("size must be positive")
34
+ if is_async_iterable(it) or timeout is not None:
35
+ return _batch_async(size, it, timeout) # type: ignore[arg-type]
36
+ return _batch_sync(size, it) # type: ignore[arg-type, return-value]
37
+
38
+
39
+ def _batch_sync[T](size: int, it: Iterable[T]) -> Iterator[list[T]]:
40
+ current: list[T] = []
41
+ for item in it:
42
+ current.append(item)
43
+ if len(current) >= size:
44
+ yield current
45
+ current = []
46
+ if current:
47
+ yield current
48
+
49
+
50
+ async def _batch_async[T](
51
+ size: int,
52
+ it: Iterable[T] | AsyncIterable[T],
53
+ timeout: float | None,
54
+ ) -> AsyncIterator[list[T]]:
55
+ current: list[T] = []
56
+
57
+ ait: AsyncIterator[T]
58
+ if is_async_iterable(it):
59
+ ait = it.__aiter__() # type: ignore[union-attr]
60
+ else:
61
+ sync_it: Iterable[T] = it # type: ignore[assignment]
62
+ ait = ensure_async_iterator(iter(sync_it))
63
+
64
+ if timeout is None:
65
+ async for item in ait:
66
+ current.append(item)
67
+ if len(current) >= size:
68
+ yield current
69
+ current = []
70
+ if current:
71
+ yield current
72
+ else:
73
+ # Use a task-based approach to avoid cancelling the iterator
74
+ async def get_next() -> T:
75
+ return await ait.__anext__()
76
+
77
+ pending_task: asyncio.Task[T] | None = None
78
+ while True:
79
+ try:
80
+ if pending_task is None:
81
+ pending_task = asyncio.create_task(get_next())
82
+ item = await asyncio.wait_for(
83
+ asyncio.shield(pending_task), timeout=timeout
84
+ )
85
+ pending_task = None # Task completed, clear it
86
+ current.append(item)
87
+ if len(current) >= size:
88
+ yield current
89
+ current = []
90
+ except TimeoutError:
91
+ if current:
92
+ yield current
93
+ current = []
94
+ # pending_task is still running, will be awaited next iteration
95
+ except StopAsyncIteration:
96
+ if current:
97
+ yield current
98
+ break
99
+
100
+
101
+ @overload
102
+ def window[T](size: int, it: Iterable[T], *, step: int = 1) -> Iterator[list[T]]: ...
103
+
104
+
105
+ @overload
106
+ def window[T](
107
+ size: int, it: AsyncIterable[T], *, step: int = 1
108
+ ) -> AsyncIterator[list[T]]: ...
109
+
110
+
111
+ def window[T](
112
+ size: int, it: Iterable[T] | AsyncIterable[T], *, step: int = 1
113
+ ) -> Iterator[list[T]] | AsyncIterator[list[T]]:
114
+ """Sliding window over elements."""
115
+ if size <= 0:
116
+ raise ValueError("size must be positive")
117
+ if step <= 0:
118
+ raise ValueError("step must be positive")
119
+ if is_async_iterable(it):
120
+ return _window_async(size, it, step) # type: ignore[arg-type]
121
+ return _window_sync(size, it, step) # type: ignore[arg-type, return-value]
122
+
123
+
124
+ def _window_sync[T](size: int, it: Iterable[T], step: int) -> Iterator[list[T]]:
125
+ buf: deque[T] = deque(maxlen=size)
126
+ skip = 0
127
+ for item in it:
128
+ if skip > 0:
129
+ skip -= 1
130
+ buf.append(item)
131
+ continue
132
+ buf.append(item)
133
+ if len(buf) == size:
134
+ yield list(buf)
135
+ skip = step - 1
136
+ for _ in range(min(step, size)):
137
+ if buf:
138
+ buf.popleft()
139
+
140
+
141
+ async def _window_async[T](
142
+ size: int, it: AsyncIterable[T], step: int
143
+ ) -> AsyncIterator[list[T]]:
144
+ buf: deque[T] = deque(maxlen=size)
145
+ skip = 0
146
+ async for item in it:
147
+ if skip > 0:
148
+ skip -= 1
149
+ buf.append(item)
150
+ continue
151
+ buf.append(item)
152
+ if len(buf) == size:
153
+ yield list(buf)
154
+ skip = step - 1
155
+ for _ in range(min(step, size)):
156
+ if buf:
157
+ buf.popleft()
158
+
159
+
160
+ def partition[T](pred: Callable[[T], bool], it: Iterable[T]) -> tuple[list[T], list[T]]:
161
+ """Split into (matches, non_matches). Terminal operation."""
162
+ matches: list[T] = []
163
+ non_matches: list[T] = []
164
+ for item in it:
165
+ if pred(item):
166
+ matches.append(item)
167
+ else:
168
+ non_matches.append(item)
169
+ return matches, non_matches
170
+
171
+
172
+ async def partition_async[T](
173
+ pred: Callable[[T], bool], it: AsyncIterable[T]
174
+ ) -> tuple[list[T], list[T]]:
175
+ """Split into (matches, non_matches). Terminal operation (async)."""
176
+ matches: list[T] = []
177
+ non_matches: list[T] = []
178
+ async for item in it:
179
+ if pred(item):
180
+ matches.append(item)
181
+ else:
182
+ non_matches.append(item)
183
+ return matches, non_matches