-
Notifications
You must be signed in to change notification settings - Fork 0
/
pointfree.py
766 lines (589 loc) · 23.4 KB
/
pointfree.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
"""
Pythonic pointfree programming.
* Full documentation: http://pointfree.readthedocs.org/en/latest/
* Project page: https://github.com/markshroyer/pointfree
Copyright notice
----------------
Copyright 2013 Mark Shroyer
Licensed under the Apache License, Version 2.0 (the "License"); you may not
use this file except in compliance with the License. You may obtain a copy
of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
License for the specific language governing permissions and limitations
under the License.
Usage
-----
The general use case is to wrap functions in the
:py:class:`~pointfree.pointfree` wrapper / decorator class, granting them
both automatic partial application support and a pair of function
composition operators::
>>> from pointfree import *
>>> @pointfree
... def pfadd(a, b):
... return a + b
>>> @pointfree
... def pfexp(n, exp):
... return n ** exp
>>> fn = pfexp(exp=2) * pfadd(1)
>>> fn(3)
16
:py:class:`pointfree.pointfree` inherits from the
:py:class:`pointfree.partial` class (not to be confused with
:py:func:`functools.partial`), which provides automatic partial application
but not the function composition operators. See :py:class:`partial's
<pointfree.partial>` documentation for details of the partial application
semantics, and :py:class:`pointfree's <pointfree.pointfree>` documentation
for information about the function composition operators.
The module also includes a number of pre-defined helper functions which can
be combined for various purposes::
>>> fn = pfmap(lambda x: x**3) >> pfprint_all
>>> fn(range(4))
0
1
8
27
Refer to the section :ref:`helper_functions` for information about the
helpers provided by this module.
"""
from __future__ import print_function
__author__ = "Mark Shroyer"
__email__ = "[email protected]"
__version__ = "1.1.1"
__all__ = [
'partial',
'pointfree',
'pf',
'pfmap',
'pfreduce',
'pffilter',
'pfcollect',
'pfprint',
'pfprint_all',
'pfignore_all',
]
import sys, inspect, types, itertools, functools
# No getfullargspec in Python 2, since there are no keyword-only arguments.
if hasattr(inspect, 'getfullargspec'):
from inspect import getfullargspec
else:
def getfullargspec(f):
return inspect.getargspec(f) + ([], None, {})
class partial(object):
"""Wraps a regular Python function or method into a callable object
supporting automatic partial application.
:param func: Function or method to wrap
:param pargs: Optional, positional arguments for the wrapped function
:param kargs: Optional, keyword arguments for the wrapped function
Example::
>>> @partial
... def foo(a,b,c):
... return a + b + c
>>> foo(1,2,3)
6
>>> foo(1)(2)(3)
6
Generally speaking, the evaluation strategy with regard to automatic
partial application is to apply all given arguments to the underlying
function as soon as possible.
When a :py:class:`~pointfree.partial` instance is called, the
positional and keyword arguments supplied are combined with the
instance's own cache of arguments for the wrapped function (which is
empty to begin with, for instances directly wrapping -- or applied as
decorators to -- pure Python functions or methods). If the combined
set of arguments is sufficient to invoke the wrapped function, then the
function is called and its result returned. If the combined arguments
are *not* sufficient, then a new copy of the wrapper is returned
instead, with the new combined argument set in its cache.
Calling a :py:class:`~pointfree.partial` object never changes its
state; instances are immutable for practical purposes, so they can be
called and reused indefinitely::
>>> p = q = foo(1,2)
>>> p(3)
6
>>> q(4) # Using the same instance twice
7
Arguments with default values do not need to be explicitly specified in
order for evaluation to occur. In the following example, ``foo2`` can
be evaluated as soon as we have specified the arguments ``a`` and
``b``::
>>> @partial
... def foo2(a, b, c=3):
... return a + b + c
>>> foo2(1,2)
6
>>> foo2(1)(2)
6
However, if extra arguments are supplied prior to evaluation, and if
the underlying function is capable of accepting those arguments, then
those will be passed to the function as well. If we call ``foo2`` as
follows, the third argument will be passed to the wrapped function as
``c``, overriding its default value::
>>> foo2(1,2,5)
8
>>> foo2(3)(4,5)
12
This works similarly with functions that accept variable positional
argument lists::
>>> @partial
... def foo3(a, *args):
... return a + sum(args)
>>> foo3(1)
1
>>> foo3(1,2)
3
>>> foo3(1,2,3)
6
Or variable keyword argument lists::
>>> @partial
... def foo4(a, **kargs):
... kargs.update({'a': a})
... return kargs
>>> result = foo4(3, b=4, c=5)
>>> for key in sorted(result.keys()):
... print("%s: %s" % (key, result[key]))
a: 3
b: 4
c: 5
But if you try to supply an argument that the function cannot accept, a
:py:exc:`~exceptions.TypeError` will be raised as soon as you attempt
to do so -- the wrapper doesn't wait until the underlying function is
called before raising the exception (unlike with
:py:func:`functools.partial`)::
>>> @partial
... def foo5(a, b, c):
... return a + b + c
>>> foo5(d=7)
Traceback (most recent call last):
...
TypeError: foo5() got an unexpected keyword argument 'd'
There are some sutble differences between how automatic partial
application works in this module and the semantics of regular Python
function application (or, again, of :py:func:`functools.partial`).
First, keyword arguments to partially applied functions can override an
argument specified in a previous call::
>>> @partial
... def foo6(a, b, c):
... return (a, b, c)
>>> foo6(1)(b=2)(b=3)(4) # overriding b given as keyword
(1, 3, 4)
>>> foo6(1,2)(b=3)(4) # overriding b given positionally
(1, 3, 4)
Also, the wrapper somewhat blurs the line between positional and
keyword arguments for the sake of flexibilty. If an argument is
specified with a keyword and then "reached" by a positional argument in
a subsequent call, the remaining positional argument values "wrap
around" the argument previously specified as a keyword.
This second difference is best illustrated by example. Again using the
function ``foo6`` from above, if we specify ``b`` as a keyword
argument::
>>> p = foo6(b=2)
and then apply two positional arguments to the resulting
:py:class:`~pointfree.partial` instance, those arguments will be used
to specify ``a`` and ``c``, skipping over ``b`` because it has already
been specified:
>>> p(1,3)
(1, 2, 3)
This approach was chosen because it allows us to compose partial
applications of functions where a previous argument has been specified
as a keyword argument.
As well as functions, :py:class:`~pointfree.partial` can be applied to
methods, including class and static methods::
>>> class Foo7(object):
... m = 2
...
... def __init__(self, n):
... self.n = n
...
... @partial
... def bar_inst(self, a, b, c):
... return self.m + self.n + a + b + c
...
... @partial
... @classmethod
... def bar_class(klass, a, b, c):
... return klass.m + a + b + c
...
... @partial
... @staticmethod
... def bar_static(a, b, c):
... return a + b + c
>>> f = Foo7(3)
>>> f.bar_inst(4)(5)(6)
20
>>> f.bar_class(3)(4)(5)
14
>>> f.bar_static(2)(3)(4)
9
The wrapper can also be instantiated from another
:py:class:`~pointfree.partial` instance::
>>> def foo8(a, b, c, *args):
... return a + b + c + sum(args)
>>> p = partial(foo8, 1)
>>> q = partial(p, 2)
>>> q(3)
6
Or even from a :py:func:`functools.partial` instance:
>>> p = functools.partial(foo8, 1)
>>> q = partial(p)
>>> q(2)(3)
6
However, it cannot currently wrap a Python builtin function (or a
:py:func:`functools.partial` instance which wraps a builtin function),
as Python does not currently provide sufficient reflection for its
builtins.
While you will probably apply :py:class:`~pointfree.partial` as a
decorator when defining your own functions, you can also wrap existing
functions by instantiating the class directly::
>>> partial(foo8)(1)(2)(3)
6
Or like with :py:func:`functools.partial`, you can specify arguments
for the wrapped function when you instantiate a wrapper:
>>> p = partial(foo8, 1)
>>> p(2)(3)
6
But unlike calling an existing wrapper instance, the wrapped function
will not be invoked during instantiation even if enough arguments are
supplied in order to do so; invocation does not occur until the
:py:class:`~pointfree.partial` instance is called at least once, even
with an empty argument list:
>>> p = partial(foo8, 1, 2, 3)
>>> type(p)
<class 'pointfree.partial'>
>>> p()
6
>>> p(4)
10
"""
def __init__(self, func, *pargs, **kargs):
self.func = func
self.argv = {}
self.extra_argv = []
self.__call_error = None
if isinstance(func, partial):
self.func = func.func
functools.update_wrapper(self, self.func)
inst = func
self.argv = inst.argv
self.extra_argv = inst.extra_argv
self.__sig_from_partial(inst)
elif isinstance(func, functools.partial):
self.func = func.func
functools.update_wrapper(self, self.func)
self.__sig_from_func(self.func)
partial_args = func.args or ()
partial_keywords = func.keywords or {}
self.__update_argv(*partial_args, **partial_keywords)
elif isinstance(func, classmethod) or isinstance(func, staticmethod):
self.__call_error = "'%s' object is not callable" % type(func).__name__
else:
functools.update_wrapper(self, func)
self.__sig_from_func(func)
self.__update_argv(*pargs, **kargs)
def __sig_from_func(self, func):
"""Extract function signature, default arguments, keyword-only
arguments, and whether or not variable positional or keyword
arguments are allowed. This also supports calling unbound instance
methods by passing an object instance as the first argument;
however, unbound classmethod and staticmethod objects are not
callable, so we do not attempt to support them here."""
if isinstance(func, types.MethodType):
# A bound instance or class method.
argspec = getfullargspec(func.__func__)
self.pargl = argspec[0][1:]
else:
# A regular function, an unbound instance method, or a
# bound static method.
argspec = getfullargspec(func)
self.pargl = argspec[0][:]
if argspec[3] is not None:
def_offset = len(self.pargl) - len(argspec[3])
self.def_argv = dict((self.pargl[def_offset+i],argspec[3][i]) \
for i in range(len(argspec[3])))
else:
self.def_argv = {}
self.var_pargs = argspec[1] is not None
self.var_kargs = argspec[2] is not None
self.kargl = argspec[4]
# We need keyword-only arguments' default values too.
if argspec[5] is not None:
self.def_argv.update(argspec[5])
def __sig_from_partial(self, inst):
"""Extract function signature from an existing partial instance."""
self.pargl = list(inst.pargl)
self.kargl = list(inst.kargl)
self.def_argv = inst.def_argv.copy()
self.var_pargs = inst.var_pargs
self.var_kargs = inst.var_kargs
@classmethod
def make_copy(klass, inst, func=None, argv=None, extra_argv=None, copy_sig=True):
"""Makes a new instance of the partial application wrapper based on
an existing instance, optionally overriding the original's wrapped
function and/or saved arguments.
:param inst: The partial instance we're copying
:param func: Override the original's wrapped function
:param argv: Override saved argument values
:param extra_argv: Override saved extra positional arguments
:param copy_sig: Copy original's signature?
:rtype: New partial wrapper instance
"""
dest = klass(func or inst.func)
dest.argv = (argv or inst.argv).copy()
dest.extra_argv = list(extra_argv if extra_argv else inst.extra_argv)
if copy_sig:
dest.__sig_from_partial(inst)
return dest
def __get__(self, inst, owner=None):
return self.make_copy(self, func=self.func.__get__(inst, owner), copy_sig=False)
def __new_argv(self, *new_pargs, **new_kargs):
"""Calculate new argv and extra_argv values resulting from adding
the specified positional and keyword arguments."""
new_argv = self.argv.copy()
new_extra_argv = list(self.extra_argv)
for v in new_pargs:
arg_name = None
for name in self.pargl:
if not name in new_argv:
arg_name = name
break
if arg_name:
new_argv[arg_name] = v
elif self.var_pargs:
new_extra_argv.append(v)
else:
num_prev_pargs = len([name for name in self.pargl if name in self.argv])
raise TypeError("%s() takes exactly %d positional arguments (%d given)" \
% (self.__name__,
len(self.pargl),
num_prev_pargs + len(new_pargs)))
for k,v in new_kargs.items():
if not (self.var_kargs or (k in self.pargl) or (k in self.kargl)):
raise TypeError("%s() got an unexpected keyword argument '%s'" \
% (self.__name__, k))
new_argv[k] = v
return (new_argv, new_extra_argv)
def __update_argv(self, *pargs, **kargs):
self.argv, self.extra_argv = self.__new_argv(*pargs, **kargs)
def __call__(self, *new_pargs, **new_kargs):
if self.__call_error:
raise TypeError(self.__call_error)
new_argv, extra_argv = self.__new_argv(*new_pargs, **new_kargs)
applic_argv = self.def_argv.copy()
applic_argv.update(new_argv)
applic_ready = True
for name in self.pargl:
if not name in applic_argv:
applic_ready = False
break
if applic_ready:
for name in self.kargl:
if not name in applic_argv:
applic_ready = False
break
if applic_ready:
fpargs = [new_argv[n] for n in self.pargl if n in new_argv] + extra_argv
fkargs = dict((n,v) for n,v in new_argv.items() if not n in self.pargl)
return self.func(*fpargs, **fkargs)
else:
return self.make_copy(self, argv=new_argv)
class pointfree(partial):
"""Wraps a regular Python function or method into a callable object
supporting the ``>>`` and ``*`` function composition operators, as well
as automatic partial application inherited from
:py:class:`~pointfree.partial`.
:param func: Function or method to wrap
:param pargs: Optional, positional arguments for the wrapped function
:param kargs: Optional, keyword arguments for the wrapped function
This class inherits its partial application behavior from
:py:class:`~pointfree.partial`; refer to its documentation for details.
On top of automatic partial application, the
:py:class:`~pointfree.pointfree` wrapper adds two function composition
operators, ``>>`` and ``*``, for "forward" and "reverse" function
composition respectively. For example, given the following wrapped
functions::
>>> @pointfree
... def pfadd(a, b):
... return a + b
>>> @pointfree
... def pfmul(a, b):
... return a * b
The following forward composition defines the function ``f()`` as one
which takes a given number, adds one to it, and then multiplies the
result of the addition by two::
>>> f = pfadd(1) >> pfmul(2)
>>> f(1)
4
Reverse composition simply works in the opposite direction. In this
example, ``g()`` takes a number, multiplies it by three, and then adds
four::
>>> g = pfadd(4) * pfmul(3)
>>> g(5)
19
The alias ``pf`` is provided for :py:class:`~pointfree.pointfree` to
conserve electrons when wrapping functions inline::
>>> def add(a, b):
... return a + b
>>> def mul(a, b):
... return a * b
>>> f = pf(add, 1) >> pf(mul, 2)
>>> f(2)
6
When using :py:class:`~pointfree.pointfree` as a decorator on class or
static methods, you must ensure that it is the "topmost" decorator, so
that the resulting object is a :py:class:`~pointfree.pointfree`
instance in order for the composition operators to work.
"""
def __mul__(self, g):
return self.make_copy(g, func=lambda *p,**k: self(g.func(*p,**k)))
def __rshift__(self, g):
return self.make_copy(self, func=lambda *p,**k: g(self.func(*p,**k)))
# Shorthand pointfree notation
pf = pointfree
@pointfree
def pfmap(func, iterable):
"""A pointfree map function: Returns an iterator over the results of
applying a function of one argument to the items of a given iterable.
The function is provided "lazily" to the given iterable; each function
application is performed on the fly as it is requested.
:param func: A function of one argument to apply to each item
:param iterable: An iterator yielding input for the function
:rtype: Iterator of function application results
Example::
>>> f = pfmap(lambda x: x+1) \\
... >> pfmap(lambda x: x*2) \\
... >> pfcollect
>>> f(range(5))
[2, 4, 6, 8, 10]
"""
for item in iterable:
yield func(item)
@pointfree
def pfreduce(func, iterable, initial=None):
"""A pointfree reduce / left fold function: Applies a function of two
arguments cumulatively to the items supplied by the given iterable, so
as to reduce the iterable to a single value. If an initial value is
supplied, it is placed before the items from the iterable in the
calculation, and serves as the default when the iterable is empty.
:param func: A function of two arguments
:param iterable: An iterable yielding input for the function
:param initial: An optional initial input for the function
:rtype: Single value
Example::
>>> from operator import add
>>> sum_of_squares = pfreduce(add, initial=0) * pfmap(lambda n: n**2)
>>> sum_of_squares([3, 4, 5, 6])
86
"""
iterator = iter(iterable)
try:
first_item = next(iterator)
if initial:
value = func(initial, first_item)
else:
value = first_item
except StopIteration:
return initial
for item in iterator:
value = func(value, item)
return value
@pointfree
def pffilter(pred, iterable):
"""Pointfree filter function.
Example::
>>> f = pffilter(lambda n: n % 2 == 0) \\
... >> pfcollect
>>> f(range(5))
[0, 2, 4]
"""
for item in iterable:
if pred(item): yield item
@pointfree
def pfcollect(iterable, n=None):
"""Collects and returns a list of values from the given iterable. If
the n parameter is not specified, collects all values from the
iterable.
:param iterable: An iterable yielding values for the list
:param n: An optional maximum number of items to collect
:rtype: List of values from the iterable
Example::
>>> @pointfree
... def fibonaccis():
... a, b = 0, 1
... while True:
... a, b = b, a+b
... yield a
>>> (pfcollect(n=10) * fibonaccis)()
[1, 1, 2, 3, 5, 8, 13, 21, 34, 55]
"""
if n:
return list(itertools.islice(iterable, n))
else:
return list(iterable)
@pointfree
def pfprint(item, end='\n', file=None):
"""Prints an item.
:param item: The item to print
:param end: String to append to the end of printed output
:param file: File to which output is printed
:rtype: None
Example::
>>> from operator import add
>>> fn = pfreduce(add, initial=0) >> pfprint
>>> fn([1, 2, 3, 4])
10
"""
# Can't just make sys.stdout the file argument's default value, because
# then we would be capturing the stdout file descriptor, and then
# doctest -- which works by redefining sys.stdout -- would fail:
if file is None:
file = sys.stdout
print(item, end=end, file=file)
@pointfree
def pfprint_all(iterable, end='\n', file=None):
"""Prints each item from an iterable.
:param iterable: An iterable yielding values to print
:param end: String to append to the end of printed output
:param file: File to which output is printed
:rtype: None
Example::
>>> @pointfree
... def prefix_all(prefix, iterable):
... for item in iterable:
... yield "%s%s" % (prefix, item)
>>> fn = prefix_all("An item: ") >> pfprint_all
>>> fn(["foo", "bar", "baz"])
An item: foo
An item: bar
An item: baz
"""
for item in iterable:
pfprint(item, end=end, file=file)
@pointfree
def pfignore_all(iterator):
"""Consumes all the items from an iterable, discarding their output.
This may be useful if evaluating the iterable produces some desirable
side-effect, but you have no need to collect its output.
:param iterable: An iterable
:rtype: None
Example::
>>> result = []
>>> @pointfree
... def append_all(collector, iterable):
... for item in iterable:
... collector.append(item)
... yield item
>>> @pointfree
... def square_all(iterable):
... for item in iterable:
... yield item**2
>>> fn = square_all \\
... >> append_all(result) \\
... >> pfignore_all
>>> fn([1, 2, 3, 4])
>>> result
[1, 4, 9, 16]
"""
for item in iterator:
pass