Chapter 9 — Duck Typing & Protocols
"If it walks like a duck and quacks like a duck, then it is a duck." This ancient Python proverb encodes a profound type-theoretic idea: type identity is determined by behavior, not by name. In homotopy type theory, this is exactly the notion of a path between types — two types are equal if they support the same operations with compatible behaviors.
This chapter is the signature contribution of Deppy. We show how Python's
Protocol classes (PEP 544) are modeled as behavioral specifications
in HoTT, and how the DuckPath proof term provides machine-checked
evidence that a class conforms to a protocol without inheriting from it.
Protocols as Behavioral Specifications
A Python Protocol declares a set of methods with their signatures.
Any class that provides those methods — regardless of its inheritance hierarchy —
is considered a structural subtype of the Protocol:
from __future__ import annotations
from typing import Protocol, runtime_checkable
@runtime_checkable
class Renderable(Protocol):
"""Anything that can be rendered to a string representation."""
def render(self, indent: int = 0) -> str:
"""Return a string representation at the given indent level."""
...
def render_width(self) -> int:
"""Return the width in characters of the rendered output."""
...
In Deppy, this Protocol is not just a type hint — it is a formal specification. Deppy extracts:
- The method signatures (parameter types, return types)
- The behavioral contracts (from docstrings and
@guaranteeannotations) - The structural requirements (which dunder methods are needed)
Deppy Protocol Specification
from deppy import protocol_spec, guarantee
@protocol_spec
class Renderable(Protocol):
@guarantee("result is a non-empty string")
def render(self, indent: int = 0) -> str: ...
@guarantee("returns a non-negative integer")
def render_width(self) -> int: ...
# Deppy generates the formal spec:
# ProtocolSpec(Renderable) = {
# render : (self, int?) → str | guarantee: len(result) > 0
# render_width : (self) → int | guarantee: result >= 0
# }
The DuckPath Proof Term
The central proof object in this chapter is DuckPath[C, P] — a
witness that class C structurally conforms to Protocol P.
Formally, a DuckPath is a path in the protocol universe:
DuckPath[C, P] : C =_{ProtocolUniverse} P
A DuckPath is valid if and only if for every methodminP, the classChas a methodC.msuch that:
(1)C.m's parameter types are contravariant-compatible withP.m's, and
(2)C.m's return type is covariant-compatible withP.m's, and
(3)C.msatisfies all@guaranteeannotations onP.m.
Constructing a DuckPath
from deppy.hott import DuckPath, MethodPath, SignatureCheck
# A class that doesn't inherit from Renderable
class HTMLElement:
def __init__(self, tag: str, children: list[HTMLElement] | None = None):
self.tag = tag
self.children = children or []
self.attrs: dict[str, str] = {}
def render(self, indent: int = 0) -> str:
pad = " " * indent
attr_str = " ".join(
f'{k}="{v}"' for k, v in self.attrs.items()
)
opening = f"{pad}<{self.tag}" + (f" {attr_str}" if attr_str else "") + ">"
if not self.children:
return f"{opening}</{self.tag}>"
child_str = "\n".join(c.render(indent + 2) for c in self.children)
return f"{opening}\n{child_str}\n{pad}</{self.tag}>"
def render_width(self) -> int:
lines = self.render().split("\n")
return max(len(line) for line in lines)
# Deppy automatically constructs this DuckPath:
duck_proof = DuckPath.auto_construct(HTMLElement, Renderable)
# DuckPath[HTMLElement, Renderable]:
# MethodPath(render): sig ✓ ret ✓ guarantee ✓
# MethodPath(render_width): sig ✓ ret ✓ guarantee ✓
# Status: VALID ✅
DuckPath.auto_construct uses Python's inspect module
to extract method signatures, then Z3 to verify signature compatibility, and
finally the guarantee checker to verify behavioral contracts. If any step fails,
the DuckPath is INVALID and Deppy reports exactly which
method and which condition failed.
Inside a MethodPath
Each MethodPath in the DuckPath contains detailed evidence:
# Inspecting the proof internals
mp = duck_proof.method_paths["render"]
print(mp.details())
# Output:
# MethodPath(HTMLElement.render → Renderable.render):
# Parameters:
# self : HTMLElement ≤ Self [OK — self is always compatible]
# indent: int ≡ int [OK — exact match]
# indent default: 0 ≡ 0 [OK — defaults match]
# Return:
# str ≡ str [OK — exact match]
# Guarantees:
# "result is a non-empty string":
# Z3 query: len(result) > 0
# Proved by: tag is always non-empty ∧ output contains tag
# Status: Z3_VERIFIED ✅
Example: The Iterable Protocol
Python's iteration protocol is the most pervasive duck typing example. Let's verify that a custom data structure satisfies it:
from __future__ import annotations
from typing import Protocol, TypeVar, Generic, Iterator
from deppy import guarantee, verify
T = TypeVar("T")
class IterableProto(Protocol[T]):
def __iter__(self) -> Iterator[T]: ...
def __next__(self) -> T: ...
@verify
class RingBuffer(Generic[T]):
"""A fixed-size circular buffer that supports iteration."""
def __init__(self, capacity: int) -> None:
assert capacity > 0
self._buf: list[T] = []
self._cap = capacity
self._pos = 0
self._iter_idx = 0
def push(self, item: T) -> None:
if len(self._buf) < self._cap:
self._buf.append(item)
else:
self._buf[self._pos] = item
self._pos = (self._pos + 1) % self._cap
def __iter__(self) -> RingBuffer[T]:
self._iter_idx = 0
return self
def __next__(self) -> T:
if self._iter_idx >= len(self._buf):
raise StopIteration
item = self._buf[self._iter_idx]
self._iter_idx += 1
return item
Deppy's verification output:
$ python -m deppy check ring_buffer.py --verbose
Analyzing RingBuffer...
✓ DuckPath[RingBuffer[T], IterableProto[T]]
├── __iter__: RingBuffer[T] → RingBuffer[T]
│ └── Return type RingBuffer[T] ≤ Iterator[T]
│ (valid because RingBuffer has __next__)
├── __next__: RingBuffer[T] → T
│ ├── Return type T ≡ T [exact match]
│ └── StopIteration raised when exhausted ✓
└── Status: VALID ✅
✓ Termination: __next__ decreases (len(_buf) - _iter_idx)
✓ Safety: no index-out-of-bounds in __next__
└── Z3: _iter_idx < len(_buf) at access point
3/3 obligations verified (0.12s)
Example: File-Like Objects
Python's ecosystem is full of file-like objects: StringIO,
BytesIO, actual files, network sockets with makefile(),
HTTP response bodies. They all satisfy the same protocol without sharing a
common base class. Let's verify this:
from __future__ import annotations
from typing import Protocol, Optional
from deppy import guarantee, protocol_spec
@protocol_spec
class ReadableFile(Protocol):
@guarantee("returns at most n bytes/chars")
def read(self, n: int = -1) -> str | bytes: ...
def readline(self) -> str | bytes: ...
@guarantee("returns False after close()")
def readable(self) -> bool: ...
def close(self) -> None: ...
# Now verify multiple classes against this protocol:
from io import StringIO, BytesIO
# Deppy can verify standard library classes too
path_stringio = DuckPath.auto_construct(StringIO, ReadableFile)
path_bytesio = DuckPath.auto_construct(BytesIO, ReadableFile)
# Both are valid — StringIO.read returns str, BytesIO.read returns bytes
# Both are subtypes of str | bytes, so the DuckPath holds
We can also verify a custom file-like object:
@verify
class LogCapture:
"""Captures log output into an in-memory buffer."""
def __init__(self) -> None:
self._buffer: list[str] = []
self._pos = 0
self._closed = False
@guarantee("returns at most n characters")
def read(self, n: int = -1) -> str:
if self._closed:
raise ValueError("I/O operation on closed file")
full = "\n".join(self._buffer[self._pos:])
if n == -1:
self._pos = len(self._buffer)
return full
result = full[:n]
# advance position by lines consumed
consumed_lines = result.count("\n")
self._pos += consumed_lines
return result
def readline(self) -> str:
if self._closed:
raise ValueError("I/O operation on closed file")
if self._pos >= len(self._buffer):
return ""
line = self._buffer[self._pos]
self._pos += 1
return line + "\n"
def readable(self) -> bool:
return not self._closed
def close(self) -> None:
self._closed = True
def write(self, msg: str) -> None:
if self._closed:
raise ValueError("I/O operation on closed file")
self._buffer.append(msg)
$ python -m deppy check log_capture.py --verbose
✓ DuckPath[LogCapture, ReadableFile]
├── read: (self, int=-1) → str ≤ (self, int=-1) → str|bytes
│ ├── Param compat: int ≡ int ✓
│ ├── Return: str ≤ str|bytes ✓ (str is subtype of union)
│ └── Guarantee "returns at most n chars":
│ Z3 query: (n ≥ 0) → len(result) ≤ n
│ Status: Z3_VERIFIED ✅
├── readline: (self) → str ≤ (self) → str|bytes ✓
├── readable: (self) → bool ≡ (self) → bool ✓
│ └── Guarantee "returns False after close()":
│ Z3 query: (self._closed == True) → (result == False)
│ Status: Z3_VERIFIED ✅
├── close: (self) → None ≡ (self) → None ✓
└── Status: VALID ✅
Note: LogCapture has extra method write not in ReadableFile — OK
(structural subtyping allows extra methods)
Path Construction: The Theory
How does Deppy actually construct the path between two duck-typed objects? The construction mirrors the cubical type theory interval:
# Simplified internal representation of DuckPath construction
from deppy.hott.cubical import Interval, PathType, refl, ap
def construct_duck_path(
cls: type,
proto: type[Protocol],
) -> PathType[type, cls, proto]:
"""
Construct a path in the protocol universe from cls to proto.
In cubical type theory, a path A =_U B is a function
p : I → U such that p(0) = A and p(1) = B.
For duck typing, we construct this path by interpolating
between the method tables:
p(i) = { m : merge(cls.m, proto.m, i) for m in proto.methods }
where merge(f, g, 0) = f, merge(f, g, 1) = g,
and merge is defined iff f and g are signature-compatible.
"""
proto_methods = inspect_protocol_methods(proto)
cls_methods = inspect_class_methods(cls)
method_paths = {}
for name, proto_sig in proto_methods.items():
if name not in cls_methods:
raise DuckPathError(
f"Class {cls.__name__} missing method {name}"
)
cls_sig = cls_methods[name]
# Check contravariant params, covariant return
param_path = check_param_compatibility(cls_sig, proto_sig)
ret_path = check_return_compatibility(cls_sig, proto_sig)
# The method path is the product of param and return paths
method_paths[name] = MethodPath(
source=cls_sig, target=proto_sig,
param_evidence=param_path,
return_evidence=ret_path,
)
# The DuckPath is the product of all method paths
return DuckPath(
source=cls, target=proto,
method_paths=method_paths,
# Valid iff all method paths are valid
is_valid=all(mp.is_valid for mp in method_paths.values()),
)
Variance and Compatibility
DuckPath construction requires careful variance checking. Python's type system has nuanced rules about when a method signature is "compatible":
| Position | Variance | Rule | Example |
|---|---|---|---|
| Parameters | Contravariant | Class may accept wider types | Protocol: int → Class: int | float ✓ |
| Return | Covariant | Class may return narrower types | Protocol: Sequence[T] → Class: list[T] ✓ |
| Defaults | Must match | Default values must be equal | Protocol: n=0 → Class: n=0 ✓ |
| *args/**kwargs | Compatible | Class may have *args if Protocol doesn't | Protocol: (x, y) → Class: (*args) ✓ |
# Deppy's actual variance analyzer — pure Python via inspect.
# No Z3 needed: variance positions follow PEP 484 rules and
# compose under a meet lattice (covariant ⊓ contravariant = invariant).
from deppy.proofs.psdl_variance import (
analyze_class_variance, VariancePos, emit_variance_check,
)
def check_param_compatibility(cls: type, type_var: str):
"""Walk cls's methods/fields, tag each occurrence, take the meet."""
report = analyze_class_variance(
cls,
type_var_name=type_var,
declared=VariancePos.COVARIANT,
)
# Method return types ↦ covariant; method params ↦ contravariant;
# mutable fields ↦ invariant; frozen fields ↦ covariant.
if report.consistent_with_declaration:
return emit_variance_check(report) # kernel ProofTerm
raise ValueError(
f"variance violations: {report.occurrences}"
)
The analyzer is invoked at proof time via the PSDL primitive
verify_variance(Cls, type_var=..., declared=...), which
emits a VarianceCheck ProofTerm into the kernel.
Soundness is established at the meta-level in
metatheory/Deppy.lean §32.
Dynamic Attribute Resolution
Python allows classes to define __getattr__, which intercepts
attribute lookups for attributes that don't exist on the object. This means
a class can satisfy a Protocol dynamically — it might not have the
methods in its __dict__, but __getattr__ synthesizes
them on demand:
class DynamicProxy:
"""Proxy that delegates all method calls to a target object."""
def __init__(self, target: object) -> None:
self._target = target
def __getattr__(self, name: str) -> Any:
return getattr(self._target, name)
# Does DynamicProxy satisfy Renderable?
# It depends on what _target is!
Deppy handles this with conditional DuckPaths — a DuckPath that is valid given certain conditions on the object's state:
# Deppy generates a conditional DuckPath:
cond_proof : ConditionalDuckPath[DynamicProxy, Renderable] = (
ConditionalDuckPath.construct(
condition="isinstance(self._target, Renderable)",
# Under this condition, __getattr__ delegates to a Renderable,
# so all method lookups succeed with compatible signatures
evidence=FiberedLookup(
fiber_over="_target",
base_path=DuckPath.auto_construct(
target_type=Renderable,
proto=Renderable
),
transport_through="__getattr__",
)
)
)
# Status: CONDITIONALLY VALID ✅
# Condition: _target must satisfy Renderable
__getattr__-based protocol conformance is inherently conditional.
Deppy cannot prove unconditional conformance for dynamic proxies —
instead it generates a proof obligation for each call site:
"At this point, prove that the proxy's target satisfies the Protocol."
Composing DuckPaths
DuckPaths compose. If A duck-types as B and
B duck-types as C, then A duck-types
as C:
from deppy.hott import path_compose
# Given:
p1 : DuckPath[HTMLElement, Renderable] # from earlier
p2 : DuckPath[Renderable, Displayable] # if Displayable ⊆ Renderable
# Then we can compose:
p3 : DuckPath[HTMLElement, Displayable] = path_compose(p1, p2)
# p3 is valid iff both p1 and p2 are valid
# This corresponds to path composition in HoTT:
# p1 : A =_U B
# p2 : B =_U C
# p1 · p2 : A =_U C
When DuckPaths Fail
Not everything that looks like a duck is actually a duck. Deppy provides detailed error reports when a DuckPath cannot be constructed:
class BadElement:
def render(self) -> None: # wrong return type!
print("<div/>")
# missing render_width entirely!
$ python -m deppy check bad_element.py --verbose
✗ DuckPath[BadElement, Renderable]: INVALID
├── render: SIGNATURE MISMATCH
│ ├── Parameters:
│ │ Protocol requires: (self, indent: int = 0)
│ │ Class provides: (self)
│ │ ✗ Missing parameter 'indent'
│ └── Return type:
│ Protocol requires: str
│ Class provides: None
│ ✗ None is not a subtype of str
│
├── render_width: MISSING
│ ✗ BadElement has no method 'render_width'
│
└── 2 errors, 0 warnings
Suggestion: Add render_width(self) → int to BadElement,
and change render to accept indent: int = 0 and return str.
Comparison with F*: Interfaces vs. DuckPaths
The fundamental difference between Deppy and F* (or any language with nominal typing) is captured in this table:
| Aspect | F* / Lean / Coq | Deppy |
|---|---|---|
| Conformance declared | Explicitly (implements) |
Inferred (structural) |
| Proof constructed | At declaration site | At use site (or precomputed) |
| New protocol, old class | Must modify old class | Automatically verified |
| Dynamic conformance | Not possible | Conditional DuckPaths |
| Proof term | Interface instance | DuckPath (path in HoTT) |
| Extra methods | Irrelevant | Irrelevant (same) |
| Variance | Declared per type parameter | Inferred per method position |
Advanced: Generic Protocols
Protocols can be generic, and DuckPaths must handle type variables correctly.
Here's a more complex example with a generic Comparable protocol:
from __future__ import annotations
from typing import Protocol, TypeVar
from deppy import guarantee, verify
T = TypeVar("T")
class Comparable(Protocol):
@guarantee("antisymmetric: (a ≤ b ∧ b ≤ a) → a == b")
@guarantee("transitive: (a ≤ b ∧ b ≤ c) → a ≤ c")
def __le__(self, other: Comparable) -> bool: ...
@guarantee("consistent with __le__: (a < b) ↔ (a ≤ b ∧ a ≠ b)")
def __lt__(self, other: Comparable) -> bool: ...
@verify
class Temperature:
def __init__(self, kelvin: float) -> None:
assert kelvin >= 0.0, "Temperature cannot be below absolute zero"
self._k = kelvin
def __le__(self, other: Temperature) -> bool:
return self._k <= other._k
def __lt__(self, other: Temperature) -> bool:
return self._k < other._k
def __eq__(self, other: object) -> bool:
if not isinstance(other, Temperature):
return NotImplemented
return self._k == other._k
$ python -m deppy check temperature.py --verbose
✓ DuckPath[Temperature, Comparable]
├── __le__: (self: Temperature, other: Temperature) → bool
│ ├── Param 'other': Temperature ≤ Comparable
│ │ (valid: Temperature satisfies Comparable — recursive check)
│ ├── Guarantee "antisymmetric":
│ │ Z3 encoding:
│ │ ∀ a_k b_k : Real.
│ │ (a_k ≥ 0 ∧ b_k ≥ 0) →
│ │ ((a_k ≤ b_k ∧ b_k ≤ a_k) → a_k == b_k)
│ │ Status: Z3_VERIFIED ✅
│ └── Guarantee "transitive":
│ Z3 encoding:
│ ∀ a_k b_k c_k : Real.
│ (a_k ≥ 0 ∧ b_k ≥ 0 ∧ c_k ≥ 0) →
│ ((a_k ≤ b_k ∧ b_k ≤ c_k) → a_k ≤ c_k)
│ Status: Z3_VERIFIED ✅
├── __lt__:
│ └── Guarantee "consistent with __le__":
│ Z3 encoding:
│ ∀ a_k b_k : Real.
│ (a_k ≥ 0 ∧ b_k ≥ 0) →
│ ((a_k < b_k) ↔ (a_k ≤ b_k ∧ a_k ≠ b_k))
│ Status: Z3_VERIFIED ✅
└── Status: VALID ✅
Temperature.__le__ accepts a Comparable parameter,
it needs to check that Temperature itself satisfies
Comparable. This is a coinductive check — Deppy uses a
fixed-point computation to handle it.
Runtime Protocol Checking
With @runtime_checkable Protocols, Python's isinstance
performs a structural check at runtime. Deppy connects this to the
static DuckPath:
@runtime_checkable
class Renderable(Protocol):
def render(self, indent: int = 0) -> str: ...
def render_width(self) -> int: ...
def display(obj: object) -> None:
if isinstance(obj, Renderable):
# Here, Deppy narrows obj's type:
# obj : object → obj : Renderable
# This is justified by the DuckPath that isinstance checks
print(obj.render())
# ✅ Safe: obj.render() is valid because isinstance verified
# the structural conformance at runtime
else:
print(repr(obj))
# Deppy's verification of 'display':
# Branch 1: isinstance(obj, Renderable) == True
# → DuckPath[type(obj), Renderable] exists at runtime
# → obj.render : (int?) → str is valid
# → print(obj.render()) : None ✓
# Branch 2: isinstance(obj, Renderable) == False
# → repr(obj) : str always valid ✓
@runtime_checkable only checks method existence, not
signatures. Deppy's DuckPath goes further by also verifying signature
compatibility and behavioral guarantees. A runtime isinstance
check gives you existence; Deppy gives you full correctness.
Exercises
Hashable Protocol with
__hash__ and __eq__, along with the guarantee that
a == b → hash(a) == hash(b). Then create a Point
class with x and y fields, implement
__hash__ and __eq__, and verify that the DuckPath
is valid. What happens if you implement __hash__ as
return hash(self.x) (ignoring y)?
Serializable Protocol
with to_json(self) → str and
from_json(cls, data: str) → Self. Verify that a
Config class with nested dict data satisfies it.
Note the classmethod: how does Deppy handle cls parameters
in DuckPaths?
DynamicProxy class that
uses __getattr__ to delegate to a target. Show that Deppy produces a ConditionalDuckPath. Then write a wrapper function
that creates a proxy and passes it to a function expecting a
Renderable — what proof obligations does Deppy generate at
the call site?
__next__ always
raises StopIteration. Define a ReusableIterable
Protocol that requires __iter__ to return a fresh
iterator each time. Show that list satisfies this Protocol
but a generator expression does not. What does the failed DuckPath look like?