Skip to content

Design: adopt the EnumMeta/EnumSchema opt-in registration pattern for Protocol, Engine, Reassembly and TraceFlow #514

Description

@JarryShaw

Design proposal, filed to iterate on rather than as a ready change. All figures measured against 27bb315d5.

Observation: the mechanism already exists and has zero users

Every one of the four ABCs already auto-registers its subclasses via __init_subclass__. Every built-in declines it by inheriting a parallel *Base class instead:

public class (auto-registers) descendants non-registering base descendants
Protocol 0 ProtocolBase 43
Engine 0 EngineBase 9
Reassembly 0 ReassemblyBase 5
TraceFlow 0 TraceFlowBase 2
EnumSchema 402

So the auto-registering half of each pair is a 0-user API, while EnumSchema — same idea, different implementation — has 402 registered descendants.

The opt-out is done by aliasing at import:

# pcapkit/foundation/engines/pcap.py:13
from pcapkit.foundation.engines.engine import EngineBase as Engine

Why this matters beyond tidiness: it has caused two real defects

#506's root cause. pcapkit/protocols/schema/schema.py:669 did isinstance(data, Protocol) where it meant ProtocolBase. Commit 680f84d4e ("revised Protocol metaclass") rewrote that import to ProtocolBase as Protocol in 20 schema/** modules but missed this one, because it is a runtime import inside pack() rather than a module-level TYPE_CHECKING one. With Protocol having 0 descendants, the branch was unreachable for three yearsIPv4.make(payload=<TCP instance>) raised ProtocolUnbound despite its own signature accepting Protocol.

#513. extraction.py:390/412/434 validate issubclass(x, Engine | Reassembly | TraceFlow), so all three public register_extractor_* functions reject pcapkit's own built-ins.

Two independent failures, one cause: a public class and a base class that differ only in whether they register, where the base is what everything actually uses.

The difference from EnumSchema is two lines

# Reassembly.__init_subclass__          pcapkit/foundation/reassembly/reassembly.py:515-520
if protocol is None:
    protocol = cast('str', cls.name)          # invent a key...
Extractor.register_reassembly(protocol.lower(), cls)   # ...then register ALWAYS
# EnumSchema.__init_subclass__          pcapkit/protocols/schema/schema.py:1028-1033
if code is not None:                          # register ONLY when asked
    cls.__enum__[code] = cls

EnumSchema guards. The four ABCs fall back. That inversion is the whole reason a parallel hierarchy is needed to decline registration — and the reason cls.name gets used as a registry key, which is itself surprising: class X(Reassembly, name='zzz') registers under 'x'-style lowercased class name, silently, because name= is swallowed by **kwargs (see the docstring fix for name vs protocol).

The pattern being proposed, as EnumMeta/EnumSchema implements it

Four parts, and the four ABCs already have three of them:

  1. A metaclass over abc.ABCMeta. SchemaMeta(abc.ABCMeta) at schema.py:135, EnumMeta(SchemaMeta, Generic[_ET]) at :915. All four ABCs already have one — ProtocolMeta, EngineMeta, ReassemblyMeta, TraceFlowMeta, all ABCMeta subclasses.
  2. A class-level registry property on the metaclass (schema.py:933-935), an immutable proxy to __enum__. This is the part that needs the metaclass — a property in the class body would be an instance property, so MySchema.registry would not work. None of the four ABCs has this.
  3. Opt-in registration in __init_subclass__(cls, /, code=None, *args, **kwargs), register only when code is given. All four ABCs already take an optional keyword (schema=None, name=None, protocol=None, protocol=None); only the guard differs.
  4. An explicit register() classmethod (schema.py:1038) coexisting with the automatic path. All four already have their register_* equivalents.

Plus a lazy per-root registry so each namespace owns its own mapping:

if not hasattr(cls, '__enum__'):
    cls.__enum__ = collections.defaultdict(cls.__default__)

Proposed change, per class

  1. Invert the fallback into a guard: if protocol is None: super().__init_subclass__(); return. Built-ins then subclass the public class directly and simply omit the keyword — no *Base needed.
  2. Add a registry property to each metaclass, mirroring EnumMeta.registry, so Reassembly.registry reads like MySchema.registry.
  3. Keep *Base as an alias for backwards compatibility, but it stops being a mechanism.

What this buys: #513 disappears rather than needing its own patch; the EngineBase as Engine trick stops being load-bearing, so the next sweep like 680f84d4e's cannot half-miss it; and the four hierarchies come to match the one that already works at 402 subclasses.

The cost, stated plainly

This is a breaking change for external subclassers. Anyone who today writes class MyEngine(Engine) and relies on free registration under the class name would stop being registered unless they pass the keyword. In-tree the blast radius is zero — public-class descendants are 0 across all four — so only third-party code is affected. The 1.5.0 prerelease window is the right time to take it, with a changelog entry.

Open questions, which is why this is a design issue and not a PR

  1. Why do the built-ins opt out today? The aliasing is clearly deliberate but the reasoning is documented nowhere. Plausibly: registering at class-definition time would populate the registry in import order, making duplicate keys and circular imports live concerns, and the built-ins are already wired in by explicit table entries. If that reasoning is sound it should be captured before it is discarded — and it may argue for keeping the explicit tables and only fixing register_extractor_engine/reassembly/traceflow reject pcapkit's own built-in classes #513.
  2. Should cls.name remain a fallback key at all? Deriving a registry key from a class name is what makes the name=-swallowed bug silent instead of loud. Requiring the keyword makes it loud.
  3. Does Protocol still need to exist once ProtocolBase registers on request? Its docstring describes it as the subclass for "external customised engines with auto registration", which the guard form makes unnecessary.
  4. Ordering. EnumSchema registers into a per-root defaultdict owned by the schema tree; the four ABCs register into Extractor, a different module, via a deferred import inside __init_subclass__. Whether that indirection survives the change is worth settling before coding.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    designA design or decision issue: a pattern being decided rather than a defect or a request

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions