# Why did my plugin registry lose a format after an edit?

> Chimera CH-03 (Python (__init_subclass__ registry)): Why did my plugin registry lose a format after an edit?

A base class registers every subclass in `__init_subclass__`, a package `__init__.py` imports the submodules so the registration runs, and the CLI looks the class up by a string key. It is compact, and adding a format is one new file. It also means the list of formats and the signature of the call live in places nobody reads together. Build the registry in one module from a literal tuple of classes, give the exporters a `Protocol`, and type the keys with `Literal`.

The code below is from a small example project we wrote for this page. The breaks below are read from the files, by line; the type-checker results quoted in section 5 come from real pyright and tsc runs on this code.

## 1. The setup

The pattern has real strengths. A new exporter needs no edit to a central list, the hook runs exactly when the class statement runs, and the CLI stays short. The package has CSV, JSON and XLSX exporters and a CLI that turns a format string into bytes.

```python
# before/exporters/base.py
REGISTRY = {}

class Exporter:
    def __init_subclass__(cls, key=None, **kwargs):      # line 7
        super().__init_subclass__(**kwargs)
        if key is not None:
            REGISTRY[key] = cls                          # line 10
```

```python
# before/exporters/__init__.py
from exporters import csv_exporter  # noqa: F401        # line 5
from exporters import json_exporter  # noqa: F401       # line 6
from exporters import xlsx_exporter  # noqa: F401       # line 7
```

```python
# before/cli.py
def run(fmt, rows):
    return REGISTRY[fmt]().export(rows)                  # line 8
```

Each exporter declares its key in the class statement, for example `class CsvExporter(Exporter, key="csv")` at `csv_exporter.py:7`.

## 2. Where the call path hides

- **The registration is a side effect of a class statement.** The Python data model says `__init_subclass__` is called whenever the containing class is subclassed, and that keyword arguments given to the new class are passed to it. No line assigns the CSV entry into `REGISTRY`; it happens when `csv_exporter.py` is executed.
- **The key is a string in two places.** `key="csv"` in the class statement and `fmt` in the CLI are joined only at runtime.
- **The import that makes it work looks unused.** Lines 5 to 7 of `__init__.py` carry `noqa: F401`, because nothing in the file uses the names. Python executes a submodule on first import and caches it in `sys.modules`, so deleting one line changes what gets registered while every other file stays the same.
- **The call resolves to the base class.** `REGISTRY[fmt]()` has no static type, so `.export(rows)` at `cli.py:8` reads as `Exporter.export` at `base.py:12`. The three overrides are not on a path a tool can follow.

## 3. The edit that breaks it, and which call is left wrong

One customer needs latin-1 CSV files, so `CsvExporter.export` gains a keyword-only argument:

```python
# before/exporters/csv_exporter.py (edit applied)
    def export(self, rows, *, encoding):                 # line 10 (was: def export(self, rows):)
```

The edit is finished in the file it touches. The tests that call the class directly were updated with it: `tests/test_exporters.py:8` and `:14` pass `encoding=`, and both are correct. The call left wrong is `cli.py:8`, `REGISTRY[fmt]().export(rows)`. For the key `"csv"` it still passes one argument, and `encoding` has no default, so the first CSV export from the CLI raises `TypeError` for the missing keyword. JSON and XLSX still work, so a smoke test on either passes.

No test reaches that call with the CSV key. `tests/test_cli.py:8` runs `cli.run("json", ...)`, and the CSV case at `:16` is written as the test that would catch it, marked `xfail` with the reason. Nothing was run to confirm that; it is read from the signature and the call.

A second edit has the same shape. If `__init__.py:7` (the XLSX import) is deleted during a cleanup of unused imports, `REGISTRY` no longer has `"xlsx"`. Every other line is untouched, and `cli.py:8` raises `KeyError: 'xlsx'` at runtime. A test that imports `exporters.xlsx_exporter` directly would run the class statement and register the key again, so whether the bug shows depends on test order and import history.

## 4. The fix

One module owns the list of formats. The classes are named in a literal tuple, the keys are a `Literal`, and the contract is a `Protocol`. Settings that vary by customer, like encoding, move to `__init__`, so `export(rows)` keeps one signature for every format.

```diff
--- before/exporters/base.py
+++ after/exporters/base.py
-REGISTRY = {}
-
-
-class Exporter:
-    def __init_subclass__(cls, key=None, **kwargs):
-        super().__init_subclass__(**kwargs)
-        if key is not None:
-            REGISTRY[key] = cls
-
-    def export(self, rows):
-        raise NotImplementedError
+FormatKey = Literal["csv", "json", "xlsx"]
+Row = Mapping[str, object]
+
+
+class Exporter(Protocol):
+    key: ClassVar[FormatKey]
+
+    def export(self, rows: Sequence[Row]) -> bytes: ...
```

```diff
--- before/exporters/csv_exporter.py
+++ after/exporters/csv_exporter.py
-class CsvExporter(Exporter, key="csv"):
-    def export(self, rows, *, encoding):
+class CsvExporter:
+    key: ClassVar[FormatKey] = "csv"
+
+    def __init__(self, encoding: str = "utf-8") -> None:
+        self.encoding = encoding
+
+    def export(self, rows: Sequence[Row]) -> bytes:
```

