Tutorial: build optional chaining (?.)
In this tutorial you’ll teach Python a brand-new piece of syntax: JavaScript-style
optional chaining, where foo?.bar short-circuits to None whenever
foo is None instead of raising AttributeError. You’ll see how
Pyccolo lets you introduce surface syntax that the Python parser would normally
reject, and attach behavior to it.
This builds the essence of the optional_chaining.py example. Syntax
augmentation requires Python ≥ 3.8.
Step 1: declare the new syntax
?. is not legal Python, so first we tell Pyccolo how to rewrite it into
something that is. An AugmentationSpec declares a token-level rewrite — here,
turn ?. into a plain . — and Pyccolo remembers where each rewrite
happened so a handler can later recognize the affected node. Declare it as a class
attribute:
import ast
import pyccolo as pyc
class OptionalChainer(pyc.BaseTracer):
optional_chaining_spec = pyc.AugmentationSpec(
aug_type=pyc.AugmentationType.dot_suffix, token="?.", replacement="."
)
aug_type=dot_suffix says the token sits where a . attribute-access dot
would go. After this rewrite, bar?.foo parses as an ordinary
ast.Attribute load — but one that Pyccolo has tagged as originating from
?.. See Add new surface syntax and Syntax augmentation for the
full augmentation vocabulary.
Step 2: handle the tagged access
Now attach behavior. When an attribute is about to be dereferenced, Pyccolo fires
before_attribute_load with the receiver object. We check whether this
attribute node came from our ?. spec using
get_augmentations(), and if the receiver is None, we
substitute a sentinel that makes any further attribute access also yield None:
class OptionalChainer(pyc.BaseTracer):
class ResolvesToNone:
def __getattr__(self, _item):
return self # keep resolving to the sentinel down the chain
resolves_to_none = ResolvesToNone()
optional_chaining_spec = pyc.AugmentationSpec(
aug_type=pyc.AugmentationType.dot_suffix, token="?.", replacement="."
)
@pyc.register_handler(pyc.before_attribute_load)
def handle_before_attr(self, obj, node: ast.Attribute, *_, **__):
if (
self.optional_chaining_spec in self.get_augmentations(id(node))
and obj is None
):
return self.resolves_to_none
return obj
Returning a value from before_attribute_load replaces the receiver that
gets dereferenced (see Handlers). The sentinel’s __getattr__
returns itself, so an entire chain like a?.b?.c keeps resolving through the
sentinel rather than blowing up.
Step 3: turn the sentinel back into None
The sentinel is an implementation detail; the result the user sees should be a
real None. A whole attribute/subscript/call chain is wrapped by a compound
“load complex symbol” event, so we intercept its result and, if it’s our
sentinel, override it with pyc.Null — the way a handler says “replace this
value with an actual None” (returning None would mean “don’t override”):
@pyc.register_raw_handler(pyc.after_load_complex_symbol)
def handle_after_load_complex_symbol(self, ret, *_, **__):
if isinstance(ret, self.ResolvesToNone):
return pyc.Null
return ret
Step 4: run it
Because the tracer defines AST-level syntax, run the target code through
pyc.exec so it gets instrumented:
with OptionalChainer:
pyc.exec("bar = None\nprint(bar?.foo)") # -> None
To instrument files loaded through the import machinery or the pyc
command-line tool instead, subclass and opt those files in by overriding
should_instrument_file():
class ScriptOptionalChainer(OptionalChainer):
def should_instrument_file(self, filename: str) -> bool:
return True
Then pyc bar.py -t pyccolo.examples.optional_chaining.ScriptOptionalChainer
runs a whole script under the tracer (see Command-line interface).
Step 5: see the rewrite as source
You don’t have to run the code to use the augmentation. pyc.transform gives
you the desugared, plain-Python source, and pyc.untransform resugars it back:
with OptionalChainer:
assert pyc.transform("y = a?.b?.c") == "y = a.b.c"
tree = pyc.parse("y = a?.b?.c", instrument=False)
assert pyc.untransform(tree) == "y = a?.b?.c"
This is the entry point for linters, formatters, and source maps — see Source-to-source transforms.
Where to next
The model: events and handlers explains how a token rewrite becomes a tagged AST node and then a handler call.
The full example adds
.?(permissive dereference),?.((optional call), and??(nullish coalescing) — a good next read inpyccolo/examples/optional_chaining.py.