dtools.fp 1.3.0__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
dtools/fp/__init__.py ADDED
@@ -0,0 +1,34 @@
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
+ """### Package dtools.fp - Pythonic Functional Programming
16
+
17
+ Tools to aid with functional programming in Python yet still endeavoring to
18
+ remain Pythonic.
19
+
20
+ #### Modules and sub-packages
21
+
22
+ * module dtools.fp.err_handling: monadic maybe and either
23
+ * module dtools.fp.functions: tools combine and partially apply functions
24
+ * module dtools.fp.iterables: tools for iterables
25
+ * module dtools.fp.lazy: lazy (non-strict) function evaluation
26
+ * module dtools.fp.singletons: useful types with but one instance
27
+ * module dtools.fp.state: handle state monadically
28
+
29
+ """
30
+ __version__ = "1.3.0"
31
+ __author__ = "Geoffrey R. Scheller"
32
+ __copyright__ = "Copyright (c) 2023-2025 Geoffrey R. Scheller"
33
+ __license__ = "Apache License 2.0"
34
+
@@ -0,0 +1,436 @@
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.err_handling - monadic error handling
16
+
17
+ Functional data types to use in lieu of exceptions.
18
+
19
+ #### Error handling types:
20
+
21
+ * class **MB**: Maybe (Optional) monad
22
+ * class **XOR**: Left biased Either monad
23
+
24
+ """
25
+ from __future__ import annotations
26
+
27
+ __all__ = [ 'MB', 'XOR' ]
28
+
29
+ from collections.abc import Callable, Iterator, Sequence
30
+ from typing import cast, Final, Never, overload
31
+ from .singletons import Sentinel
32
+
33
+ class MB[D]():
34
+ """Maybe monad - class wrapping a potentially missing value.
35
+
36
+ * where `MB(value)` contains a possible value of type `~D`
37
+ * `MB()` semantically represent a non-existent or missing value of type `~D`
38
+ * `MB` objects are self flattening, therefore a `MB` cannot contain a MB
39
+ * `MB(MB(d)) == MB(d)`
40
+ * `MB(MB()) == MB()`
41
+ * immutable, a `MB` does not change after being created
42
+ * immutable semantics, map & bind return new instances
43
+ * warning: contained values need not be immutable
44
+ * warning: not hashable if contained value is mutable
45
+
46
+ """
47
+ __slots__ = '_value',
48
+ __match_args__ = '_value',
49
+
50
+ @overload
51
+ def __init__(self) -> None: ...
52
+ @overload
53
+ def __init__(self, value: MB[D]) -> None: ...
54
+ @overload
55
+ def __init__(self, value: D) -> None: ...
56
+
57
+ def __init__(self, value: D|MB[D]|Sentinel=Sentinel('MB')) -> None:
58
+ self._value: D|Sentinel
59
+ _sentinel: Final[Sentinel] = Sentinel('MB')
60
+ match value:
61
+ case MB(d) if d is not _sentinel:
62
+ self._value = d
63
+ case MB(s):
64
+ self._value = _sentinel
65
+ case d:
66
+ self._value = d
67
+
68
+ def __bool__(self) -> bool:
69
+ return self._value is not Sentinel('MB')
70
+
71
+ def __iter__(self) -> Iterator[D]:
72
+ if self:
73
+ yield cast(D, self._value)
74
+
75
+ def __repr__(self) -> str:
76
+ if self:
77
+ return 'MB(' + repr(self._value) + ')'
78
+ else:
79
+ return 'MB()'
80
+
81
+ def __len__(self) -> int:
82
+ return (1 if self else 0)
83
+
84
+ def __eq__(self, other: object) -> bool:
85
+ if not isinstance(other, type(self)):
86
+ return False
87
+
88
+ if self._value is other._value:
89
+ return True
90
+ elif self._value == other._value:
91
+ return True
92
+ else:
93
+ return False
94
+
95
+ @overload
96
+ def get(self) -> D|Never: ...
97
+ @overload
98
+ def get(self, alt: D) -> D: ...
99
+ @overload
100
+ def get(self, alt: Sentinel) -> D|Never: ...
101
+
102
+ def get(self, alt: D|Sentinel=Sentinel('MB')) -> D|Never:
103
+ """Return the contained value if it exists, otherwise an alternate value.
104
+
105
+ * alternate value must be of type `~D`
106
+ * raises `ValueError` if an alternate value is not provided but needed
107
+
108
+ """
109
+ _sentinel: Final[Sentinel] = Sentinel('MB')
110
+ if self._value is not _sentinel:
111
+ return cast(D, self._value)
112
+ else:
113
+ if alt is _sentinel:
114
+ msg = 'MB: an alternate return type not provided'
115
+ raise ValueError(msg)
116
+ else:
117
+ return cast(D, alt)
118
+
119
+ def map[U](self, f: Callable[[D], U]) -> MB[U]:
120
+ """Map function `f` over the 0 or 1 elements of this data structure.
121
+
122
+ * if `f` should fail, return a MB()
123
+
124
+ """
125
+ if self._value is Sentinel('MB'):
126
+ return cast(MB[U], self)
127
+ else:
128
+ try:
129
+ return MB(f(cast(D, self._value)))
130
+ except Exception:
131
+ return MB()
132
+
133
+ def bind[U](self, f: Callable[[D], MB[U]]) -> MB[U]:
134
+ """Map `MB` with function `f` and flatten."""
135
+ try:
136
+ return (f(cast(D, self._value)) if self else MB())
137
+ except Exception:
138
+ return MB()
139
+
140
+ @staticmethod
141
+ def call[U, V](f: Callable[[U], V], u: U) -> MB[V]:
142
+ """Return an function call wrapped in a MB"""
143
+ try:
144
+ return MB(f(u))
145
+ except Exception:
146
+ return MB()
147
+
148
+ @staticmethod
149
+ def lz_call[U, V](f: Callable[[U], V], u: U) -> Callable[[], MB[V]]:
150
+ def ret() -> MB[V]:
151
+ return MB.call(f, u)
152
+ return ret
153
+
154
+ @staticmethod
155
+ def idx[V](v: Sequence[V], ii: int) -> MB[V]:
156
+ """Return an indexed value wrapped in a MB"""
157
+ try:
158
+ return MB(v[ii])
159
+ except IndexError:
160
+ return MB()
161
+
162
+ @staticmethod
163
+ def lz_idx[V](v: Sequence[V], ii: int) -> Callable[[], MB[V]]:
164
+ def ret() -> MB[V]:
165
+ return MB.idx(v, ii)
166
+ return ret
167
+
168
+ @staticmethod
169
+ def sequence[T](seq_mb_d: Sequence[MB[T]]) -> MB[Sequence[T]]:
170
+ """Sequence an indexable container of `MB[~D]`
171
+
172
+ * if all the contained `MB` values in the container are not empty,
173
+ * return a `MB` of a container containing the values contained
174
+ * otherwise return an empty `MB`
175
+
176
+ """
177
+ l: list[T] = []
178
+
179
+ for mb_d in seq_mb_d:
180
+ if mb_d:
181
+ l.append(mb_d.get())
182
+ else:
183
+ return MB()
184
+
185
+ ds = cast(Sequence[T], type(seq_mb_d)(l)) # type: ignore # will be a subclass at runtime
186
+ return MB(ds)
187
+
188
+ class XOR[L, R]():
189
+ """Either monad - class semantically containing either a left or a right
190
+ value, but not both.
191
+
192
+ * implements a left biased Either Monad
193
+ * `XOR(left: ~L, right: ~R)` produces a left `XOR` which
194
+ * contains a value of type `~L`
195
+ * and a potential right value of type `~R`
196
+ * `XOR(MB(), right)` produces a right `XOR`
197
+ * in a Boolean context
198
+ * `True` if a left `XOR`
199
+ * `False` if a right `XOR`
200
+ * two `XOR` objects compare as equal when
201
+ * both are left values or both are right values whose values
202
+ * are the same object
203
+ * compare as equal
204
+ * immutable, an `XOR` does not change after being created
205
+ * immutable semantics, map & bind return new instances
206
+ * warning: contained values need not be immutable
207
+ * warning: not hashable if value or potential right value mutable
208
+
209
+ """
210
+ __slots__ = '_left', '_right'
211
+ __match_args__ = ('_left', '_right')
212
+
213
+ @overload
214
+ def __init__(self, left: L, right: R, /) -> None: ...
215
+ @overload
216
+ def __init__(self, left: MB[L], right: R, /) -> None: ...
217
+
218
+ def __init__(self, left: L|MB[L], right: R, /) -> None:
219
+ self._left: L|MB[L]
220
+ self._right: R
221
+ match left:
222
+ case MB(l) if l is not Sentinel('MB'):
223
+ self._left, self._right = cast(L, l), right
224
+ case MB(s):
225
+ self._left, self._right = MB(), right
226
+ case l:
227
+ self._left, self._right = l, right
228
+
229
+ def __bool__(self) -> bool:
230
+ return MB() != self._left
231
+
232
+ def __iter__(self) -> Iterator[L]:
233
+ if self:
234
+ yield cast(L, self._left)
235
+
236
+ def __repr__(self) -> str:
237
+ if self:
238
+ return 'XOR(' + repr(self._left) + ', ' + repr(self._right) + ')'
239
+ else:
240
+ return 'XOR(MB(), ' + repr(self._right) + ')'
241
+
242
+ def __str__(self) -> str:
243
+ if self:
244
+ return '< ' + str(self._left) + ' | >'
245
+ else:
246
+ return '< | ' + str(self._right) + ' >'
247
+
248
+ def __len__(self) -> int:
249
+ # Semantically, an XOR always contains just one value.
250
+ return 1
251
+
252
+ def __eq__(self, other: object) -> bool:
253
+ if not isinstance(other, type(self)):
254
+ return False
255
+
256
+ if self and other:
257
+ if self._left is other._left:
258
+ return True
259
+ elif self._left == other._left:
260
+ return True
261
+ else:
262
+ return False
263
+
264
+ if not self and not other:
265
+ if self._right is other._right:
266
+ return True
267
+ elif self._right == other._right:
268
+ return True
269
+ else:
270
+ return False
271
+
272
+ return False
273
+
274
+ @overload
275
+ def getLeft(self) -> MB[L]: ...
276
+ @overload
277
+ def getLeft(self, altLeft: L) -> MB[L]: ...
278
+ @overload
279
+ def getLeft(self, altLeft: MB[L]) -> MB[L]: ...
280
+
281
+ def getLeft(self, altLeft: L|MB[L]=MB()) -> MB[L]:
282
+ """Get value if a left.
283
+
284
+ * if the `XOR` is a left, return its value
285
+ * if a right, return an alternate value of type ~L` if it is provided
286
+ * alternate value provided directly
287
+ * or optionally provided with a MB
288
+ * returns a `MB[L]` for when an altLeft value is needed but not provided
289
+
290
+ """
291
+ _sentinel = Sentinel('MB')
292
+ match altLeft:
293
+ case MB(l) if l is not _sentinel:
294
+ if self:
295
+ return MB(self._left)
296
+ else:
297
+ return MB(cast(L, l))
298
+ case MB(s):
299
+ if self:
300
+ return MB(self._left)
301
+ else:
302
+ return MB()
303
+ case l:
304
+ if self:
305
+ return MB(self._left)
306
+ else:
307
+ return MB(l)
308
+
309
+ def getRight(self) -> R:
310
+ """Get value of `XOR` if a right, potential right value if a left.
311
+
312
+ * if `XOR` is a right, return its value
313
+ * if `XOR` is a left, return the potential right value
314
+
315
+ """
316
+ return self._right
317
+
318
+ def makeRight(self) -> XOR[L, R]:
319
+ """Make a right based on the `XOR`.
320
+
321
+ * return a right based on potential right value
322
+ * returns itself if already a right
323
+
324
+ """
325
+ if self._left == MB():
326
+ return self
327
+ else:
328
+ return cast(XOR[L, R], XOR(MB(), self._right))
329
+
330
+ def newRight(self, right: R) -> XOR[L, R]:
331
+ """Swap in a right value.
332
+
333
+ * returns a new instance with a new right (or potential right) value.
334
+
335
+ """
336
+ if self._left == MB():
337
+ return cast(XOR[L, R], XOR(MB(), right))
338
+ else:
339
+ return cast(XOR[L, R], XOR(self._left, right))
340
+
341
+ def map[U](self, f: Callable[[L], U]) -> XOR[U, R]:
342
+ """Map over if a left value.
343
+
344
+ * if `XOR` is a left then map `f` over its value
345
+ * if `f` successful return a left `XOR[S, R]`
346
+ * if `f` unsuccessful return right `XOR[S, R]`
347
+ * swallows any exceptions `f` may throw
348
+ * if `XOR` is a right
349
+ * return new `XOR(right=self._right): XOR[S, R]`
350
+ * use method `mapRight` to adjust the returned value
351
+
352
+ """
353
+ if self._left == MB():
354
+ return cast(XOR[U, R], self)
355
+ try:
356
+ applied = f(cast(L, self._left))
357
+ except Exception:
358
+ return cast(XOR[U, R], XOR(MB(), self._right))
359
+ else:
360
+ return XOR(applied, self._right)
361
+
362
+ def mapRight(self, g: Callable[[R], R], altRight: R) -> XOR[L, R]:
363
+ """Map over a right or potential right value."""
364
+ try:
365
+ applied = g(self._right)
366
+ right = applied
367
+ except:
368
+ right = altRight
369
+
370
+ if self:
371
+ left: L|MB[L] = cast(L, self._left)
372
+ else:
373
+ left = MB()
374
+
375
+ return XOR(left, right)
376
+
377
+ def bind[U](self, f: Callable[[L], XOR[U, R]]) -> XOR[U, R]:
378
+ """Flatmap - bind
379
+
380
+ * map over then flatten left values
381
+ * propagate right values
382
+
383
+ """
384
+ if self._left == MB():
385
+ return cast(XOR[U, R], self)
386
+ else:
387
+ return f(cast(L, self._left))
388
+
389
+ @staticmethod
390
+ def call[U, V](f: Callable[[U], V], left: U) -> XOR[V, MB[Exception]]:
391
+ try:
392
+ return XOR(f(left), MB())
393
+ except Exception as esc:
394
+ return XOR(MB(), MB(esc))
395
+
396
+ @staticmethod
397
+ def lz_call[U, V](f: Callable[[U], V], left: U) -> Callable[[], XOR[V, MB[Exception]]]:
398
+ def ret() -> XOR[V, MB[Exception]]:
399
+ return XOR.call(f, left)
400
+ return ret
401
+
402
+ @staticmethod
403
+ def idx[V](v: Sequence[V], ii: int) -> XOR[V, MB[Exception]]:
404
+ try:
405
+ return XOR(v[ii], MB())
406
+ except Exception as esc:
407
+ return XOR(MB(), MB(esc))
408
+
409
+ @staticmethod
410
+ def lz_idx[V](v: Sequence[V], ii: int) -> Callable[[], XOR[V, MB[Exception]]]:
411
+ def ret() -> XOR[V, MB[Exception]]:
412
+ return XOR.idx(v, ii)
413
+ return ret
414
+
415
+ @staticmethod
416
+ def sequence(seq_xor_lr: Sequence[XOR[L, R]], potential_right: R) -> XOR[Sequence[L], R]:
417
+ """Sequence an indexable container of `XOR[L, R]`
418
+
419
+ * if all the `XOR` values contained in the container are lefts, then
420
+ * return an `XOR` of the same type container of all the left values
421
+ * setting the potential right `potential_right`
422
+ * if at least one of the `XOR` values contained in the container is a right,
423
+ * return a right XOR containing the right value of the first right
424
+
425
+ """
426
+ l: list[L] = []
427
+
428
+ for xor_lr in seq_xor_lr:
429
+ if xor_lr:
430
+ l.append(xor_lr.getLeft().get())
431
+ else:
432
+ return XOR(MB(), xor_lr.getRight())
433
+
434
+ ds = cast(Sequence[L], type(seq_xor_lr)(l)) # type: ignore # will be a subclass at runtime
435
+ return XOR(ds, potential_right)
436
+
dtools/fp/function.py ADDED
@@ -0,0 +1,71 @@
1
+ # Copyright 2024-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.functional - compose and partially apply functions.
16
+
17
+ Not a replacement for the std library's `functools` which is more about
18
+ modifying function behavior through decorators than functional composition
19
+ and application.
20
+
21
+ #### FP utilities to manipulate function arguments return values:
22
+
23
+ * function **swap:** swap the arguments of a 2 argument function
24
+ * function **sequenced:** convert function to take a sequence of its arguments
25
+ * function **partial:** returns a partially applied function
26
+ * function **iter_args:** function returning an iterator of its arguments
27
+
28
+ """
29
+ from __future__ import annotations
30
+ from collections.abc import Callable, Iterator, Sequence
31
+ from typing import Any
32
+
33
+ __all__ = [ 'swap', 'sequenced', 'partial', 'iter_args' ]
34
+
35
+ ## Functional Utilities
36
+
37
+ def swap[U,V,R](f: Callable[[U, V], R]) -> Callable[[V, U], R]:
38
+ """Swap arguments of a two argument function."""
39
+ return (lambda v, u: f(u,v))
40
+
41
+ def sequenced[R](f: Callable[..., R]) -> Callable[..., R]:
42
+ """Convert a function with arbitrary positional arguments to one taking
43
+ a sequence of the original arguments.
44
+ """
45
+ def F(arguments: Sequence[Any]) -> R:
46
+ return f(*arguments)
47
+ return F
48
+
49
+ def partial[R](f: Callable[..., R], *args: Any) -> Callable[..., R]:
50
+ """Partially apply arguments to a function, left to right.
51
+
52
+ * type-wise the only thing guaranteed is the return value
53
+ * best practice is to either
54
+ * use `partial` and `sequenced` results immediately and locally
55
+ * otherwise cast the results when they are created
56
+
57
+ """
58
+ def wrap(*rest: R) -> R:
59
+ return sequenced(f)(args + rest)
60
+
61
+ return wrap
62
+
63
+ def iter_args[A](*args: A) -> Iterator[A]:
64
+ """Function returning an iterators of its arguments.
65
+
66
+ * useful for API's with single iterable constructors
67
+
68
+ """
69
+ for arg in args:
70
+ yield arg
71
+