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/__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
+