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 also provide an in-process, background REPL server for live inspection and hot-patching. The emphasis is on clear, pythonic syntax, making features work together, and obsessive correctness.
Some hypertext features of this README, such as local links to detailed documentation, and expandable example highlights, are not supported when viewed on PyPI; view on GitHub to have those work properly.
Dependencies
None required.
mcpyrate
optional, to enable the syntactic macro layer, an interactive macro REPL, and some example dialects.
As of v0.15.3, unpythonic
runs on CPython 3.8, 3.9 and 3.10, 3.11, 3.12, and PyPy3 (language versions 3.8, 3.9, 3.10); the CI process verifies the tests pass on those platforms. New Python versions are added and old ones are removed following the Long-term support roadmap.
Documentation
The features of unpythonic
are built out of, in increasing order of magic:
- Pure Python (e.g. batteries for
itertools
), - Macros driving a pure-Python core (
do
, let
), - Pure macros (e.g.
continuations
, lazify
, dbg
). - Whole-module transformations, a.k.a. dialects (e.g.
Lispy
).
This depends on the purpose of each feature, as well as ease-of-use considerations. See the design notes for more information.
Examples
Small, limited-space overview of the overall flavor. There is a lot more that does not fit here, especially in the pure-Python feature set. We give here simple examples that are not necessarily of the most general form supported by the constructs. See the full documentation and unit tests for more examples.
Unpythonic in 30 seconds: Pure Python
Loop functionally, with tail call optimization.
[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)
assert result == 45
@looped_over(range(3), acc=[])
def result(loop, i, acc):
acc.append(lambda x: i * x)
return loop()
assert [f(10) for f in result] == [0, 10, 20]
Introduce dynamic variables.
[docs]
from unpythonic import dyn, make_dynvar
make_dynvar(x=42)
def f():
assert dyn.x == 17
with dyn.let(x=23):
assert dyn.x == 23
g()
assert dyn.x == 17
def g():
assert dyn.x == 23
assert dyn.x == 42
with dyn.let(x=17):
assert dyn.x == 17
f()
assert dyn.x == 42
Interactively hot-patch your running Python program.
[docs]
To opt in, add just two lines of code to your main program:
from unpythonic.net import server
server.start(locals={})
import time
def main():
while True:
time.sleep(1)
if __name__ == '__main__':
main()
Or if you just want to take this for a test run, start the built-in demo app:
python3 -m unpythonic.net.server
Once a server is running, to connect:
python3 -m unpythonic.net.client 127.0.0.1
This gives you a REPL, inside your live process, with all the power of Python. You can importlib.reload
any module, and through sys.modules
, inspect or overwrite any name at the top level of any module. You can pickle.dump
your data. Or do anything you want with/to the live state of your app.
You can have multiple REPL sessions connected simultaneously. When your app exits (for any reason), the server automatically shuts down, closing all connections if any remain. But exiting the client leaves the server running, so you can connect again later - that's the whole point.
Optionally, if you have mcpyrate, the REPL sessions support importing, invoking and defining macros.
Industrial-strength scan and fold.
[docs]
Scan and fold accept multiple iterables, like in Racket.
from operator import add
from unpythonic import scanl, foldl, unfold, take, Values
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
def nextfibo(a, b):
return Values(a, a=b, b=a + b)
assert tuple(take(10, unfold(nextfibo, 1, 1))) == (1, 1, 2, 3, 5, 8, 13, 21, 34, 55)
Industrial-strength curry.
[docs]
We bind arguments to parameters like Python itself does, so it does not matter whether arguments are passed by position or by name during currying. We support @generic
multiple-dispatch functions.
We also feature a Haskell-inspired passthrough system: any args and kwargs that are not accepted by the call signature will be passed through. This is useful when a curried function returns a new function, which is then the target for the passthrough. See the docs for details.
from unpythonic import curry, generic, foldr, composerc, cons, nil, ll
@curry
def f(x, y):
return x, y
assert f(1, 2) == (1, 2)
assert f(1)(2) == (1, 2)
assert f(1)(y=2) == (1, 2)
assert f(y=2)(x=1) == (1, 2)
@curry
def add3(x, y, z):
return x + y + z
assert add3(1)(2)(3) == 6
assert add3(1, 2)(3) == 6
assert add3(1)(2, 3) == 6
assert add3(1, 2, 3) == 6
@curry
def lispyadd(*args):
return sum(args)
assert lispyadd() == 0
@generic
def g(x: int, y: int):
return "int"
@generic
def g(x: float, y: float):
return "float"
@generic
def g(s: str):
return "str"
g = curry(g)
assert callable(g(1))
assert g(1)(2) == "int"
assert callable(g(1.0))
assert g(1.0)(2.0) == "float"
assert g("cat") == "str"
assert g(s="cat") == "str"
mymap = lambda f: curry(foldr, composerc(cons, f), nil)
myadd = lambda a, b: a + b
assert curry(mymap, myadd, ll(1, 2, 3), ll(2, 4, 6)) == ll(3, 6, 9)
Multiple-dispatch generic functions, like in CLOS or Julia.
[docs]
from unpythonic import generic
@generic
def my_range(stop: int):
return my_range(0, 1, stop)
@generic
def my_range(start: int, stop: int):
return my_range(start, 1, stop)
@generic
def my_range(start: int, step: int, stop: int):
return start, step, stop
This is a purely run-time implementation, so it does not give performance benefits, but it can make code more readable, and makes it modular to add support for new input types (or different call signatures) to an existing function later.
Holy traits are also a possibility:
import typing
from unpythonic import generic, augment
class FunninessTrait:
pass
class IsFunny(FunninessTrait):
pass
class IsNotFunny(FunninessTrait):
pass
@generic
def funny(x: typing.Any):
raise NotImplementedError(f"`funny` trait not registered for anything matching {type(x)}")
@augment(funny)
def funny(x: str):
return IsFunny()
@augment(funny)
def funny(x: int):
return IsNotFunny()
@generic
def laugh(x: typing.Any):
return laugh(funny(x), x)
@augment(laugh)
def laugh(traitvalue: IsFunny, x: typing.Any):
return f"Ha ha ha, {x} is funny!"
@augment(laugh)
def laugh(traitvalue: IsNotFunny, x: typing.Any):
return f"{x} is not funny."
assert laugh("that") == "Ha ha ha, that is funny!"
assert laugh(42) == "42 is not funny."
Conditions: resumable, modular error handling, like in Common Lisp.
[docs]
Contrived example:
from unpythonic import error, restarts, handlers, invoke, use_value, unbox
class MyError(ValueError):
def __init__(self, value):
self.value = value
def lowlevel(lst):
_drop = object()
out = []
for k in lst:
with restarts(use_value=(lambda x: x),
halve=(lambda x: x // 2),
drop=(lambda: _drop)) as result:
if k > 9000:
error(MyError(k))
result << k
r = unbox(result)
if r is not _drop:
out.append(r)
return out
def highlevel():
with handlers((MyError, lambda c: use_value(c.value))):
assert lowlevel([17, 10000, 23, 42]) == [17, 10000, 23, 42]
with handlers((MyError, lambda c: invoke("halve", c.value))):
assert lowlevel([17, 10000, 23, 42]) == [17, 5000, 23, 42]
with handlers((MyError, lambda: invoke("drop"))):
assert lowlevel([17, 10000, 23, 42]) == [17, 23, 42]
highlevel()
Conditions only shine in larger systems, with restarts set up at multiple levels of the call stack; this example is too small to demonstrate that. The single-level case here could be implemented as a error-handling mode parameter for the example's only low-level function.
With multiple levels, it becomes apparent that this mode parameter must be threaded through the API at each level, unless it is stored as a dynamic variable (see unpythonic.dyn
). But then, there can be several types of errors, and the error-handling mode parameters - one for each error type - have to be shepherded in an intricate manner. A stack is needed, so that an inner level may temporarily override the handler for a particular error type...
The condition system is the clean, general solution to this problem. It automatically scopes handlers to their dynamic extent, and manages the handler stack automatically. In other words, it dynamically binds error-handling modes (for several types of errors, if desired) in a controlled, easily understood manner. The local programmability (i.e. the fact that a handler is not just a restart name, but an arbitrary function) is a bonus for additional flexibility.
If this sounds a lot like an exception system, that's because conditions are the supercharged sister of exceptions. The condition model cleanly separates mechanism from policy, while otherwise remaining similar to the exception model.
Lispy symbol type.
[docs]
Roughly, a symbol is a guaranteed-interned string.
A gensym is a guaranteed-unique string, which is useful as a nonce value. It's similar to the pythonic idiom nonce = object()
, but with a nice repr, and object-identity-preserving pickle support.
from unpythonic import sym
sandwich = sym("sandwich")
hamburger = sym("sandwich")
assert hamburger is sandwich
assert str(sandwich) == "sandwich"
assert repr(sandwich) == 'sym("sandwich")'
assert eval(repr(sandwich)) is sandwich
from pickle import dumps, loads
pickled_sandwich = dumps(sandwich)
unpickled_sandwich = loads(pickled_sandwich)
assert unpickled_sandwich is sandwich
from unpythonic import gensym
tabby = gensym("cat")
scottishfold = gensym("cat")
assert tabby is not scottishfold
pickled_tabby = dumps(tabby)
unpickled_tabby = loads(pickled_tabby)
assert unpickled_tabby is tabby
Lispy data structures.
[docs for box
] [docs for cons
] [docs for frozendict
]
from unpythonic import box, unbox
cat = object()
cardboardbox = box(cat)
assert cardboardbox is not cat
assert unbox(cardboardbox) is cat
assert cat in cardboardbox
dog = object()
cardboardbox << dog
assert unbox(cardboardbox) is dog
from unpythonic import cons, nil, ll, llist
lst = cons(1, cons(2, cons(3, nil)))
assert ll(1, 2, 3) == lst
assert llist([1, 2, 3]) == lst
from unpythonic import frozendict
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})
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)
assert fact(5) == 120
square = namelambda("square")(lambda x: x**2)
assert square.__name__ == "square"
assert square.__qualname__ == "square"
assert square.__code__.co_name == "square"
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
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
thunk = memoize(lambda: print("hi from thunk"))
thunk()
thunk()
@gmemoize
def primes():
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)
Functional updates.
[docs]
from itertools import repeat
from unpythonic import fup
t = (1, 2, 3, 4, 5)
s = fup(t)[0::2] << repeat(10)
assert s == (10, 2, 10, 4, 10)
assert t == (1, 2, 3, 4, 5)
from itertools import count
from unpythonic import imemoize
t = (1, 2, 3, 4, 5)
s = fup(t)[::-2] << imemoize(count(start=10))()
assert s == (12, 2, 11, 4, 10)
assert t == (1, 2, 3, 4, 5)
Live list slices.
[docs]
from unpythonic import view
lst = list(range(10))
v = view(lst)[::2]
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]
Pipes: method chaining syntax for regular functions.
[docs]
from unpythonic import piped, exitpipe
double = lambda x: 2 * x
inc = lambda x: x + 1
x = piped(42) | double | inc | exitpipe
assert x == 85
The point is usability: in a function composition using pipe syntax, data flows from left to right.
Unpythonic in 30 seconds: Language extensions with macros
unpythonic.test.fixtures: a minimalistic test framework for macro-enabled Python.
[docs]
from unpythonic.syntax import macros, test, test_raises, fail, error, warn, the
from unpythonic.test.fixtures import session, testset, terminate, returns_normally
def f():
raise RuntimeError("argh!")
def g(a, b):
return a * b
fail["this line should be unreachable"]
count = 0
def counter():
global count
count += 1
return count
with session("simple framework demo"):
with testset():
test[2 + 2 == 4]
test_raises[RuntimeError, f()]
test[returns_normally(g(2, 3))]
test[g(2, 3) == 6]
test[the[counter()] < the[counter()]]
with testset("outer"):
with testset("inner 1"):
test[g(6, 7) == 42]
with testset("inner 2"):
test[None is None]
with testset("inner 3"):
pass
with testset("inner 4"):
warn["This testset not implemented yet"]
with testset("integration"):
try:
import blargly
except ImportError:
error["blargly not installed, cannot test integration with it."]
else:
...
with testset(postproc=terminate):
test[2 * 2 == 5]
test[2 * 2 == 4]
We provide the low-level syntactic constructs test[]
, test_raises[]
and test_signals[]
, with the usual meanings. The last one is for testing code that uses conditions and restarts; see unpythonic.conditions
.
The test macros also come in block variants, with test
, with test_raises
, with test_signals
.
As usual in test frameworks, the testing constructs behave somewhat like assert
, with the difference that a failure or error will not abort the whole unit (unless explicitly asked to do so).
let: 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,
c := 2 * c,
c := 2 * c] in
c]
z = letrec[[evenp := (lambda x: (x == 0) or oddp(x - 1)),
oddp := (lambda x: (x != 0) and evenp(x - 1))]
in evenp(42)]
let-over-lambda: stateful functions.
[docs]
from unpythonic.syntax import macros, dlet
@dlet[x := 0]
def count():
return x := x + 1
assert count() == 1
assert count() == 2
do: code imperatively in any expression position.
[docs]
from unpythonic.syntax import macros, do, local, delete
x = do[local[a := 21],
local[b := 2 * a],
print(b),
delete[b],
4 * a]
assert x == 84
Automatically apply tail call optimization (TCO), à la Scheme/Racket.
[docs]
from unpythonic.syntax import macros, tco
with tco:
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, autocurry
from unpythonic import foldr, composerc as compose, cons, nil, ll
with autocurry:
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)
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
else:
return b
assert my_if(True, 23, 1/0) == 23
assert my_if(False, 1/0, 42) == 42
Genuine multi-shot continuations (call/cc).
[docs]
from unpythonic.syntax import macros, continuations, call_cc
with continuations:
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()
def pt():
z = call_cc[amb(range(1, 21))]
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()
Unpythonic in 30 seconds: Language extensions with dialects
The dialects subsystem of mcpyrate
makes Python into a language platform, à la Racket. We provide some example dialects based on unpythonic
's macro layer. See documentation.
Lispython: automatic TCO and an implicit return statement.
[docs]
Also comes with automatically named, multi-expression lambdas.
from unpythonic.dialects import dialects, Lispython
def factorial(n):
def f(k, acc):
if k == 1:
return acc
f(k - 1, k * acc)
f(n, acc=1)
assert factorial(4) == 24
factorial(5000)
square = lambda x: x**2
assert square(3) == 9
assert square.__name__ == "square"
g = lambda x: [local[y := 2 * x],
y + 1]
assert g(10) == 21
Pytkell: Automatic currying and implicitly lazy functions.
[docs]
from unpythonic.dialects import dialects, Pytkell
from operator import add, mul
def addfirst2(a, b, c):
return a + b
assert addfirst2(1)(2)(1 / 0) == 3
assert tuple(scanl(add, 0, (1, 2, 3))) == (0, 1, 3, 6)
assert tuple(scanr(add, 0, (1, 2, 3))) == (0, 3, 5, 6)
my_sum = foldl(add, 0)
my_prod = foldl(mul, 1)
my_map = lambda f: foldr(compose(cons, f), nil)
assert my_sum(range(1, 5)) == 10
assert my_prod(range(1, 5)) == 24
double = lambda x: 2 * x
assert my_map(double, (1, 2, 3)) == ll(2, 4, 6)
Listhell: Prefix syntax for function calls, and automatic currying.
[docs]
from unpythonic.dialects import dialects, Listhell
from operator import add, mul
from unpythonic import foldl, foldr, cons, nil, ll
(print, "hello from Listhell")
my_sum = (foldl, add, 0)
my_prod = (foldl, mul, 1)
my_map = lambda f: (foldr, (compose, cons, f), nil)
assert (my_sum, (range, 1, 5)) == 10
assert (my_prod, (range, 1, 5)) == 24
double = lambda x: 2 * x
assert (my_map, double, (q, 1, 2, 3)) == (ll, 2, 4, 6)
Installation
PyPI
pip install unpythonic
GitHub
Clone the repo from GitHub. Then, navigate to it in a terminal, and:
pip install .
To uninstall:
pip uninstall unpythonic
Support
Not working as advertised? Missing a feature? Documentation needs improvement?
In case of a problem, see Troubleshooting first. Then:
Issue reports and pull requests are welcome. Contribution guidelines.
While unpythonic
is intended as a serious tool for improving productivity as well as for teaching, right now my work priorities mean that it's developed and maintained on whatever time I can spare for it. Thus getting a response may take a while, depending on which project I happen to be working on.
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 early feedback.
Relevant reading
Links to blog posts, online articles and papers on topics relevant in the context of unpythonic
have been collected to a separate document.
If you like both FP and numerics, we have some examples based on various internet sources.