Skip to content

Technologicat/unpythonic

Repository files navigation

Unpythonic: Python meets Lisp and Haskell

In the spirit of toolz, we provide missing features for Python, mainly from the list processing tradition, but with some Haskellisms mixed in. We extend the language with a set of syntactic macros. We emphasize clear, pythonic syntax, and making features work together.

The features are built out of, in increasing order of magic:

  • Pure Python (e.g. batteries for itertools),
  • Macros driving a pure-Python core (e.g. do, let),
  • Pure macros (e.g. continuations, lazify, dbg).

This depends on the purpose of each feature, as well as ease-of-use considerations. See our design notes for more information.

Dependencies

None required.
MacroPy optional, to enable the syntactic macro layer.

Documentation

Pure-Python feature set
Syntactic macro feature set
Design notes: for more insight into the design choices of unpythonic.

Examples

Small, limited-space overview of the overall flavor. There's a lot more that doesn't fit here, especially in the pure-Python feature set. See the full documentation and unit tests for more.

Click each example to expand.

Unpythonic in 30 seconds: Pure Python

Scan, fold and unfold like a boss.

[docs]

from operator import add
from unpythonic import scanl, foldl, unfold, take

assert tuple(scanl(add, 0, range(1, 5))) == (0, 1, 3, 6, 10)

def op(e1, e2, acc):
    return acc + e1 * e2
assert foldl(op, 0, (1, 2), (3, 4)) == 11  # we accept multiple input sequences, like Racket

def nextfibo(a, b):       # *oldstates
    return (a, b, a + b)  # value, *newstates
assert tuple(take(10, unfold(nextfibo, 1, 1))) == (1, 1, 2, 3, 5, 8, 13, 21, 34, 55)
Experience resumable, modular error handling, a.k.a. Common Lisp style conditions.

[docs]

from unpythonic import error, restarts, handlers, invoke, use_value, unbox

class MyError(ValueError):
    def __init__(self, value):  # We want to act on the value, so save it.
        self.value = value

