Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 14 additions & 29 deletions patterns/behavioral/command/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,32 +14,17 @@ stdlib_sightings: [functools.partial, sched.scheduler, unittest.mock.call]

# Command

## Problem

A menu button, a job queue, or an undo stack must trigger operations without
knowing what they do. Reify the request: an object carrying everything needed
to perform (and possibly reverse) it.

## Naive solution

`naive.py` is the classic remote-control shape: a `Command` interface with
`execute`/`undo`, concrete commands closing over a receiver, and an invoker
that runs them and keeps a history for undo.

## Pythonic solution

Functions are first-class, so *a command is just a callable*. `pythonic.py`
queues `functools.partial` objects for the execute-only case, and uses a pair
of callables (do, undo) where reversibility matters — no interface, no
hierarchy.

## In the wild

Every callback API is the Command pattern: `sched.scheduler.enter` takes the
action as a callable, Tkinter buttons take `command=`, `atexit.register`
queues commands to run at shutdown.

## Verdict

**Use with care.** Callables for deferral, the class form only once commands
need undo, serialization, or introspection beyond "run me".
Package a request as an object so it can be queued, logged, undone, or run by
code that doesn't know its details. **Verdict: use with care** — a callable is
the whole pattern until commands need undo, logs, or metadata.

| Where | What |
|---|---|
| [`pattern/`](pattern/) | The importable code: `Undoable`, `UndoStack`, `Action` |
| [`docs/`](docs/) | [Fundamentals](docs/fundamentals.md) · [Implementation guide](docs/implementation.md) · [External examples](docs/examples.md) |
| [`examples/editor_undo/`](examples/editor_undo/) | Mini-project: text-editor undo/redo built on `pattern/` |
| [`tests/`](tests/) | Behavioral tests for the pattern and the mini-project |

```bash
uv run python -m patterns.behavioral.command.examples.editor_undo
```
9 changes: 8 additions & 1 deletion patterns/behavioral/command/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,8 @@
"""Command: reify a request so it can be queued, logged, or undone."""
"""Command — public API.

>>> from patterns.behavioral.command import UndoStack, Undoable
"""

from patterns.behavioral.command.pattern import Action, Undoable, UndoStack

__all__ = ["Action", "UndoStack", "Undoable"]
39 changes: 39 additions & 0 deletions patterns/behavioral/command/docs/examples.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# Command — where it lives outside this repo

Cited, real implementations to study (or point an agent at) when designing or
reviewing command-shaped code.

## Python standard library

