Tutorial: build a pipe operator (|>)
In this tutorial you’ll add an Elixir/F#-style pipe operator to Python:
x |> f means f(x), so a left-to-right chain like (1, 2, 3) |> list |>
sum reads in the order it runs. You’ll see how a binary-operator augmentation
works, and how returning a function from before_binop lets you redefine what
an operator does. Then you’ll add an assigning variant, |>>, that stores the
running value in a variable.
This is the essence of the shipped pipeline_tracer.py example, minus the
placeholder-lambda machinery. Syntax augmentation requires Python ≥ 3.8.
Step 1: declare the operator token
|> isn’t legal Python, so first we tell Pyccolo to rewrite it into a token the
parser does accept — the bitwise-or | — while remembering that this
particular | came from our spec. A binop-position
AugmentationSpec does exactly that:
import pyccolo as pyc
class Pipe(pyc.BaseTracer):
global_guards_enabled = False # pure syntax, no runtime guards
pipe_spec = pyc.AugmentationSpec(
aug_type=pyc.AugmentationType.binop, token="|>", replacement="|"
)
Setting global_guards_enabled = False marks the tracer as one that rewrites
syntax rather than emitting guarded runtime events, so it layers cleanly with
others (see Compose multiple tracers).
Step 2: redefine what the operator does
After the rewrite, (1, 2, 3) |> list parses as an ordinary ast.BinOp whose
operator is ast.BitOr — but one Pyccolo has tagged as originating from our
spec. When such a binop is about to run, before_binop fires; returning a
two-argument function from it replaces the operation itself. We return one
that applies the right operand to the left:
class Pipe(pyc.BaseTracer):
global_guards_enabled = False
pipe_spec = pyc.AugmentationSpec(
aug_type=pyc.AugmentationType.binop, token="|>", replacement="|"
)
@pyc.before_binop(when=lambda node: isinstance(node.op, ast.BitOr))
def apply(self, ret, node, *_, **__):
if self.pipe_spec in self.get_augmentations(id(node)):
return lambda left, right: right(left) # x |> f == f(x)
return ret
with Pipe:
assert pyc.exec("out = (1, 2, 3) |> list")["out"] == [1, 2, 3]
Two things keep us precise. The when= guard skips the handler unless the
operator is a | at all, and
get_augmentations() narrows us further to just the |
tokens that came from |> — a real bitwise-or elsewhere in the program is left
completely alone.
Step 3: chain it
Because the handler runs for every |> node, chains compose for free — each
pipe feeds its result into the next:
with Pipe:
env = pyc.exec("total = range(5) |> list |> sum")
assert env["total"] == 10
Step 4: add an assigning pipe |>>
A pipeline is more useful if you can tap the running value into a variable. We add
a second spec for |>> and handle it in the same place: instead of calling the
right operand, we assign the left one to the name on the right. (We pre-seed the
name to None first, so evaluating the right-hand operand doesn’t raise
NameError before our function runs.)
class Pipe(pyc.BaseTracer):
global_guards_enabled = False
pipe_spec = pyc.AugmentationSpec(
aug_type=pyc.AugmentationType.binop, token="|>", replacement="|"
)
assign_spec = pyc.AugmentationSpec(
aug_type=pyc.AugmentationType.binop, token="|>>", replacement="|"
)
@pyc.before_binop(when=lambda node: isinstance(node.op, ast.BitOr))
def apply(self, ret, node, frame, *_, **__):
augmentations = self.get_augmentations(id(node))
if self.assign_spec in augmentations:
name = node.right # the variable to assign into
frame.f_globals[name.id] = None # avoid NameError on the RHS
def stash(left, _right):
frame.f_globals[name.id] = left
return left # ...and keep piping it along
return stash
if self.pipe_spec in augmentations:
return lambda left, right: right(left)
return ret
with Pipe:
env = pyc.exec("(1, 2, 3) |> list |>> items\nresult = items |> sum")
assert env["result"] == 6
|>> returns the value it stashed, so the chain keeps flowing: we captured the
list into items mid-pipeline and then summed it.
Where to next
The shipped pipeline_tracer.py goes much further — placeholder lambdas
(range(5) |> f[map(f[_ + 1], _)]), tuple/dict-splat variants, and function
composition — and composes with the quick-lambda tracer. Both are built on the exact binop augmentation you
just wrote. To see the rewrite as plain source rather than running it, reach for
Source-to-source transforms.