```diff
--- before/exporters/__init__.py
+++ after/exporters/registry.py
-from exporters import csv_exporter  # noqa: F401
-from exporters import json_exporter  # noqa: F401
-from exporters import xlsx_exporter  # noqa: F401
+EXPORTER_CLASSES: tuple[type[Exporter], ...] = (
+    CsvExporter,
+    JsonExporter,
+    XlsxExporter,
+)
+REGISTRY: dict[FormatKey, type[Exporter]] = {cls.key: cls for cls in EXPORTER_CLASSES}
```

```diff
--- before/cli.py
+++ after/cli.py
-def run(fmt, rows):
+def run(fmt: FormatKey, rows: Sequence[Row]) -> bytes:
     return REGISTRY[fmt]().export(rows)
```

## 5. Why the fix works

- **The registry is code you can read.** `EXPORTER_CLASSES` in `registry.py` names each class. The imports are used, so there is no `noqa: F401` line to delete, and a missing format is a missing name in a tuple.
- **The contract is stated once.** `Exporter` is a `Protocol`, which the typing docs describe as structural subtyping checked by static type checkers. The exporters don't inherit from it, so each is a plain class a language server can rename.
- **A signature change is a type error.** If someone adds a required keyword to `CsvExporter.export`, the tuple entry no longer matches `type[Exporter]`. Checked with pyright 1.1.414 in strict mode: the tuple type-checks cleanly as provided, and after adding a required keyword-only `encoding` to `CsvExporter.export` pyright reports `registry.py:10:5` ("Extra parameter `encoding`", `CsvExporter` incompatible with protocol `Exporter`) and does not flag `cli.py`. That is the point of the fix: the typed registry moves the error from a runtime `TypeError` at the call to a static error at the registration line, before any CLI call runs. It depends on a checker running in CI.
- **Keys are checked.** `Literal` tells type checkers the value must be one of the listed strings; it does not validate at runtime, so `parse_format` in `registry.py` rejects other input with a `ValueError`.
- **Per-customer settings have a home.** `CsvExporter(encoding="latin-1")` is a constructor call, `after/tests/test_exporters.py` tests it, and the CLI path stays unchanged.

## 6. Rules that apply

- **AR-06**: keep import side effects out of modules; registration happens in an explicit setup call. In `after/` the registry is built by ordinary code in one module.
- **AR-09**: for open sets, register explicitly into a typed registry next to a typed contract.
- **AR-21**: keep `__init_subclass__` generation out of edit paths; when you use it, document what it generates.

## 7. What LLMGRAPH shows

A bare `export` matches four definitions, so the question is asked about the class methods. LLMGRAPH names the registry call in `cli.py` as a possible caller it can't prove, before and after the fix, and it proves the other callers it can resolve.

```text
who_calls Exporter.export (before)
llmgraph · who_calls Exporter.export (exporters/base.py:12): 1 open
  ⚠ maybe_more cli.py:8 run (open: type unknown) w1
  – ruled out: 3 same-name calls (proven: other target) w2

who_calls CsvExporter.export (before)
llmgraph · who_calls CsvExporter.export (exporters/csv_exporter.py:10): 2 certain, 1 open
  ✓ certain tests/test_exporters.py:8 test_csv_exporter_writes_header_and_rows w3
  ✓ certain tests/test_exporters.py:14 test_csv_exporter_honours_encoding w4
  ⚠ maybe_more cli.py:8 run (open: type unknown) w5
  – ruled out: 1 same-name call (proven: other target) w6

who_calls CsvExporter.export (after)
llmgraph · who_calls CsvExporter.export (exporters/csv_exporter.py:15): 1 certain, 1 open
  ✓ certain tests/test_exporters.py:12 test_csv_exporter_honours_encoding w3
  ⚠ maybe_more cli.py:9 run (open: type unknown) w4
```

The `maybe_more` label on `cli.py` is the cue to read that call site before you change the signature, because `REGISTRY[fmt]()` has no static type; the fix does not turn it into a certain caller.

## 8. References

Python (fetched 2026-10-30):
- Data model, `object.__init_subclass__` (called when the class is subclassed, implicit classmethod, class keywords passed through): https://docs.python.org/3/reference/datamodel.html#object.__init_subclass__
- Import system (parent packages executed on submodule import, results cached in `sys.modules`): https://docs.python.org/3/reference/import.html
- `typing.Protocol` and `typing.Literal`: https://docs.python.org/3/library/typing.html#typing.Protocol


## Changelog

- 2026-10-30: LLMGRAPH capture added (Lead-checked)
- 2026-10-30: tool checks resolved (tsc 7.0.2, pyright 1.1.414)
- 2026-10-30: copy-gate wording fixes

## Rules it tests

- [AR-06: Why does my agent not see what importing this module does?](https://beta.llmgraph.in/agent-readable/ar-06.md)
- [AR-09: How should plugin registries be written for coding agents?](https://beta.llmgraph.in/agent-readable/ar-09.md)
- [AR-21: Do metaclasses hide code from coding agents?](https://beta.llmgraph.in/agent-readable/ar-21.md)

Last verified 2026-10-30
