The story behind the speLL
speLL stands for Single Pipeline L-chain, a streamlined approach to chaining fastcore’s L
operations that ensure your data transformations are clean, readable, and maintainable (L
is a powerful list-like container). The idea for the speLL
emerged when I was practicing coding with AI to tackle Advent of Code (AoC) puzzles. Initially, I found myself writing code snippets like:
map(f_a).filter(f_b))).map(f_d)[0] L(f_c(L(...).
Although this worked, it was cumbersome to read, and the transformation order (f_a → filter(f_b) → f_c → f_d
) was unclear, forcing you to break the pipeline for the sake of readability introducing intermediate variables.
= L(...).map(f_a).filter(f_b)
t map(f_d)[0] L(f_c(t)).
Wanting a cleaner syntax, I introduced the pipe
operator so I could write:
map(f_a).filter(f_b).pipe(f_c).map(f_d)[0] L(...).
This was an improvement, but debugging remained frustrating. To see intermediate outputs, I’d comment out entire chunks of the chain:
map(f_a).filter(f_b)#.pipe(f_c).map(f_d)[0] # This will inspect up to f_b L(...).
Then, I realized Python’s parentheses allow multiline chaining without extra syntactic fuss:
# input
(L(...) map(f_a) # apply f_a
.filter(f_b) # filter by f_b
.# apply f_c
.pipe(f_c) map(f_d)[0] # final step
. )
From there, I introduced the tee
to document and inspect any step in the chain, making these annotations persistent rather than transient, as comments often are:
'input data')
(L(...) .tee(map(f_a) .tee('apply f_a to individual elements')
.filter(f_b) .tee('filter by f_b')
.'apply f_c to the whole list')
.pipe(f_c) .tee(map(f_d)[0] .tee('finally apply f_d to the resulting elements and returning first value')
. )
Finally, I added unwrap
to safely and cleanly exit the pipeline. With these combined, I successfully solved multiple AoC puzzles in a “single line of code,” which I call a speLL
.
*NOTE: Despite being spread across multiple lines using the (parentheses)
trick, a speLL
is still a one-statement. Of course, I’m not counting the definitions and tests of support functions ;-)
The speLL
approach is designed not only for exploratory programming (i.e., enabling simple code modification, fluently laying out code from start to end of a pipeline, etc.) but also facilitates production-ready code that can be organized to be inspected live when needed (ie: with verbose=True
):
def process(data, verbose=False):
= dict(show=verbose)
s
# Customize state representation
def display_as_table ...
def print_and_write_to_disk ...
def display_as_image ...
return (L(...)
map(parse_line) .tee('f_a: apply to individual elements', f=display_as_table, **s)
.filter(is_valid) .tee('f_b: filter valid ones', f=print_and_write_to_disk, **s)
.'f_c: summarize the whole list', f=display_as_image, **s)
.pipe(summarize) .tee(map(post_process) .tee('f_d: post processing', **s)
.
.unwrap() )
The speLL
is not about code-golf ⛳️. Everything, including the L
additions, emerged from tackling real problems (after all, we all want to help the AoC Elves save the day 😉) and from the need to reduce boilerplate while organizing code in a clear, linear, and understandable way.
The challenge and spellops
library
I challenge you to try a different way of solving AoC puzzles - the speLL
game. A speLL
is a one-liner that harnesses the power of L
operations (the LL
is there as a reminder!) to summon the solution of a puzzle step in a clean, concise, and readable way, like a real Wizard ;-)
Let me share some examples and helpful tools to get you started:
# SPOILER ALERT: AoC 2024/1 part A
= '''3 4
sample 4 3
2 5
1 3
3 9
3 3
'''
# The speLL
= lambda a,b: (int(a),int(b)) # named lambda
to_int_tuple def distance(a,b): return abs(a-b) # extract function
(L(sample.splitlines())map(str.split) # Split individual strings into two sub strings
.# Make an int out of string
.starmap(to_int_tuple) zip(cycled=True) # Same as zip*
.map(sorted) # Sorting individual lists
.zip() # Make a list of tuples from tuple of lists
.# Compute distance for each tuple
.starmap(distance) sum() # Summing distances
.
)
# RETURN: 11
Extract functions or name lambdas if they lead to important part of your solution, and test them independently: you’ll be surprised when it will works at first shot. I strongly recommend structuring the code this way, wrapping the (speLL)
in parentheses. This will allow you to experiment with and debug intermediate steps more easily while keeping the code organized:
(L(sample.splitlines())map(str.split) # Split individual strings into two sub strings
.# Make an int out of string
.starmap(to_int_tuple) zip(cycled=True) # Same as zip*
.map(sorted) # Sorting individual lists
.#.zip() # Make a list of tuples from tuple of lists
#.starmap(distance) # Compute distance for each tuple
#.sum() # Summing distances
)
# RETURN: (#2) [[1, 2, 3, 3, 3, 4],[3, 3, 3, 4, 5, 9]]
To make the speLL
game possible and more enjoyable, I’ve created spellops
, a small library that contains some fastcore.foundation.L
additions:
Install from pip:
pip install spellops
This is the source code for the main contributions:
# spellops: a new set of fastcore L operators for creating speLL
from fastcore.foundation import L,patch
@patch
def pipe(self:L, f, wrap=True): return self._new(f(self)) if wrap else f(self)
@patch
def starpipe(self:L, f, wrap=True): return self._new(f(*self)) if wrap else f(*self)
@patch
def unwrap(self:L):
if len(self)==0: return None # nothing to return
if len(self)!=1: raise ValueError("Can't unwrap list with more than one element")
return self[0]
def _default_print(x, msg=None, sep='\n', **kwargs): print(f"{msg+sep if msg else ''}{x}")
@patch
def tee(self:L, msg=None, f=_default_print, show=True, **kwargs):
if show: f(self, **kwargs) if msg is None else f(self, msg, **kwargs)
return self
Main operators:
pipe(f)
: Transform the entire L using function f, staying in L-world (e.g.,L([1,2,3]).pipe(sum) # L([6])
)starpipe(f)
: Like pipe but unpacks L as arguments (e.g.,L([{1,2},{2,3}]).starpipe(set.intersection) # L([2])
)unwrap()
: Get single result or None, error if multiple elements (e.g.,L([42]).unwrap() # 42
)tee(msg)
: Debug/log current L state and continue chain (e.g.,L([1,2]).tee("values") # prints and returns L([1,2])
)
This is a speLL
using the new tee
operator to document code and results:
# SPOILER ALERT: AoC 2024/1 part A
(L(sample.splitlines())map(str.split) .tee('Split individual strings into two sub strings')
.'Make an int out of string')
.starmap(to_int_tuple) .tee(zip(cycled=True) .tee('Same as zip*')
.map(sorted) .tee('Sorting individual lists')
.zip() .tee('Make a list of tuples from tuple of lists')
.'Compute distance for each tuple')
.starmap(distance) .tee(sum() # Summing distances
. )
Split individual strings into two sub strings
[['3', '4'], ['4', '3'], ['2', '5'], ['1', '3'], ['3', '9'], ['3', '3']]
Make an int out of string
[(3, 4), (4, 3), (2, 5), (1, 3), (3, 9), (3, 3)]
Same as zip*
[(3, 4, 2, 1, 3, 3), (4, 3, 5, 3, 9, 3)]
Sorting individual lists
[[1, 2, 3, 3, 3, 4], [3, 3, 3, 4, 5, 9]]
Make a list of tuples from tuple of lists
[(1, 3), (2, 3), (3, 3), (3, 4), (3, 5), (4, 9)]
Compute distance for each tuple
[2, 1, 0, 1, 2, 5]
RETURN: 11
Here we use the new starpipe
operator to apply a custom function instead of breaking the pipeline with a temporary variable:
# SPOILER ALERT: AoC 2024/1 part B
# This function will be "mapped" to individual elements
def to_int_tuple(a,b): return (int(a),int(b))
# this function acts on the whole list
def count_instances(As,Bs): return [(o,len([t for t in Bs if t==o])) for o in As]
from math import prod
(L(sample.splitlines())map(str.split) .tee('input data')
.'int to tuples')
.starmap(to_int_tuple) .tee(zip(cycled=True) .tee('tuple of lists')
.'apply count_instances to the "whole list"')
.starpipe(count_instances) .tee(map(prod) .tee('multiply tuple elements')
.sum()
. )
input data
[['3', '4'], ['4', '3'], ['2', '5'], ['1', '3'], ['3', '9'], ['3', '3']]
int to tuples
[(3, 4), (4, 3), (2, 5), (1, 3), (3, 9), (3, 3)]
tuple of lists
[(3, 4, 2, 1, 3, 3), (4, 3, 5, 3, 9, 3)]
apply count_instances to the "whole list"
[(3, 3), (4, 1), (2, 0), (1, 0), (3, 3), (3, 3)]
multiply tuple elements
[9, 4, 0, 0, 9, 9]
RETURN: 31
This is another example showing the usage of pipe
, starpipe
, unwrap
, tee
:
= dict(sep='\n= ', show=True)
s
# Count the number of common elements in two lists
1,2,3,2,7],[2,4,1]])
(L([[map(set) .tee('transform lists in sets',**s)
.set.intersection) .tee('intersect them using standard operator',**s)
.starpipe(len) .tee('count number of common items',**s)
.pipe(# stop the chain and return the value
.unwrap() )
transform lists in sets
= [{1, 2, 3, 7}, {1, 2, 4}]
intersect them using standard operator
= [1, 2]
count number of common items
= [2]
RETURN: 2
This example shows how to use advanced tee:
import numpy as np
def print_as_matrix(x:L, msg):
print(msg)
= len(x)
N = len(x[0])
rows for r in range(rows):
= (L([o[r] for o in x])
row_str map(lambda o: ''.join(map(str,o)))
.lambda x:' '.join(x),wrap=False))
.pipe(print(row_str)
# Sums all the values of the first row of all matrices after rotating them of 90 degree
0,0,0],[1,1,1],[0,0,0]],[[1,0,0],[0,1,0],[0,0,1]]]) .tee('Input images',f=print_as_matrix)
(L([[[map(lambda x: np.rot90(x,k=1)) .tee('Rotate 90 degree',f=print_as_matrix)
.map(lambda x: x[0].tolist()) .tee('Take first row')
.map(sum) .tee('Sum element in list')
.sum()
. )
Input images
000 100
111 010
000 001
Rotate 90 degree
010 001
010 010
010 100
Take first row
[[0, 1, 0], [0, 0, 1]]
Sum element in list
[1, 1]
RETURN: 2
Or if you want you can use matplotlib:
def plot_images(x:L, msg):
= len(x)
N =(4*N,4))
plt.figure(figsizefor i,o in enumerate(x):
1,N,i+1)
plt.subplot(
plt.imshow(o)f'Image: {i}')
plt.title( plt.suptitle(msg)