Routing charm events à la PEP 636

PEP 636 - “Structural pattern matching” - is about the match expression that was added to Python 3.10, and it is much more powerful than the familiar C-style switch-case. (Yet not as powerful as Rust’s match, but that’s a different story.)

If you’re using modern ops, chances are your charm is on jammy (22.04) or later, which means you have Python 3.10+. Yay!

A few words about framework engineering before we begin

Currently, the operator framework enables “routing” events using framework.observe() calls from __init__. The charm’s __init__ is the “event registration grounds”, and the framework “emits” the events thereafter. This enables many-to-many mapping between events and event handlers. The framework also guarantees that the order of observe() statements prescribes the order of the event handlers called, so code ordering is straightforward. If charming hadn’t taken the trajectory of observing events from within charm libraries, then code execution order would not have been such a pain, but that’s a different story.

Another typical “registration” approach is by sprinkling decorators. Charming with an alternative observer framework could have looked like this:

@framework.observe("relation-changed", "ingress")
@framework.observe("relation-changed", "certificates")
def on_lots_has_changed(event):
	...

This also enables many-to-many mapping from events to event handlers, but in this case code ordering is a bit more confusing, and brittle, if you reorder your methods. So I think the observe() methods we currently have were a better choice than decorators, for the purpose of charming.

So how does Python’s match-case block compare to a block of observe() statements? If used in the trivial C-style switch-case, they would be functionally equivalent. If used as intended, then it would no longer be many-to-many (which could be a good thing!). Some wild charmers may even think to combine the two together, but that’s a different story.

This story is about observe()-free event routing using match-case expressions.

JujuContext

Among other things, operator/1313 helped clearing up the line between juju and the operator framework. Now we can from jujucontext import JujuContext and ctx = JujuContext.from_environ() to get access to all the raw data from which the operator framework scaffolds everything.

PEP 636: Capturing sub-patterns and wildcards

Imagine JujuContext had a List[str] representation for events:

event: List[str] = JujuContext.from_environ().event_as_list()

Then “reconciler with exceptions” could look like this:

match event:
    # VM charm
    case ["install"]:
	    snap.install("...")
	case ["remove"]:
		snap.remove("...")
    # K8s charm
    case ["pebble-ready", ("grafana" | "litestream") as c]:
	    Container(c).push(certs)
    case _:
        reconcile(event)

PEP 636: Matching objects

Imagine JujuContext had a dataclass representation for events:

@dataclass
class RelationEvent:
	kind: SomeStrEnum  # with CHANGED = "changed", etc.
	relation: str

# etc.

event = JujuContext.from_environ().event_as_dataclass()

Then we’d need less string literals:

match event:
	case RelationEvent(RelKind.CREATED, "certificates"):
		# stop pebble plan until certs are in
	case RelationEvent(RelKind.CHANGED, relation):
		# do something with all _other_ `relation-changed` events
		

PEP 636: Mappings

Imagine JujuContext had a json representation for events:

event = JujuContext.from_environ().event_as_json()

Then we could have explicit granularity on action routing:

match event:
	case {"name": "export-action", "compression": c, "filename": fn}
		# compress using algo `c` to filename `fn`
	case {"name": "clone-action", "from": src, "to": dst} if src == dst
		# fail the event because src == dst
	case {"name": "secret-changed", "key1": val1, "key2": val2}
		# Whatever secret it was, if it has those matching
		# key-val pairs then let's do something

Additional context

  • Charms are woken up by a single event at a time, i.e. charms are “one-shot” commands, not a long running process, so the observer pattern has a very narrow job in charms.
  • When a match is found, the other matches are not executed, and this is a key subtlety an difference from the trivial many-to-many of observe() calls.
  • match-case expressions can be used multiple times along the routing path of an event.
2 Likes

This is a cool train of thought, and an interesting use of Python’s pattern matching (which I have mixed feelings about). I think your “matching objects” variant fits pattern matching best, and I could easily see a lightweight module that defines a set of event dataclasses to use in “raw JujuContext” charms. I’d probably break things up a little differently (RelKind → different event classes, for example):

ctx = JujuContext.from_environ()
event = events.context_to_class(ctx)

match event:
    case events.RelationCreated(name='certificates'):
        ...
    case events.RelationChanged():
        ...
    case events.PebbleReady(container='grafana'):
        ...

RelationCreated and RelationChanged dataclasses could inherit from a RelationBase dataclass, though I’m not sure.

Yep, that looks nice!

Need to think how to design those object so that match-case is significantly more appealing than elif isinstance.

One raw idea I had is to do matching on relation data itself, for example match when peer relation data from all peers is not empty.

Another potential use case is matching on a juju action’s arbitrary kwargs (additionalProperties: true).