Session 15 · Command pattern#

The big idea#

Normally you call a method and it runs now. The Command pattern turns an action into an object. Now you can store it, queue it, log it, run it later, and undo it.

Everyday example: ordering at a restaurant. The waiter writes your order on a slip (the command). The slip goes to the kitchen queue, and the chef cooks when ready. You and the chef never talk directly, and the slip can be cancelled.

In plain words

Turn “do this” into a thing you can hold: an object that knows how to do the action and how to undo it. Then you can save it, queue it, log it or reverse it.

Reach for it when:

  • you need undo/redo

  • actions should run later, in a queue or on another worker

  • you want a log of what was done, or a human to approve it first

Without Command#

0.no_command.py: actions happen immediately and leave no trace.

class Editor:
    def __init__(self): self.text = ""

editor = Editor()
editor.text += "Hello"
editor.text += " World"
# How do we undo " World"? We forgot what we did.

With Command: undo and redo#

1.command.py:

from abc import ABC, abstractmethod

class Editor:
    def __init__(self):
        self.text = ""

class Command(ABC):
    @abstractmethod
    def execute(self): ...
    @abstractmethod
    def undo(self): ...

class AppendText(Command):
    def __init__(self, editor: Editor, text: str):
        self.editor, self.text = editor, text

    def execute(self):
        self.editor.text += self.text

    def undo(self):
        self.editor.text = self.editor.text[: -len(self.text)]

class History:
    def __init__(self):
        self._done: list[Command] = []
        self._undone: list[Command] = []

    def run(self, cmd: Command):
        cmd.execute()
        self._done.append(cmd)
        self._undone.clear()

    def undo(self):
        if self._done:
            cmd = self._done.pop()
            cmd.undo()
            self._undone.append(cmd)

    def redo(self):
        if self._undone:
            cmd = self._undone.pop()
            cmd.execute()
            self._done.append(cmd)

editor, history = Editor(), History()
history.run(AppendText(editor, "Hello"))
history.run(AppendText(editor, " World"))
print(editor.text)      # Hello World
history.undo()
print(editor.text)      # Hello
history.redo()
print(editor.text)      # Hello World

Every action is now an object in a list, so undo is just “pop the last one and reverse it”.

Command: actions as objects you can keep Each action is an object with execute() and undo(). History keeps them on a stack, so undo pops the last one and reverses it; redo puts it back. AppendText("Hello") execute() · undo() AppendText(" World") execute() · undo() History done: [Hello, World] undone: [] run Editor text = "Hello World" execute undo(): pop and reverse
Each action is an object with execute() and undo(). History keeps them on a stack, so undo pops the last one and reverses it; redo puts it back.

Queue commands for later#

2.queue.py: the same idea powers job queues (Celery, RabbitMQ consumers).

from queue import Queue

class SendEmail:
    def __init__(self, to): self.to = to
    def execute(self): print(f"emailing {self.to}")

class ResizeImage:
    def __init__(self, path): self.path = path
    def execute(self): print(f"resizing {self.path}")

jobs = Queue()
jobs.put(SendEmail("kavya@example.com"))
jobs.put(ResizeImage("photo.jpg"))

while not jobs.empty():
    jobs.get().execute()          # a worker runs them one by one

Command + AI: tool calls#

When an LLM agent decides to use a tool, it produces a name and arguments. That is a command (3.tool_commands.py):

class ToolCall:
    def __init__(self, name, args):
        self.name, self.args = name, args

tools = {
    "add": lambda a, b: a + b,
    "upper": lambda text: text.upper(),
}

def run_tool(call: ToolCall):
    if call.name not in tools:
        raise ValueError(f"unknown tool {call.name}")
    return tools[call.name](**call.args)

log = []
for call in [ToolCall("add", {"a": 2, "b": 3}), ToolCall("upper", {"text": "hi"})]:
    log.append((call.name, call.args))          # audit trail
    print(run_tool(call))                        # 5, then HI

Because calls are data, you can log them, validate them (with Pydantic, from the data-validation session), ask a human to approve, or replay them.

