Skip to content

Fitness Functions API Reference

Architecture fitness functions: measure component health with Robert Martin's Abstractness / Instability / Distance metrics, adapted for Python. Drives the istos analyze command; also importable for custom checks.

Architecture fitness functions for measuring component health.

Implements Robert Martin's Abstractness / Instability / Distance metrics as described in "Software Architecture: The Hard Parts" (ch. 4), adapted for Python: a type counts as abstract when it is a typing.Protocol, an abc.ABC, or carries an @abstractmethod -- the real seams of a Python codebase, rather than Java-style interfaces.

A component is a top-level subpackage or module of the package under analysis (e.g. istos.consistency, istos.routing), never an individual line or class. For each component we measure:

Ce  efferent coupling   other components it imports from
Ca  afferent coupling   other components that import it
I   instability         Ce / (Ce + Ca)          0 = stable .. 1 = unstable
A   abstractness        abstract types / all types
D   distance            |A + I - 1|             0 = on the main sequence

Distance is diagnostic, not a score to drive to zero. Concrete leaf utilities in Python legitimately sit at A=0, I=0 (D=1); that only signals pain when the component is also large and volatile. Read the numbers, don't optimise them.

Component dataclass

Health metrics for one package-level component.

Source code in src/istos/fitness.py
@dataclass
class Component:
    """Health metrics for one package-level component."""

    name: str
    efferent: int          # Ce
    afferent: int          # Ca
    abstract_types: int    # m_a
    concrete_types: int    # m_c
    loc: int
    modules: int

    @property
    def instability(self) -> float:
        total = self.efferent + self.afferent
        return self.efferent / total if total else 0.0

    @property
    def abstractness(self) -> float:
        total = self.abstract_types + self.concrete_types
        return self.abstract_types / total if total else 0.0

    @property
    def distance(self) -> float:
        return abs(self.abstractness + self.instability - 1.0)

    @property
    def zone(self) -> str:
        if self.distance <= MAIN_SEQUENCE_TOLERANCE:
            return "balanced"
        if self.abstractness + self.instability > 1:
            return "uselessness"
        if self.loc >= PAIN_LOC and self.afferent >= PAIN_CA:
            return "pain"
        return "leaf"

    def as_dict(self) -> dict:
        return {
            "name": self.name,
            "modules": self.modules,
            "loc": self.loc,
            "ce": self.efferent,
            "ca": self.afferent,
            "instability": round(self.instability, 3),
            "abstractness": round(self.abstractness, 3),
            "distance": round(self.distance, 3),
            "zone": self.zone,
        }

Report dataclass

The full result of analysing a package.

Source code in src/istos/fitness.py
@dataclass
class Report:
    """The full result of analysing a package."""

    package: str
    components: List[Component]
    cycles: List[List[str]]
    god_modules: List[Tuple[str, int]]   # (dotted module, loc)

    @property
    def mean_distance(self) -> float:
        return sum(c.distance for c in self.components) / len(self.components) if self.components else 0.0

    def as_dict(self) -> dict:
        return {
            "package": self.package,
            "mean_distance": round(self.mean_distance, 3),
            "components": [c.as_dict() for c in self.components],
            "cycles": self.cycles,
            "god_modules": [{"module": m, "loc": n} for m, n in self.god_modules],
        }

analyze(path, package=None)

Analyse a package and return per-component health metrics.

