For AI agentsA Markdown index of these docs is at /llms.txt. Every page is also Markdown: add .md to its address.

Agent Rules / Chimeras

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

Chimera CH-03 · Python (__init_subclass__ registry)

Rules it tests: AR-06, AR-09, AR-21

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.

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

Last verified

For agentsthis page as Markdown/agent-readable/ch-03.mdllms.txt