Common mistakes#

  • Undo that doesn’t match execute. Test execute then undo returns the original state.

  • Commands that hold too much state. A command should store only what it needs to run and undo.

  • Forgetting to clear redo after a new action. Otherwise redo applies stale commands.

  • Huge history. Cap the undo stack.

Hands-on exercises#

Exercise 1 · Delete command. Add DeleteLast(n) that removes the last n characters and can undo.

Solution
class DeleteLast(Command):
    def __init__(self, editor, n):
        self.editor, self.n, self._removed = editor, n, ""

    def execute(self):
        self._removed = self.editor.text[-self.n:]
        self.editor.text = self.editor.text[: -self.n]

    def undo(self):
        self.editor.text += self._removed

Note how the command remembers what it removed.

Exercise 2 · Smart-home remote. LightOn, LightOff commands and a Remote with press(cmd) and undo_last().

Solution
class Light:
    def __init__(self): self.on = False

class LightOn:
    def __init__(self, light): self.light = light
    def execute(self): self.light.on = True
    def undo(self): self.light.on = False

class LightOff:
    def __init__(self, light): self.light = light
    def execute(self): self.light.on = False
    def undo(self): self.light.on = True

class Remote:
    def __init__(self): self.history = []
    def press(self, cmd): cmd.execute(); self.history.append(cmd)
    def undo_last(self):
        if self.history: self.history.pop().undo()

light, remote = Light(), Remote()
remote.press(LightOn(light)); print(light.on)    # True
remote.undo_last();          print(light.on)    # False

Exercise 3 · Macro. A MacroCommand that runs a list of commands together and undoes them in reverse.

Solution
class Macro(Command):
    def __init__(self, commands): self.commands = commands
    def execute(self):
        for c in self.commands: c.execute()
    def undo(self):
        for c in reversed(self.commands): c.undo()

history.run(Macro([AppendText(editor, "A"), AppendText(editor, "B")]))
history.undo()     # removes both

Full source#

1.command.py
"""Command: each action is an object, so it can be stored, undone and redone."""
from abc import ABC, abstractmethod


class Editor:
    def __init__(self):
        self.text = ""


class Command(ABC):
    @abstractmethod
    def execute(self): ...

    @abstractmethod
    def undo(self): ...


class AppendText(Command):
    def __init__(self, editor: Editor, text: str):
        self.editor, self.text = editor, text

    def execute(self):
        self.editor.text += self.text

    def undo(self):
        self.editor.text = self.editor.text[: -len(self.text)]


class History:
    def __init__(self):
        self._done: list[Command] = []
        self._undone: list[Command] = []

    def run(self, cmd: Command):
        cmd.execute()
        self._done.append(cmd)
        self._undone.clear()          # a new action makes old redos invalid

    def undo(self):
        if self._done:
            cmd = self._done.pop()
            cmd.undo()
            self._undone.append(cmd)

    def redo(self):
        if self._undone:
            cmd = self._undone.pop()
            cmd.execute()
            self._done.append(cmd)


editor, history = Editor(), History()
history.run(AppendText(editor, "Hello"))
history.run(AppendText(editor, " World"))
print(editor.text)      # Hello World
history.undo()
print(editor.text)      # Hello
history.redo()
print(editor.text)      # Hello World
3.tool_commands.py
"""Command + AI: an LLM tool call is a command: a name plus arguments, as data."""


class ToolCall:
    def __init__(self, name, args):
        self.name, self.args = name, args


tools = {
    "add": lambda a, b: a + b,
    "upper": lambda text: text.upper(),
}


def run_tool(call: ToolCall):
    if call.name not in tools:
        raise ValueError(f"unknown tool {call.name}")
    return tools[call.name](**call.args)


log = []
for call in [ToolCall("add", {"a": 2, "b": 3}), ToolCall("upper", {"text": "hi"})]:
    log.append((call.name, call.args))          # audit trail
    print(run_tool(call))                        # 5, then HI

print(log)

Downloads: 0.no_command.py · 1.command.py · 2.queue.py · 3.tool_commands.py