- **`functools.partial`** — the execute-only half of the pattern as a
builtin: a call and its arguments packaged into one object.
[docs.python.org/3/library/functools.html#functools.partial](https://docs.python.org/3/library/functools.html#functools.partial)
- **`sched.scheduler`** — queues `Event` records (time, priority, sequence,
action, argument, kwargs) — commands with metadata — and its run loop is
the invoker.
[docs.python.org/3/library/sched.html](https://docs.python.org/3/library/sched.html)
- **`unittest.mock.call`** — recorded invocations as inspectable, comparable
objects: the audit-log face of the pattern.
[docs.python.org/3/library/unittest.mock.html#unittest.mock.call](https://docs.python.org/3/library/unittest.mock.html#unittest.mock.call)

## Major ecosystems

- **Django migration operations.** Each operation implements
`database_forwards` *and* `database_backwards` — execute plus undo — and
the migration executor is an invoker replaying them in order, either way.
[docs.djangoproject.com/en/stable/ref/migration-operations/](https://docs.djangoproject.com/en/stable/ref/migration-operations/)
- **Qt's `QUndoCommand` / `QUndoStack`** (exposed by PyQt/PySide) — the
canonical GUI undo architecture: commands with `redo()`/`undo()`, an
invoker stack with exactly the clear-redo-on-push contract this module's
`UndoStack` implements. [doc.qt.io/qt-6/qundocommand.html](https://doc.qt.io/qt-6/qundocommand.html) *(unverified)*
- **Celery tasks.** A task invocation serialized onto a broker queue is the
pattern at distributed scale: the worker (invoker) executes requests it
never saw created. [docs.celeryq.dev](https://docs.celeryq.dev/) *(unverified)*

## What to notice across all of them

The dividing line is always the same: plain callables until requests need to
be *stored, inspected, or reversed*, objects after. Django's migrations and
Qt's undo stack both pay the class-per-operation cost precisely because they
need the backwards direction — and neither uses a command class where a
forward-only callback would do.
73 changes: 73 additions & 0 deletions patterns/behavioral/command/docs/fundamentals.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
# Command — fundamentals

## Intent

Package a request as an object carrying everything needed to perform it, so
code that triggers requests (menus, queues, schedulers) need not know what
they do — and so requests can be queued, logged, undone, or replayed.

## Participants

| Role | Classic (GoF) form | Python form |
|---|---|---|
| Command contract | Interface with `execute()` (and often `undo()`) | Any callable for execute-only; [`Undoable`](../pattern/commands.py) — a (do, undo) pair — when reversible |
| Concrete commands | One class per operation, binding a Receiver | A closure or `functools.partial` capturing its arguments |
| Invoker | Runs commands, may keep history | [`UndoStack`](../pattern/commands.py): push / undo / redo / log |
| Receiver | The object acted upon | Any object the callables close over |

## Mechanism

1. The moment a request is *created*, everything it needs is captured in it.
2. The invoker executes commands without inspecting them.
3. Because executed commands are objects, history is a list: undo pops and
reverses; a log is a projection; a queue is deferral.
4. Pushing a new command after undoing clears the redo branch — history is
linear, and that is a deliberate contract, not an accident.

## The classic form, and what Python absorbs

The textbook shape is an interface and a class per operation:

```python
class Command(ABC):
@abstractmethod
def execute(self) -> None: ...
@abstractmethod
def undo(self) -> None: ...


class AppendText(Command): # one class per operation
def __init__(self, doc: Document, text: str) -> None:
self.doc, self.text = doc, text

def execute(self) -> None:
self.doc.text += self.text

def undo(self) -> None:
self.doc.text = self.doc.text[: -len(self.text)]
```

Python absorbs the *deferral* half completely: functions are first-class, so
`partial(log.append, "line")` **is** a packaged request — no interface, no
hierarchy. What survives is the *reversibility* half: a bare callable cannot
carry its own inverse or its own label, so the moment you need undo, audit
logs, or serialization, the request must become data again. That is the
line this module draws: `Action` (a plain callable) below it, `Undoable`
above it.

## When to use it

- Undo/redo — the canonical justification.
- Audit trails and macro recording: executed operations must be inspectable.
- Queues and schedulers where requests outlive the code that created them.

## When not to use it

- "Call this later" with no undo, no log, no metadata → a callable or
`functools.partial` is the whole pattern; a class hierarchy is ceremony.
- One-off callbacks → pass the function.

## Verdict: use with care

Callables for deferral; the (do, undo) pair exactly when commands need to be
reversed, logged, or stored. See the unit's [caveats](../README.md).
66 changes: 66 additions & 0 deletions patterns/behavioral/command/docs/implementation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Command — putting it into a system

## The smell it fixes

Undo implemented by snapshotting entire state ("save a copy of the document
before every change"), or an event log reverse-engineered from side effects.
Both grow unbounded and neither can answer "what exactly did the user do?".

## Steps

1. **Identify the operations** users trigger that must be reversible or
auditable. Each becomes a command factory, not a subclass.
2. **Write each factory to capture its own inverse.** The critical rule:
capture undo state *at execution time*. Deleting text must remember what
it deleted — that memory is the command's whole reason to be an object:

```python
def delete_span(doc: Document, position: int, length: int) -> Undoable:
removed: list[str] = [] # filled by do, consumed by undo

def do() -> None:
removed.append(doc.delete(position, length))

def undo() -> None:
doc.insert(position, removed.pop())

return Undoable(do=do, undo=undo, label=f"delete {length}@{position}")
```

3. **Route every mutation through one invoker.** `UndoStack.push` is the
single door: nothing edits the receiver directly, or history lies.
4. **Give commands labels.** `stack.log()` is your audit trail and your
macro recording for free.
5. **Test the round-trip property**: for any command, `do(); undo()` must
restore the receiver exactly. Property-style tests catch asymmetric pairs.

## Python idioms that keep it small

- Command factories are **plain functions returning `Undoable`** — closures
capture receiver and arguments; no Receiver/ConcreteCommand classes.
- Execute-only queues are **lists of callables**; `functools.partial`
packages arguments without ceremony.
- A macro is `[stack.push(cmd) for cmd in recorded]` — replay is iteration.

## Pitfalls

- **Undo state captured too early.** Computing the inverse when the command
is *built* (not executed) breaks as soon as commands run against a state
that changed since construction.
- **Bypassing the invoker.** One direct mutation makes every later undo
corrupt the receiver. The receiver's mutators should be package-private by
convention.
- **Non-invertible operations** (send email, charge card) don't belong on an
undo stack — model them as compensations (a *new* command), not undos.
- **Forgetting to clear redo on new pushes** — replaying a stale future
corrupts state; `UndoStack` does this for you, keep the contract if you
write your own.

## Worked example

[`examples/editor_undo/`](../examples/editor_undo/) applies every step to a
text editor — insert/delete/replace with undo, redo, and a session log:

```bash
uv run python -m patterns.behavioral.command.examples.editor_undo
```
1 change: 1 addition & 0 deletions patterns/behavioral/command/examples/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
"""Mini-projects demonstrating the Command pattern in practice."""
13 changes: 13 additions & 0 deletions patterns/behavioral/command/examples/editor_undo/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""A text editor's undo/redo built on the Command pattern.

Run it: ``uv run python -m patterns.behavioral.command.examples.editor_undo``
"""

from patterns.behavioral.command.examples.editor_undo.editing import (
delete_span,
insert_text,
replace_span,
)
from patterns.behavioral.command.examples.editor_undo.models import Document

__all__ = ["Document", "delete_span", "insert_text", "replace_span"]
33 changes: 33 additions & 0 deletions patterns/behavioral/command/examples/editor_undo/__main__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"""Demo: an editing session with undo, redo, and a command log."""

from __future__ import annotations

from patterns.behavioral.command.examples.editor_undo.editing import (
delete_span,
insert_text,
replace_span,
)
from patterns.behavioral.command.examples.editor_undo.models import Document
from patterns.behavioral.command.pattern import UndoStack


def main() -> None:
doc = Document()
history = UndoStack()

history.push(insert_text(doc, 0, "hello world"))
history.push(replace_span(doc, 0, 5, "goodbye"))
history.push(delete_span(doc, 7, 6))
print(f"after edits: {doc.text!r}")

history.undo()
history.undo()
print(f"after 2 undos: {doc.text!r}")

history.redo()
print(f"after redo: {doc.text!r}")
print("session log:", " | ".join(history.log()))


if __name__ == "__main__":
main()
52 changes: 52 additions & 0 deletions patterns/behavioral/command/examples/editor_undo/editing.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
"""Edit operations as reversible commands.

Each factory captures everything its undo needs *at execution time* —
``delete_span`` must remember the text it removed, which is exactly the
state a bare callback cannot carry and the reason Command earns its keep.
"""

from __future__ import annotations

from patterns.behavioral.command.examples.editor_undo.models import Document
from patterns.behavioral.command.pattern import Undoable


def insert_text(doc: Document, position: int, chunk: str) -> Undoable:
"""Insert ``chunk`` at ``position``; undo removes exactly that span."""

def undo() -> None:
doc.delete(position, len(chunk))

return Undoable(
do=lambda: doc.insert(position, chunk),
undo=undo,
label=f"insert {chunk!r}@{position}",
)


def delete_span(doc: Document, position: int, length: int) -> Undoable:
"""Delete ``length`` chars at ``position``; undo restores what was removed."""
removed: list[str] = [] # captured by do, needed by undo

def do() -> None:
removed.append(doc.delete(position, length))

def undo() -> None:
doc.insert(position, removed.pop())

return Undoable(do=do, undo=undo, label=f"delete {length}@{position}")


def replace_span(doc: Document, position: int, length: int, chunk: str) -> Undoable:
"""Replace ``length`` chars at ``position`` with ``chunk``, reversibly."""
removed: list[str] = []

def do() -> None:
removed.append(doc.delete(position, length))
doc.insert(position, chunk)

def undo() -> None:
doc.delete(position, len(chunk))
doc.insert(position, removed.pop())

return Undoable(do=do, undo=undo, label=f"replace {length}@{position} with {chunk!r}")
19 changes: 19 additions & 0 deletions patterns/behavioral/command/examples/editor_undo/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"""Domain type for the editor-undo mini-project: a mutable text buffer."""

from __future__ import annotations


class Document:
"""The receiver: commands operate on this buffer, it knows no history."""

def __init__(self, text: str = "") -> None:
self.text = text

def insert(self, position: int, chunk: str) -> None:
self.text = self.text[:position] + chunk + self.text[position:]

def delete(self, position: int, length: int) -> str:
"""Remove and return ``length`` characters at ``position``."""
removed = self.text[position : position + length]
self.text = self.text[:position] + self.text[position + length :]
return removed
Loading
Loading