Volume 0 — Foundations of Automation Engineering with Python — contents
  1. Chapter 1 — The CodeCraftX Philosophy
  2. Chapter 2 — Coding Standards as a Tool, Not a Style Guide
  3. Chapter 3 — The Error Vocabulary
  4. Chapter 4 — Interfaces and Contracts
  5. Chapter 5 — Waiting with Rich Diagnostics
  6. Chapter 6 — Application Identity and Presence
  7. Chapter 7 — Extension and Factory Machinery
  8. Chapter 8 — Runtime Variables and Well-Known Paths
  9. Chapter 9 — Configuration Loading
  10. Chapter 10 — Credentials, Data Profiles, Storage Scaffolding

Chapter 4 — Interfaces and Contracts

An interface is a promise about behaviour without a commitment to implementation. The promise is the contract; the contract is what makes packages substitutable.

The previous chapter named the typed exception vocabulary that makes packages talk about errors in the same language. This chapter names the typed interface vocabulary that makes packages substitute for each other without their callers caring which implementation is on the other end.

The code is the ccai.interfaces subpackage of codecraftai-foundations. Eight files; eight protocols; one purpose: shared contract definitions that downstream packages implement and other downstream packages consume. The package itself contains no behaviour — only the typed shape of the behaviour.

Why protocols, not abstract base classes

Python has two main mechanisms for declaring interfaces:

  • Abstract base classes (abc.ABC with @abstractmethod) — the implementer inherits from the ABC. Type checkers verify the inheritance. Runtime instance checks (isinstance(x, MyABC)) are accurate.
  • Protocols (typing.Protocol) — structural typing. The implementer matches the protocol if it provides the required methods, regardless of inheritance. Type checkers verify the shape; runtime instance checks require runtime_checkable.

The portfolio's interfaces use Protocol uniformly. Two reasons:

  1. Decoupling. An implementer that already inherits from another base (or that lives in a package that doesn't depend on codecraftai-foundations at the inheritance level) can still be an ICommandRunner if it has the right method shape. No forced inheritance; no diamond-inheritance problems.
  2. Substitutability. Test mocks and fakes don't need to inherit from the interface to type-check as it. A test builds a small class with the right methods and passes it wherever ICommandRunner is expected.

The cost of protocols: less rich runtime introspection. When the application needs runtime enforcement of an interface (rare in the portfolio), abstract base classes are the right choice. When the application needs structural checks at the type-checker level, protocols are. The portfolio uses protocols because the structural-check use case dominates.

The eight protocols

The shipped package has eight protocol files. This section walks each, anchored on the actual code.

ICommandRunner — running commands across system boundaries

icommandrunner.py defines the most-used protocol in the portfolio. Its job: provide a uniform shape for running a command, regardless of whether the command runs locally, over SSH, in a container, or via some other transport.

@dataclass
class RunResult:
    exit_code: Optional[int]
    stdout: str
    stderr: str
    timed_out: bool = False


class ICommandRunner(Protocol):
    def run(
        self,
        args: List[str],
        *,
        elevated: bool = False,
        cwd: Optional[str] = None,
        env: Optional[Dict[str, str]] = None,
        stdin: Optional[str] = None,
        timeout: Optional[float] = None,
        on_output: Optional[Callable[[str], None]] = None,
        on_error: Optional[Callable[[str], None]] = None,
        capture_output: bool = True,
    ) -> RunResult: ...

Eight keyword-only parameters. The positional parameter is the command (args as a list, not a string — explicit-is-better and shell-injection-safer). The keyword-only parameters are aspects of execution: elevated privilege, working directory, environment, stdin, timeout, output streaming callbacks, capture flag.

The shape is consistent across implementations:

  • A local LocalCommandRunner calls subprocess.run with the args.
  • A remote SshCommandRunner packages the args into an SSH command and runs them on the remote host.
  • A containerised DockerCommandRunner calls docker exec.
  • A test mock FakeCommandRunner returns canned RunResult values without running anything.

Code that needs to "run a command" takes an ICommandRunner parameter and gets to work without knowing which runner it received.

IFileOperator — filesystem operations across boundaries

ifileoperator.py is ICommandRunner's sibling for filesystem work. Five methods: write_text, delete, read_text, list_dir, exists. Each takes an elevated: bool parameter, mirroring the privilege discipline from the runner.

class IFileOperator(Protocol):
    def write_text(self, path: str, content: str, elevated: bool = False) -> None: ...
    def delete(self, path: str, elevated: bool = False) -> None: ...
    def read_text(self, path: str, elevated: bool = False) -> str: ...
    def list_dir(self, path: str, elevated: bool = False) -> List[str]: ...
    def exists(self, path: str, elevated: bool = False) -> bool: ...

Same substitutability: a LocalFileOperator works against the local filesystem; a SshFileOperator works against a remote filesystem over SSH; a S3FileOperator works against S3-compatible object storage. The caller doesn't care.

The minimalism is deliberate. Files have many possible operations (set permissions, change owner, create symlinks, copy ranges, append, truncate); the interface picks the five most universally needed and stops there. Implementers can offer more on their concrete classes; the interface only promises these five.

ISerializable — dict round-trip

iserializable.py is the smallest protocol in the package. Two methods:

class ISerializable(Protocol):

    @classmethod
    def from_dict(cls, data: Dict[str, Any]): ...

    def as_dict(self) -> Dict[str, Any]: ...

as_dict produces a dictionary representation; from_dict constructs an instance from one. The contract is round-trip: obj == obj.from_dict(obj.as_dict()) should hold for any serialisable object.

Code that needs to serialise heterogeneous objects (write to JSON, write to YAML, send over the wire to another process) takes ISerializable instances and gets to work. Code that needs to consume a configuration document constructs the right concrete class through from_dict.

The chapter-9 configuration loader uses this pattern: every typed configuration class implements ISerializable; the loader calls from_dict to construct instances from parsed YAML/JSON.

IBasicCredential — username + password

ibasiccredential.py is the smallest credential interface. Two read-only properties:

class IBasicCredential(Protocol):
    @property
    def username(self) -> str: ...

    @property
    def password(self) -> str: ...

Used by code that consumes basic credentials without caring which credential class produced them. A CouchDbProfile.connection_string(credobj: IBasicCredential) takes any object with .username and .password properties — a real BasicCredential from codecraftai-credentials, a test FakeBasicCredential, an inherited SshCredential that has both fields, etc.

The credential package's BasicCredential implements this protocol; CouchDbProfile consumes it. The implementing package doesn't depend on the consuming package; the consuming package doesn't depend on the implementing package; both depend on the interfaces package. This is the dependency-inversion discipline in action.

IIncludeFilter and IExcludeFilter — list filtering

iincludefilter.py and iexcludefilter.py provide a uniform interface for filtering lists of items. They are siblings:

class IIncludeFilter(Protocol):
    def should_include(self, check_object: Any) -> bool: ...
    def filter(self, input_list: List[Any]) -> List[Any]: ...

class IExcludeFilter(Protocol):
    def should_exclude(self, check_object: Any) -> bool: ...
    def filter(self, input_list: List[Any]) -> List[Any]: ...

The pair separates include and exclude semantics deliberately. An include filter says "keep these"; an exclude filter says "drop these." Composing the two — running both against the same list — gives a uniform "include unless excluded" pattern that works without conditional flags.

Both protocols ship a filter(input_list) default implementation that walks the list and applies should_include / should_exclude to each item. Implementers only need to override should_include (or should_exclude); the list walk is provided.

This is one of the few places where the protocol carries a default implementation. It works because Python's Protocol is permissive about whether members are defined; the structural check passes if the implementer either overrides or inherits the default.

IHint — friendly identification

ihint.py is a small protocol for objects that can produce a "hint" string matching them against another object:

class IHint(Protocol):
    def get_hint(self) -> str: ...
    def matches(self, obj: Any) -> bool: ...

Used by codecraftai-landscaping's FriendlyIdentifier class. A landscape's apod entries carry friendly names ("dev01", "runner-east-1") that match the actual host name through hint strings. The protocol formalises the relationship.

ITestJob — the test-job contract

itestjob.py is the largest of the eight protocols. It defines the contract for a test job — the unit of work codecraftai-testbase and its extensions execute:

class ITestJob(Protocol):
    @property
    def importerrors(self): ...
    def begin(self) -> None: ...
    def execute(self) -> int: ...
    def finalize(self) -> None: ...
    def query(self, query: str, format: int=0) -> List[Any]: ...

Five methods plus one property. The lifecycle is begin → execute → finalize; the property surfaces import errors that occurred during the job's load; the query method exposes introspection for tooling.

The protocol is the contract Volume 2's distributed test jobs implement (TestJob → InteropLibraryTestJob → DistributedTestJob). Each layer of the inheritance chain satisfies the protocol; consumers (test runners, reporters, schedulers) talk to whichever layer of the chain they have.

How the interfaces compose

The eight protocols are not independent — they compose into the larger contracts the higher tiers of the portfolio rely on.

Composition example: a sysman feature

A Volume 4 sysman feature operates on a per-host basis. Each host is represented by a HostContext that carries (among other things):

  • An ICommandRunner — to run commands on the host.
  • An IFileOperator — to read and write files on the host.
  • An IBasicCredential — to authenticate to the host.

The sysman feature's code takes a HostContext and uses the three interfaces. The same feature works against:

  • A local host (LocalCommandRunner + LocalFileOperator + a null credential).
  • A remote host over SSH (SshCommandRunner + SshFileOperator + SshCredential).
  • A containerised host (DockerCommandRunner + DockerFileOperator + a basic credential).
  • A mocked host in unit tests (FakeCommandRunner + InMemoryFileOperator + FakeCredential).

The feature's source code is identical in every case. The substitutability is what makes the cross-OS contract layer in Volume 4 work — the same feature implementation runs against every host because the per-host machinery is interface-shaped.

Composition example: a test runner

A test runner consumes:

  • An ITestJob — the job to run.
  • An IIncludeFilter and IExcludeFilter — to scope which tests within the job to execute.
  • An ISerializable — to serialise results.

The runner's code takes the four interfaces and runs. The same runner works against single-host jobs (TestJob), landscape-aware jobs (InteropLibraryTestJob), and distributed jobs (DistributedTestJob) — because all three implement ITestJob.

What the interfaces do not do

Three deliberate non-features.

Not runtime-enforced (typically)

The protocols are type-checker enforced, not runtime-enforced. A Python program can pass a non-implementer where an ICommandRunner is expected; the type checker complains; the runtime doesn't. When runtime enforcement is needed, the consuming code does an explicit isinstance check (with the protocol marked @runtime_checkable) or — more often — does duck-typing checks (hasattr(runner, 'run')).

This is consistent with Python's structural-typing tradition and with the philosophy's explicit-is-better-than-implicit principle. Runtime enforcement of an interface is additional rigour that costs runtime cycles; the type checker catches the common case at zero runtime cost.

Not behaviour, just shape

The protocols describe what methods exist with what signatures; they do not describe what those methods do. A malicious ICommandRunner implementation that returns random data instead of running the command satisfies the type contract; the runtime check would still pass; the bug is in the implementer's behaviour.

The portfolio mitigates this through testing — each concrete implementation has its own test suite that verifies the behaviour matches the documented contract. The interface package doesn't do that work; it shouldn't.

Not capability discovery

The interfaces don't declare what capabilities an implementation has beyond the methods it implements. A filesystem implementation that supports symlinks doesn't have a "supports_symlinks" flag on the interface; a runner that streams output doesn't have a "supports_streaming" flag.

When a consumer needs capability discovery, the answer is a separate capability protocol, not feature flags on a base protocol. This keeps the base protocols small and the discovery logic explicit.

How interfaces drive the dependency graph

The ccai.interfaces subpackage, shipped by codecraftai-foundations, sits at the bottom of the portfolio's dependency graph. Specifically:

               codecraftai-foundations  (ccai.interfaces)
                            ▲
                            │ imported by everyone
                            │
           ┌────────────────┼─────────────────┐
           │                │                 │
   codecraftai-credentials   codecraftai-       codecraftai-landscaping
           │              dataprofiles            │
           │                │                     │
           └────────────────┼─────────────────────┘
                            │
                            ▼
                codecraftai-interop, sysman, ...

Two important properties of this graph:

  • ccai.interfaces depends on nothing. Not even on ccai.errors, its sibling inside the same distribution. codecraftai-foundations is the leaf; it can be installed standalone.
  • Every package that wants to interoperate depends on codecraftai-foundations. The dependency is small (eight small files); the value is large (every package speaks the shared contract language).

When a package introduces a new interface, the right place is here, not in the introducing package's own surface. This keeps the interfaces centralised; consumers only need to import from one place.

When to add a new interface

Three signals that a new interface is the right move:

Signal 1: two implementations, one consumer

When a consuming package has one shape it wants to consume and two or more implementing packages produce that shape, the shape is an interface. The interface goes in ccai.interfaces; both implementers depend on the interfaces package; the consumer depends only on the interfaces package.

Signal 2: testing requires a substitute

When unit testing a package would benefit from a fake of some external dependency, the dependency's shape is a candidate interface. Testing the sysman feature without spawning real SSH sessions requires a fake ICommandRunner; making the fake easy to write requires ICommandRunner to be a protocol.

Signal 3: cross-tier substitutability

When a higher-tier package wants to plug a different implementation in for a lower-tier dependency (chapter-7's extension and factory machinery is the substantive form of this pattern), the seam is an interface.

The ccai.interfaces subpackage deliberately stays small. Eight files today; growth is deliberate. Each new interface goes through the question: do at least two existing or planned implementations want to satisfy this shape? If yes, add it; if no, don't add the interface yet.

Summary

  • ccai.interfaces, inside codecraftai-foundations, is the leaf of the portfolio's dependency graph. Eight protocol files; one purpose: shared contract definitions for substitutable behaviour.
  • The protocols are typing.Protocol (structural typing), not abc.ABC (nominal typing). Implementers don't inherit; they match by shape. The cost is less runtime introspection; the benefit is decoupling and easy mocking.
  • The eight protocols:
    • ICommandRunner — running commands across local, remote, containerised boundaries.
    • IFileOperator — filesystem operations with the same cross-boundary uniformity.
    • ISerializable — dict round-trip via from_dict and as_dict.
    • IBasicCredential — username + password read-only properties.
    • IIncludeFilter / IExcludeFilter — uniform list filtering with default implementations of filter().
    • IHint — friendly identification.
    • ITestJob — the test-job lifecycle contract used by Volume 1 and Volume 2.
  • The interfaces compose into larger contracts. A sysman feature consumes ICommandRunner + IFileOperator + IBasicCredential to operate on a host — local, remote, containerised, or mocked, all with the same code.
  • The protocols describe shape, not behaviour. Behaviour conformance is verified through each concrete implementation's test suite; the interface package itself contains no tests of behaviour.
  • New interfaces earn their place by satisfying at least two existing or planned implementations. The package stays small deliberately; growth is gated.

The next chapter introduces the waiting primitives — small helpers for the recurring problem of "wait for a condition" that turn the trivial loop into a debugged, observable, timeout-aware operation.