dtools.fp 1.3.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.
dtools/fp/iterables.py ADDED
@@ -0,0 +1,413 @@
1
+ # Copyright 2023-2025 Geoffrey R. Scheller
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """### Module fp.iterables - Iterator related tools
16
+
17
+ Library of iterator related functions and enumerations.
18
+
19
+ * iterables are not necessarily iterators
20
+ * at all times iterator protocol is assumed to be followed, that is
21
+ * all iterators are assumed to be iterable
22
+ * for all iterators `foo` we assume `iter(foo) is foo`
23
+
24
+ #### Concatenating and merging iterables:
25
+
26
+ * function **concat**: sequentially chain iterables
27
+ * function **exhaust**: shuffle together iterables until all are exhausted
28
+ * function **merge**: shuffle together iterables until one is exhausted
29
+
30
+ ---
31
+
32
+ #### Dropping and taking values from an iterable:
33
+
34
+ * function **drop**: drop first `n` values from iterable
35
+ * function **drop_while**: drop values from iterable while predicate holds
36
+ * function **take**: take up to `n` initial values from iterable
37
+ * function **take_split**: splitting out initial `n` initial values of iterable * function **take_while**: take values from iterable while predicate holds
38
+ * function **take_while_split**: splitting an iterable while predicate holds
39
+
40
+ ---
41
+
42
+ #### Reducing and accumulating an iterable:
43
+
44
+ * function **accumulate**: take iterable & function, return iterator of accumulated values
45
+ * function **foldL0**: fold iterable left with a function
46
+ * raises `StopIteration` exception if iterable is empty
47
+ * function **foldL1**: fold iterable left with a function and initial value
48
+ * function **mbFoldL**: fold iterable left with an optional initial value
49
+ * wraps result in a `MB` monad
50
+
51
+ """
52
+ from __future__ import annotations
53
+ from collections.abc import Callable, Iterator, Iterable, Reversible
54
+ from enum import auto, Enum
55
+ from typing import cast, Never, Protocol
56
+ from .err_handling import MB
57
+ from .function import swap
58
+ from .singletons import NoValue
59
+
60
+ __all__ = [ 'FM', 'concat', 'merge', 'exhaust',
61
+ 'drop', 'drop_while',
62
+ 'take', 'take_while',
63
+ 'take_split', 'take_while_split',
64
+ 'accumulate', 'foldL0', 'foldL1', 'mbFoldL' ] #,
65
+ # 'scFoldL', 'scFoldR' ]
66
+
67
+ ## Iterate over multiple Iterables
68
+
69
+ class FM(Enum):
70
+ CONCAT = auto()
71
+ MERGE = auto()
72
+ EXHAUST = auto()
73
+
74
+ def concat[D](*iterables: Iterable[D]) -> Iterator[D]:
75
+ """Sequentially concatenate multiple iterables together.
76
+
77
+ * pure Python version of standard library's `itertools.chain`
78
+ * iterator sequentially yields each iterable until all are exhausted
79
+ * an infinite iterable will prevent subsequent iterables from yielding any values
80
+ * performant to `itertools.chain`
81
+
82
+ """
83
+ for iterator in map(lambda x: iter(x), iterables):
84
+ while True:
85
+ try:
86
+ value = next(iterator)
87
+ yield value
88
+ except StopIteration:
89
+ break
90
+
91
+ def exhaust[D](*iterables: Iterable[D]) -> Iterator[D]:
92
+ """Shuffle together multiple iterables until all are exhausted.
93
+
94
+ * iterator yields until all iterables are exhausted
95
+
96
+ """
97
+ iterList = list(map(lambda x: iter(x), iterables))
98
+ if (numIters := len(iterList)) > 0:
99
+ ii = 0
100
+ values = []
101
+ while True:
102
+ try:
103
+ while ii < numIters:
104
+ values.append(next(iterList[ii]))
105
+ ii += 1
106
+ for value in values:
107
+ yield value
108
+ ii = 0
109
+ values.clear()
110
+ except StopIteration:
111
+ numIters -= 1
112
+ if numIters < 1:
113
+ break
114
+ del iterList[ii]
115
+ for value in values:
116
+ yield value
117
+
118
+ def merge[D](*iterables: Iterable[D], yield_partials: bool=False) -> Iterator[D]:
119
+ """Shuffle together the `iterables` until one is exhausted.
120
+
121
+ * iterator yields until one of the iterables is exhausted
122
+ * if `yield_partials` is true,
123
+ * yield any unmatched yielded values from other iterables
124
+ * prevents data lose
125
+ * if any of the iterables are iterators with external references
126
+
127
+ """
128
+ iterList = list(map(lambda x: iter(x), iterables))
129
+ values = []
130
+ if (numIters := len(iterList)) > 0:
131
+ while True:
132
+ try:
133
+ for ii in range(numIters):
134
+ values.append(next(iterList[ii]))
135
+ for value in values:
136
+ yield value
137
+ values.clear()
138
+ except StopIteration:
139
+ break
140
+ if yield_partials:
141
+ for value in values:
142
+ yield value
143
+
144
+ ## dropping and taking
145
+
146
+ def drop[D](
147
+ iterable: Iterable[D],
148
+ n: int, /
149
+ ) -> Iterator[D]:
150
+ """Drop the next `n` values from `iterable`."""
151
+ it = iter(iterable)
152
+ for _ in range(n):
153
+ try:
154
+ next(it)
155
+ except StopIteration:
156
+ break
157
+ return it
158
+
159
+ def drop_while[D](
160
+ iterable: Iterable[D],
161
+ predicate: Callable[[D], bool], /
162
+ ) -> Iterator[D]:
163
+ """Drop initial values from `iterable` while predicate is true."""
164
+ it = iter(iterable)
165
+ while True:
166
+ try:
167
+ value = next(it)
168
+ if not predicate(value):
169
+ it = concat((value,), it)
170
+ break
171
+ except StopIteration:
172
+ break
173
+ return it
174
+
175
+ def take[D](
176
+ iterable: Iterable[D],
177
+ n: int, /
178
+ ) -> Iterator[D]:
179
+ """Return an iterator of up to `n` initial values of an iterable"""
180
+ it = iter(iterable)
181
+ for _ in range(n):
182
+ try:
183
+ value = next(it)
184
+ yield value
185
+ except StopIteration:
186
+ break
187
+
188
+ def take_split[D](
189
+ iterable: Iterable[D],
190
+ n: int, /
191
+ ) -> tuple[Iterator[D], Iterator[D]]:
192
+ """Same as take except also return an iterator of the remaining values.
193
+
194
+ * return a tuple of
195
+ * an iterator of up to `n` initial values
196
+ * an iterator of the remaining vales of the `iterable`
197
+ * best practice is not to access second iterator until first is exhausted
198
+
199
+ """
200
+ it = iter(iterable)
201
+ itn = take(it, n)
202
+
203
+ return itn, it
204
+
205
+ def take_while[D](
206
+ iterable: Iterable[D],
207
+ pred: Callable[[D], bool], /
208
+ ) -> Iterator[D]:
209
+ """Yield values from `iterable` while predicate is true.
210
+
211
+ **Warning:** risk of potential value loss if iterable is iterator with
212
+ multiple references.
213
+ """
214
+ it = iter(iterable)
215
+ while True:
216
+ try:
217
+ value = next(it)
218
+ if pred(value):
219
+ yield value
220
+ else:
221
+ break
222
+ except StopIteration:
223
+ break
224
+
225
+ def take_while_split[D](
226
+ iterable: Iterable[D],
227
+ predicate: Callable[[D], bool], /
228
+ ) -> tuple[Iterator[D], Iterator[D]]:
229
+ """Yield values from `iterable` while `predicate` is true.
230
+
231
+ * return a tuple of two iterators
232
+ * first of initial values where predicate is true, followed by first to fail
233
+ * second of the remaining values of the iterable after first failed value
234
+ * best practice is not to access second iterator until first is exhausted
235
+
236
+ """
237
+ def _take_while(it: Iterator[D], pred: Callable[[D], bool], val: list[D]) -> Iterator[D]:
238
+ while True:
239
+ try:
240
+ if val:
241
+ val[0] = next(it)
242
+ else:
243
+ val.append(next(it))
244
+ if pred(val[0]):
245
+ yield val[0]
246
+ val.pop()
247
+ else:
248
+ break
249
+ except StopIteration:
250
+ break
251
+
252
+ it = iter(iterable)
253
+ value: list[D] = []
254
+ it_pred = _take_while(it, predicate, value)
255
+
256
+ return (it_pred, concat(value, it))
257
+
258
+ ## reducing and accumulating
259
+
260
+ def accumulate[D,L](
261
+ iterable: Iterable[D],
262
+ f: Callable[[L, D], L],
263
+ initial: L|NoValue=NoValue(), /
264
+ ) -> Iterator[L]:
265
+ """Returns an iterator of accumulated values.
266
+
267
+ * pure Python version of standard library's `itertools.accumulate`
268
+ * function `f` does not default to addition (for typing flexibility)
269
+ * begins accumulation with an optional `initial` value
270
+
271
+ """
272
+ it = iter(iterable)
273
+ try:
274
+ it0 = next(it)
275
+ except StopIteration:
276
+ if initial is NoValue():
277
+ return
278
+ else:
279
+ yield cast(L, initial)
280
+ else:
281
+ if initial is not NoValue():
282
+ init = cast(L, initial)
283
+ yield init
284
+ acc = f(init, it0)
285
+ for ii in it:
286
+ yield acc
287
+ acc = f(acc, ii)
288
+ yield acc
289
+ else:
290
+ acc = cast(L, it0) # in this case L = D
291
+ for ii in it:
292
+ yield acc
293
+ acc = f(acc, ii)
294
+ yield acc
295
+
296
+ def foldL0[D](
297
+ iterable: Iterable[D],
298
+ f: Callable[[D, D], D], /
299
+ ) -> D|Never:
300
+ """Folds an iterable left with optional initial value.
301
+
302
+ * traditional FP type order given for function `f`
303
+ * if iterable empty raises StopIteration exception
304
+ * does not catch any exception `f` raises
305
+ * never returns if `iterable` generates an infinite iterator
306
+
307
+ """
308
+ it = iter(iterable)
309
+ try:
310
+ acc = next(it)
311
+ except StopIteration:
312
+ msg = "Attemped to left fold an empty iterable."
313
+ raise StopIteration(msg)
314
+
315
+ for v in it:
316
+ acc = f(acc, v)
317
+
318
+ return acc
319
+
320
+ def foldL1[D, L](
321
+ iterable: Iterable[D],
322
+ f: Callable[[L, D], L],
323
+ initial: L, /
324
+ ) -> L|Never:
325
+ """Folds an iterable left with optional initial value.
326
+
327
+ * traditional FP type order given for function `f`
328
+ * does not catch any exception `f` may raise
329
+ * like builtin `sum` for Python >=3.8 except
330
+ - not restricted to __add__ for the folding function
331
+ - initial value required, does not default to `0` for initial value
332
+ - handles non-numeric data just find
333
+ * never returns if `iterable` generates an infinite iterator
334
+
335
+ """
336
+ acc = initial
337
+ for v in iterable:
338
+ acc = f(acc, v)
339
+ return acc
340
+
341
+ def mbFoldL[L, D](
342
+ iterable: Iterable[D],
343
+ f: Callable[[L, D], L],
344
+ initial: L|NoValue=NoValue()
345
+ ) -> MB[L]:
346
+ """Folds an iterable left with optional initial value.
347
+
348
+ * traditional FP type order given for function `f`
349
+ * when an initial value is not given then `~L = ~D`
350
+ * if iterable empty and no `initial` value given, return `MB()`
351
+ * never returns if iterable generates an infinite iterator
352
+
353
+ """
354
+ acc: L
355
+ it = iter(iterable)
356
+ if initial is NoValue():
357
+ try:
358
+ acc = cast(L, next(it)) # in this case L = D
359
+ except StopIteration:
360
+ return MB()
361
+ else:
362
+ acc = cast(L, initial)
363
+
364
+ for v in it:
365
+ try:
366
+ acc = f(acc, v)
367
+ except Exception:
368
+ return MB()
369
+
370
+ return MB(acc)
371
+
372
+
373
+ #def scFoldL[D, L](iterable: Iterable[D],
374
+ # f: Callable[[L, D], L],
375
+ # initial: L|NoValue=NoValue(), /,
376
+ # start_folding: Callable[[D], bool]=lambda d: True,
377
+ # stop_folding: Callable[[D], bool]=lambda d: False,
378
+ # include_start: bool=True,
379
+ # propagate_failed: bool=True) -> tuple[MB[L], Iterable[D]]:
380
+ # """Short circuit version of a left fold. Useful for infinite or
381
+ # non-reversible iterables.
382
+ #
383
+ # * Behavior for default arguments will
384
+ # * left fold finite iterable
385
+ # * start folding immediately
386
+ # * continue folding until end (of a possibly infinite iterable)
387
+ # * Callable `start_folding` delays starting a left fold
388
+ # * Callable `stop_folding` is to prematurely stop the folding left
389
+ # * Returns an XOR of either the folded value or error string
390
+ #
391
+ # """
392
+ #
393
+ #def scFoldR[D, R](iterable: Iterable[D],
394
+ # f: Callable[[D, R], R],
395
+ # initial: R|NoValue=NoValue(), /,
396
+ # start_folding: Callable[[D], bool]=lambda d: False,
397
+ # stop_folding: Callable[[D], bool]=lambda d: False,
398
+ # include_start: bool=True,
399
+ # include_stop: bool=True) -> tuple[MB[R], Iterable[D]]:
400
+ # """Short circuit version of a right fold. Useful for infinite or
401
+ # non-reversible iterables.
402
+ #
403
+ # * Behavior for default arguments will
404
+ # * right fold finite iterable
405
+ # * start folding at end (of a possibly infinite iterable)
406
+ # * continue folding right until beginning
407
+ # * Callable `start_folding` prematurely starts a right fold
408
+ # * Callable `stop_folding` is to prematurely stops a right fold
409
+ # * Returns an XOR of either the folded value or error string
410
+ # * best practice is not to access second iterator until first is exhausted
411
+ #
412
+ # """
413
+
dtools/fp/lazy.py ADDED
@@ -0,0 +1,126 @@
1
+ # Copyright 2023-2025 Geoffrey R. Scheller
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """### Module fp.lazy - lazy function evaluations
16
+
17
+ Delayed function evaluations, if needed, usually in some inner scope. FP tools
18
+ for "non-strict" function evaluations.
19
+
20
+ #### Non-strict delayed function evaluation:
21
+
22
+ * class **Lazy:** Delay evaluation of function taking & returning single values
23
+ * function **lazy:** Delay evaluation of a function taking more than one value
24
+
25
+ """
26
+ from __future__ import annotations
27
+
28
+ __all__ = [ 'Lazy', 'lazy' ]
29
+
30
+ from collections.abc import Callable
31
+ from typing import Final
32
+ from .err_handling import MB, XOR
33
+ from .function import sequenced
34
+
35
+ class Lazy[D, R]():
36
+ """Delayed evaluation of a function mapping a value of type D
37
+
38
+ Class instance delays the executable of a function where `Lazy(f, arg)`
39
+ constructs an object that can evaluate the Callable `f` with its argument
40
+ at a later time.
41
+
42
+ * first argument `f` taking values of type `~D` to values of type `~R`
43
+ * second argument `arg: ~D` is the argument to be passed to `f`
44
+ * where the type `~D` is the `tuple` type of the argument types to `f`
45
+ * function is evaluated when the eval method is called
46
+ * result is cached unless `pure` is set to `False` in `__init__` method
47
+
48
+ Usually use case is to make a function "non-strict" by passing some of its
49
+ arguments wrapped in Lazy instances.
50
+ """
51
+ __slots__ = '_f', '_d', '_result', '_pure'
52
+
53
+ def __init__(self, f: Callable[[D], R], d: D, pure: bool=True) -> None:
54
+ self._f: Final[Callable[[D], R]] = f
55
+ self._d: Final[D] = d
56
+ self._pure: Final[bool] = pure
57
+ self._result: XOR[R, MB[Exception]] = XOR(MB(), MB())
58
+
59
+ def __bool__(self) -> bool:
60
+ return True if self._result else False
61
+
62
+ def is_evaluated(self) -> bool:
63
+ return self._result != XOR(MB(), MB())
64
+
65
+ def is_exceptional(self) -> bool:
66
+ if self.is_evaluated():
67
+ return False if self._result else True
68
+ else:
69
+ return False
70
+
71
+ def is_pure(self) -> bool:
72
+ return self._pure
73
+
74
+ def eval(self) -> bool:
75
+ """Evaluate function with its argument.
76
+
77
+ * evaluate function
78
+ * cache results or exceptions if `pure == True`
79
+ * reevaluate if `pure == False`
80
+
81
+ """
82
+ if not self.is_evaluated() or not self._pure:
83
+ try:
84
+ result = self._f(self._d)
85
+ except Exception as exc:
86
+ self._result = XOR(MB(), MB(exc))
87
+ return False
88
+ else:
89
+ self._result = XOR(MB(result), MB())
90
+ return True
91
+ if self:
92
+ return True
93
+ else:
94
+ return False
95
+
96
+ def result(self) -> MB[R]:
97
+ if not self.is_evaluated():
98
+ self.eval()
99
+
100
+ if self._result:
101
+ return MB(self._result.getLeft())
102
+ else:
103
+ return MB()
104
+
105
+ def exception(self) -> MB[Exception]:
106
+ if not self.is_evaluated():
107
+ self.eval()
108
+ return self._result.getRight()
109
+
110
+ def lazy[R, **P](f: Callable[P, R], *args: P.args, pure: bool=True) -> Lazy[tuple[P.args], R]:
111
+ """Delayed evaluation of a function with arbitrary positional arguments.
112
+
113
+ Function returning a delayed evaluation of a function of an arbitrary number
114
+ of positional arguments.
115
+
116
+ * first positional argument `f` takes a function
117
+ * next positional arguments are the arguments to be applied later to `f`
118
+ * `f` is evaluated when the `eval` method of the returned Lazy is called
119
+ * `f` is evaluated only once with results cached unless `pure` is `False`
120
+ * if `pure` is false, the arguments are reapplied to `f`
121
+ * useful for repeating side effects
122
+ * when arguments are or contain shared references
123
+
124
+ """
125
+ return Lazy(sequenced(f), args, pure=pure)
126
+
dtools/fp/py.typed ADDED
File without changes