Metadata-Version: 2.4
Name: pymainsequence
Version: 0.1.0
Summary: Package dependency metrics for Python — Abstractness, Instability, Distance from Main Sequence
License: MIT
License-File: LICENSE
Keywords: abstraction,architecture,coupling,dependency,metrics
Requires-Python: >=3.12
Requires-Dist: click>=8.0
Requires-Dist: rich>=13.0
Description-Content-Type: text/markdown

# PyMainSequence

Package dependency metrics for Python — **Abstractness**, **Instability**, and **Distance from the Main Sequence**, as described by Robert C. Martin (Uncle Bob) in *Clean Architecture*.

Think JDepend or NETDepend, but for Python.

---

## Metrics

| Metric            | Symbol | Formula                    | Range | Meaning                                          |
|-------------------|--------|----------------------------|-------|--------------------------------------------------|
| Afferent Coupling | Ca     | —                          | ≥ 0   | Components that depend **on** this one (fan-in)  |
| Efferent Coupling | Ce     | —                          | ≥ 0   | Components this one depends **on** (fan-out)     |
| Instability       | I      | `Ce / (Ca + Ce)`           | 0–1   | 0 = maximally stable, 1 = maximally unstable     |
| Abstractness      | A      | `abstract / total classes` | 0–1   | 0 = fully concrete, 1 = fully abstract           |
| Distance          | D      | `\|A + I - 1\|`            | 0–1   | Distance from the Main Sequence; 0 = on the line |

**The Main Sequence** is the line `A + I = 1`. Components on it balance stability with abstraction.
- **Zone of Pain** (I≈0, A≈0): stable but fully concrete — hard to change, painful to extend.
- **Zone of Uselessness** (I≈1, A≈1): unstable and fully abstract — nobody depends on it.

Abstract classes, `abc.ABC` subclasses, `@abstractmethod` methods, and `typing.Protocol` subclasses all count toward abstractness.

---

## Installation

```bash
pip install pymainsequence
# or with uv:
uv add pymainsequence
```

Requires Python 3.12+.

---

## CLI

### `pymainsequence analyze`

Print metrics for every component (package) found under a path:

```
$ pymainsequence analyze ./src

╭───────────┬────┬────┬──────┬──────┬──────┬─────────╮
│ Component │ Ca │ Ce │    I │    A │    D │ Classes │
├───────────┼────┼────┼──────┼──────┼──────┼─────────┤
│ api       │  0 │  2 │ 1.00 │ 0.00 │ 0.00 │       1 │
│ core      │  1 │  1 │ 0.50 │ 0.50 │ 0.00 │       2 │
│ utils     │  2 │  0 │ 0.00 │ 0.00 │ 1.00 │       1 │
╰───────────┴────┴────┴──────┴──────┴──────╯─────────╯
3 component(s)
```

Distance is colour-coded: green (D ≤ 0.3), yellow (D ≤ 0.5), red (D > 0.5).

**Options:**

| Flag | Default | Description |
|---|---|---|
| `--depth N` | `1` | Package nesting depth treated as components |
| `--include-external` | off | Count stdlib/third-party imports in Ce |
| `--format table\|json` | `table` | Output format |

```bash
pymainsequence analyze ./src --depth 2 --format json
```

### `pymainsequence check`

Assert constraints and exit non-zero on violations — designed for CI:

```bash
pymainsequence check ./src --max-distance 0.5 --max-instability 0.8
# exit 0 on pass, exit 1 on violation
```

**Options:**

| Flag | Description |
|---|---|
| `--max-distance F` | Maximum allowed D per component |
| `--max-instability F` | Maximum allowed I per component |
| `--max-ce N` | Maximum allowed efferent coupling |
| `--no-dep FROM TO` | Forbidden dependency (repeatable) |

```bash
pymainsequence check ./src --no-dep core api --no-dep core infra
```

---

## Programmatic API

```python
from pymainsequence import analyze

report = analyze("./src")

for component in report:
    print(f"{component.name}: I={component.instability:.2f} A={component.abstractness:.2f} D={component.distance:.2f}")

core = report.get("core")
print(core.ca, core.ce, core.afferent, core.efferent)
```

`analyze()` accepts an optional `depth` (default `1`) and `include_external` (default `False`).

---

## Constraint API

Use `enforce()` to write architectural tests with pytest (or any test runner):

```python
from pymainsequence import analyze
from pymainsequence.rules import enforce

def test_architecture():
    report = analyze("./src")

    enforce(report) \
        .max_instability(0.8) \
        .max_distance(0.5) \
        .no_dependency("core", "api") \
        .check()
```

Or as a context manager:

```python
def test_architecture():
    report = analyze("./src")
    with enforce(report) as e:
        e.max_instability(0.8)
        e.max_distance(0.5)
        e.no_dependency("core", "api")
    # raises ViolationError with a full list of offenders on exit
```

### Available constraints

| Method | Description |
|---|---|
| `.max_instability(threshold)` | I ≤ threshold for all (or one) component |
| `.max_distance(threshold)` | D ≤ threshold |
| `.max_abstractness(threshold)` | A ≤ threshold |
| `.max_ce(n)` | Efferent coupling ≤ n |
| `.max_ca(n)` | Afferent coupling ≤ n |
| `.stable_abstractions_principle(threshold=0.5)` | Alias for `max_distance` |
| `.no_dependency(from, to)` | No direct dependency from → to |
| `.check()` | Raise `ViolationError` listing all violations |

All methods accept an optional `component="name"` keyword to scope the check to a single component.

---

## How components are determined

A **component** is a Python package (directory with `__init__.py`) or a top-level `.py` file directly under the analyzed path. At `--depth 1` (default), only immediate sub-packages are components; files nested deeper still belong to their parent component.

Only imports between components within the analyzed path are counted. Standard library and third-party imports are ignored by default (`--include-external` to change this).

Relative imports (`from . import x`, `from ..utils import y`) are resolved to their absolute module paths before matching.

---

## License

MIT