Source code in src/istos/fitness.py
def analyze(path: Path, package: Optional[str] = None) -> Report:
    """Analyse a package and return per-component health metrics."""
    pkg_name, pkg_dir = find_package(path, package)

    abstract: Dict[str, int] = {}
    concrete: Dict[str, int] = {}
    loc: Dict[str, int] = {}
    modules: Dict[str, int] = {}
    edges: Dict[str, Set[str]] = {}
    god_modules: List[Tuple[str, int]] = []

    for f in sorted(pkg_dir.rglob("*.py")):
        rel = f.relative_to(pkg_dir)
        # Skip the package facade: it re-exports rather than being a component.
        if rel == Path("__init__.py"):
            continue
        comp = _component_of_file(rel)
        try:
            tree = ast.parse(f.read_text(encoding="utf-8"))
        except (SyntaxError, UnicodeDecodeError):
            continue

        file_loc = sum(1 for line in f.read_text(encoding="utf-8").splitlines() if line.strip())
        loc[comp] = loc.get(comp, 0) + file_loc
        modules[comp] = modules.get(comp, 0) + 1
        abstract.setdefault(comp, 0)
        concrete.setdefault(comp, 0)
        edges.setdefault(comp, set())
        if file_loc > GOD_MODULE_LOC:
            dotted = pkg_name + "." + ".".join(rel.with_suffix("").parts)
            god_modules.append((dotted, file_loc))

        abstract[comp] += _callable_aliases(tree)
        tc_lines = _type_checking_lines(tree)
        for node in ast.walk(tree):
            if isinstance(node, ast.ClassDef):
                if _is_abstract(node):
                    abstract[comp] += 1
                else:
                    concrete[comp] += 1
            elif isinstance(node, (ast.Import, ast.ImportFrom)):
                if node.lineno in tc_lines:
                    continue
                for dotted in _imported_modules(node, rel, pkg_name):
                    target = _component_of_module(dotted, pkg_name)
                    if target and target != comp:
                        edges[comp].add(target)

    afferent: Dict[str, Set[str]] = {c: set() for c in edges}
    for src, targets in edges.items():
        for t in targets:
            afferent.setdefault(t, set()).add(src)

    components = [
        Component(
            name=c,
            efferent=len(edges.get(c, set())),
            afferent=len(afferent.get(c, set())),
            abstract_types=abstract.get(c, 0),
            concrete_types=concrete.get(c, 0),
            loc=loc.get(c, 0),
            modules=modules.get(c, 0),
        )
        for c in sorted(set(loc) | set(edges) | set(afferent))
    ]
    # Drop empty placeholder packages (no code, no coupling) -- they carry no signal.
    components = [
        c for c in components
        if c.loc or c.abstract_types or c.concrete_types or c.efferent or c.afferent
    ]
    components.sort(key=lambda c: c.distance, reverse=True)

    return Report(
        package=pkg_name,
        components=components,
        cycles=_find_cycles(edges),
        god_modules=sorted(god_modules, key=lambda x: x[1], reverse=True),
    )

find_package(path, package=None)

Locate the importable package to analyse under path.

Looks for src/<pkg>/__init__.py first (the src layout), then a top-level <pkg>/__init__.py. Pass package to disambiguate when a project ships more than one.

Source code in src/istos/fitness.py
def find_package(path: Path, package: Optional[str] = None) -> Tuple[str, Path]:
    """Locate the importable package to analyse under ``path``.

    Looks for ``src/<pkg>/__init__.py`` first (the src layout), then a
    top-level ``<pkg>/__init__.py``. Pass ``package`` to disambiguate when a
    project ships more than one.
    """
    path = path.resolve()
    if path.is_file():
        raise ValueError(f"{path} is a file; pass a project or package directory")

    if (path / "__init__.py").exists():
        return path.name, path

    roots = [path / "src", path]
    candidates: List[Path] = []
    for root in roots:
        if root.is_dir():
            candidates += [p.parent for p in root.glob("*/__init__.py")]
        if candidates:
            break

    if package:
        for c in candidates:
            if c.name == package:
                return package, c
        raise ValueError(f"package {package!r} not found under {path}")
    if not candidates:
        raise ValueError(f"no importable package found under {path}")
    if len(candidates) > 1:
        names = ", ".join(sorted(c.name for c in candidates))
        raise ValueError(f"multiple packages found ({names}); pass --package to choose")
    return candidates[0].name, candidates[0]

format_report(report)

Render a report as a human-readable table.

Source code in src/istos/fitness.py
def format_report(report: Report) -> str:
    """Render a report as a human-readable table."""
    lines = [f"Architecture health for '{report.package}'  (main sequence: A + I = 1)", ""]
    header = f"{'component':<16}{'mods':>5}{'loc':>6}{'Ce':>4}{'Ca':>4}{'I':>7}{'A':>7}{'D':>7}  zone"
    lines.append(header)
    lines.append("-" * len(header))
    for c in report.components:
        lines.append(
            f"{c.name:<16}{c.modules:>5}{c.loc:>6}{c.efferent:>4}{c.afferent:>4}"
            f"{c.instability:>7.2f}{c.abstractness:>7.2f}{c.distance:>7.2f}  {c.zone}"
        )
    lines.append("-" * len(header))
    lines.append(f"mean D = {report.mean_distance:.3f} over {len(report.components)} components")

    if report.cycles:
        lines.append("")
        lines.append("Dependency cycles (break these -- they defeat modular deployment):")
        for scc in report.cycles:
            lines.append("  " + " -> ".join(scc) + " -> " + scc[0])
    if report.god_modules:
        lines.append("")
        lines.append(f"God-module candidates (> {GOD_MODULE_LOC} loc):")
        for mod, n in report.god_modules:
            lines.append(f"  {mod}  ({n} loc)")

    lines.append("")
    lines.append(
        "Zones: 'balanced' near the main sequence; 'leaf' a small stable concrete "
        "utility (fine); 'pain' a large, widely-imported concrete blob (extract "
        "seams); 'uselessness' an abstraction with no callers (delete it)."
    )
    return "\n".join(lines)