def lowlevel(lst):
    _drop = object()  # gensym/nonce
    out = []
    for k in lst:
        # Provide several different error recovery strategies.
        with restarts(use_value=(lambda x: x),
                      halve=(lambda x: x // 2),
                      drop=(lambda: _drop)) as result:
            if k > 9000:
                error(MyError(k))
            # This is reached when no error occurs.
            # `result` is a box, send k into it.
            result << k
        # Now the result box contains either k,
        # or the return value of one of the restarts. 
        r = unbox(result)  # get the value from the box
        if r is not _drop:
            out.append(r)
    return out

def highlevel():
    # Choose which error recovery strategy to use...
    with handlers((MyError, lambda c: use_value(c.value))):
        assert lowlevel([17, 10000, 23, 42]) == [17, 10000, 23, 42]

    # ...on a per-use-site basis...
    with handlers((MyError, lambda c: invoke("halve", c.value))):
        assert lowlevel([17, 10000, 23, 42]) == [17, 5000, 23, 42]

    # ...without changing the low-level code.
    with handlers((MyError, lambda: invoke("drop"))):
        assert lowlevel([17, 10000, 23, 42]) == [17, 23, 42]

highlevel()
Loop functionally.

[docs]

from unpythonic import looped, looped_over

@looped
def result(loop, acc=0, i=0):
    if i == 10:
        return acc
    else:
        return loop(acc + i, i + 1)  # tail call optimized, no call stack blowup.
assert result == 45

@looped_over(range(3), acc=[])
def result(loop, i, acc):
    acc.append(lambda x: i * x)  # fresh "i" each time, no mutation of loop counter.
    return loop()
assert [f(10) for f in result] == [0, 10, 20]
Allow a lambda to call itself. Name a lambda.

[docs for withself] [docs for namelambda]

from unpythonic import withself, namelambda

fact = withself(lambda self, n: n * self(n - 1) if n > 1 else 1)  # see @trampolined to do this with TCO
assert fact(5) == 120

square = namelambda("square")(lambda x: x**2)
assert square.__name__ == "square"
assert square.__qualname__ == "square"  # or e.g. "somefunc.<locals>.square" if inside a function
assert square.__code__.co_name == "square"  # used by stack traces
Break infinite recursion cycles.

[docs]

from typing import NoReturn
from unpythonic import fix

@fix()
def a(k):
    return b((k + 1) % 3)
@fix()
def b(k):
    return a((k + 1) % 3)
assert a(0) is NoReturn
Build number sequences by example. Slice general iterables.

[docs for s] [docs for islice]

from unpythonic import s, islice

seq = s(1, 2, 4, ...)
assert tuple(islice(seq)[:10]) == (1, 2, 4, 8, 16, 32, 64, 128, 256, 512)
Memoize functions and generators.

[docs for memoize] [docs for gmemoize]

from itertools import count, takewhile
from unpythonic import memoize, gmemoize, islice

ncalls = 0
@memoize  # <-- important part
def square(x):
    global ncalls
    ncalls += 1
    return x**2
assert square(2) == 4
assert ncalls == 1
assert square(3) == 9
assert ncalls == 2
assert square(3) == 9
assert ncalls == 2  # called only once for each unique set of arguments

# "memoize lambda": classic evaluate-at-most-once thunk
thunk = memoize(lambda: print("hi from thunk"))
thunk()  # the message is printed only the first time
thunk()

@gmemoize  # <-- important part
def primes():  # FP sieve of Eratosthenes
    yield 2
    for n in count(start=3, step=2):
        if not any(n % p == 0 for p in takewhile(lambda x: x*x <= n, primes())):
            yield n

assert tuple(islice(primes())[:10]) == (2, 3, 5, 7, 11, 13, 17, 19, 23, 29)
Make functional updates.

[docs]

from itertools import repeat
from unpythonic import fup

t = (1, 2, 3, 4, 5)
s = fup(t)[0::2] << tuple(repeat(10, 3))
assert s == (10, 2, 10, 4, 10)
assert t == (1, 2, 3, 4, 5)
Use lispy data structures.

[docs for box] [docs for cons] [docs for frozendict]

from unpythonic import box, unbox  # mutable single-item container
cat = object()
b = box(cat)
assert b is not cat  # the box is not the cat
assert unbox(b) is cat  # but when you look inside the box, you find the cat
dog = object()
b << dog  # let's replace the contents of the box
assert unbox(b) is dog

from unpythonic import cons, nil, ll, llist  # lispy linked lists
lst = cons(1, cons(2, cons(3, nil)))
assert ll(1, 2, 3) == lst  # make linked list out of elements
assert llist([1, 2, 3]) == lst  # convert iterable to linked list

from unpythonic import frozendict  # immutable dictionary
d1 = frozendict({'a': 1, 'b': 2})
d2 = frozendict(d1, c=3, a=4)
assert d1 == frozendict({'a': 1, 'b': 2})
assert d2 == frozendict({'a': 4, 'b': 2, 'c': 3})
View list slices writably, re-slicably.

[docs]

from unpythonic import view

lst = list(range(10))
v = view(lst)[::2]  # [0, 2, 4, 6, 8]
v[2:4] = (10, 20)
assert lst == [0, 1, 2, 3, 10, 5, 20, 7, 8, 9]

lst[2] = 42
assert v == [0, 42, 10, 20, 8]
Focus on data flow in function composition.

[docs]

from unpythonic import piped, getvalue

double = lambda x: 2 * x
inc    = lambda x: x + 1
x = piped(42) | double | inc | getvalue
assert x == 85

Unpythonic in 30 seconds: Language extensions with macros

Introduce expression-local variables.

[docs]

from unpythonic.syntax import macros, let, letseq, letrec

x = let[((a, 1), (b, 2)) in a + b]
y = letseq[((c, 1),  # LET SEQuential, like Scheme's let*
            (c, 2 * c),
            (c, 2 * c)) in
           c]
z = letrec[((evenp, lambda x: (x == 0) or oddp(x - 1)),  # LET mutually RECursive, like in Scheme
            (oddp,  lambda x: (x != 0) and evenp(x - 1)))
           in evenp(42)]
Introduce stateful functions.

[docs]

from unpythonic.syntax import macros, dlet

@dlet((x, 0))  # let-over-lambda for Python
def count():
    return x << x + 1  # `name << value` rebinds in the let env
assert count() == 1
assert count() == 2
Code imperatively in an expression.

[docs]

from unpythonic.syntax import macros, do, local, delete

x = do[local[a << 21],
       local[b << 2 * a],
       print(b),
       delete[b],  # do[] local variables can be deleted, too
       4 * a]
assert x == 84
Apply tail call optimization (TCO) automatically.

[docs]

from unpythonic.syntax import macros, tco

with tco:
    # expressions are automatically analyzed to detect tail position, too.
    evenp = lambda x: (x == 0) or oddp(x - 1)
    oddp  = lambda x: (x != 0) and evenp(x - 1)
    assert evenp(10000) is True
Curry automatically, à la Haskell.

[docs]

from unpythonic.syntax import macros, curry
from unpythonic import foldr, composerc as compose, cons, nil, ll

with curry:
    def add3(a, b, c):
        return a + b + c
    assert add3(1)(2)(3) == 6

    mymap = lambda f: foldr(compose(cons, f), nil)
    double = lambda x: 2 * x
    assert mymap(double, (1, 2, 3)) == ll(2, 4, 6)
Make lazy functions, a.k.a. call-by-need.

[docs]

from unpythonic.syntax import macros, lazify

with lazify:
    def my_if(p, a, b):
        if p:
            return a  # b never evaluated in this code path
        else:
            return b  # a never evaluated in this code path
    assert my_if(True, 23, 1/0) == 23
    assert my_if(False, 1/0, 42) == 42
Capture and use continuations (call/cc).

[docs]

from unpythonic.syntax import macros, continuations, call_cc

with continuations:  # automatically enables also TCO
    # McCarthy's amb() operator
    stack = []
    def amb(lst, cc):
        if not lst:
            return fail()
        first, *rest = tuple(lst)
        if rest:
            remaining_part_of_computation = cc
            stack.append(lambda: amb(rest, cc=remaining_part_of_computation))
        return first
    def fail():
        if stack:
            f = stack.pop()
            return f()

    # Pythagorean triples using amb()
    def pt():
        z = call_cc[amb(range(1, 21))]  # capture continuation, auto-populate cc arg
        y = call_cc[amb(range(1, z+1)))]
        x = call_cc[amb(range(1, y+1))]
        if x*x + y*y != z*z:
            return fail()
        return x, y, z
    t = pt()
    while t:
        print(t)
        t = fail()  # note pt() has already returned when we call this.

Installation

PyPI

pip3 install unpythonic --user

or

sudo pip3 install unpythonic

GitHub

Clone (or pull) from GitHub. Then,

python3 setup.py install --user

or

sudo python3 setup.py install

Uninstall

Uninstallation must be invoked in a folder which has no subfolder called unpythonic, so that pip recognizes it as a package name (instead of a filename). Then,

pip3 uninstall unpythonic

or

sudo pip3 uninstall unpythonic

License

All original code is released under the 2-clause BSD license.

For sources and licenses of fragments originally seen on the internet, see AUTHORS.

Acknowledgements

Thanks to TUT for letting me teach RAK-19006 in spring term 2018; early versions of parts of this library were originally developed as teaching examples for that course. Thanks to @AgenttiX for feedback.

The trampoline implementation of unpythonic.tco takes its remarkably clean and simple approach from recur.tco in fn.py. Our main improvements are a cleaner syntax for the client code, and the addition of the FP looping constructs.

Another important source of inspiration was tco by Thomas Baruchel, for thinking about the possibilities of TCO in Python.

Python-related FP resources

Python clearly wants to be an impure-FP language. A decorator with arguments is a curried closure - how much more FP can you get?

Old, but interesting: