Part 3: OOP, Dataclasses and Protocols

Introduction

ansible-inspec has two major subsystems that need to share a common interface: the Ansible adapter (which builds playbooks and inventories) and the InSpec adapter (which parses Ruby-based profiles). Both must be swappable for testing purposes — I can inject a fake adapter in tests without touching real inventory files or Ruby parsers.

That design lives on three Python OOP features: @dataclass, Protocol, and standard class inheritance. This part covers each.


Classes and __init__

The fundamental building block:

class ProfileConverter:
    """Converts an InSpec profile directory into an Ansible collection."""

    # Class variable — shared across all instances
    supported_resources: list[str] = [
        "file", "service", "package",
        "security_policy", "registry_key", "audit_policy",
    ]

    def __init__(self, profile_path: str, namespace: str = "local") -> None:
        self.profile_path = profile_path
        self.namespace = namespace
        self._controls: list[dict] = []   # private by convention

    def load(self) -> None:
        """Parse controls from the profile directory."""
        import os
        for root, _, files in os.walk(self.profile_path):
            for f in files:
                if f.endswith(".rb"):
                    self._parse_file(os.path.join(root, f))

    def _parse_file(self, path: str) -> None:
        """Internal — parse a single .rb control file."""
        with open(path) as f:
            content = f.read()
        # Simplified: real impl does Ruby AST parsing
        self._controls.append({"path": path, "raw": content})

    def convert(self) -> dict:
        """Return the Ansible collection structure as a dict."""
        if not self._controls:
            self.load()
        return {
            "namespace": self.namespace,
            "controls": self._controls,
        }

Inheritance

The converter has multiple output targets. Inheritance lets us share the parsing logic:

super() — calling parent methods


@dataclass

@dataclass auto-generates __init__, __repr__, __eq__ from field annotations. I use dataclasses for lightweight value objects that don't need Pydantic's validation overhead:

@dataclass(frozen=True) — immutable data

@dataclass vs Pydantic

@dataclass

Pydantic BaseModel

Validation

None (you add it manually)

Automatic on assignment

JSON serialise

Manual

.model_dump_json()

Performance

Very fast

Slightly more overhead (still fast with v2)

Use for

Internal value objects

API request/response, config


Protocol — Structural Subtyping

Protocol is Python's duck-typing formalised. Instead of requiring isinstance checks, you define the interface:

This is exactly how ansible-inspec separates the real Ansible adapter from test doubles.


Abstract Base Classes (ABC)

When you want to enforce that subclasses implement certain methods at class definition time (not at call time), use ABC:

ABC vs Protocol

ABC

Protocol

Enforcement

At class definition (TypeError on instantiation)

At type-check time (mypy/pyright)

Inheritance required

Yes

No — structural match is enough

Runtime check

isinstance(x, ABC)

isinstance(x, Protocol) with @runtime_checkable

Use for

Shared base with concrete helpers

Interface contract without coupling


Class Methods and Static Methods


Properties


Putting It Together

Here is a simplified slice of how ansible-inspec's adapter layer is structured:


Summary

Concept
When to use

Plain class

When you need methods and shared state

@dataclass

Lightweight value objects; auto-generates boilerplate

@dataclass(frozen=True)

Immutable value objects, safe dict keys

ABC

Shared base with enforced abstract interface

Protocol

Interface-only contract without inheritance coupling

@classmethod

Alternative constructors

@staticmethod

Utility functions tied to the class logically

@property

Computed attributes with validation on set


What's Next

Part 4 covers asyncio, async/await, and how ansible-inspec's FastAPI server handles concurrent compliance job execution without blocking the event loop.

Last updated