Semantic Axes Beyond (B, S): Why type() Needs Extension

TL;DR: type() defines only (B, S) as semantic axes. This proposes an opt-in axes={...} parameter to make framework metadata first-class and introspectable, without grammar changes. Legacy classes unchanged; opt-in classes get __axes__.

Per Guido van Rossum’s suggestion (personal email, Jan 6, 2026), posting to Typing for review.


The Problem

Python’s type(name, bases, namespace) has two semantic axes:

  • B (__bases__): inheritance hierarchy
  • S (__dict__): attributes and methods

Frameworks need more. In OpenHCS (microscopy automation), we need scope, registry membership, priority—none of which type() provides. The workaround is packing them into S:

class MyStep(Step):
    __scope__ = "/pipeline/step_0"      # framework axis packed into namespace
    __registry__ = "step_handlers"       # another axis packed into namespace

This works, but:

  1. Flattens independent axes into a single namespace, which loses per-axis inheritance and creates metadata/method collisions (orthogonality is a proven result, not an assumption. See Paper 1).
  2. Requires per-framework metaclass machinery
  3. Not uniformly introspectable
  4. A type checker can’t distinguish __scope__ (metadata) from scope() (method)

One immediate payoff of first-class axes is stronger type-based dispatch: frameworks can distinguish classes via axes + MRO without probing ad-hoc attributes at runtime.


Proposed Solution

Add an opt-in axes parameter to type():

MyStep = type("MyStep", (Step,), {"process": fn},
              axes={"scope": "/pipeline/step_0", "registry": STEP_REGISTRY})

MyStep.__axes__  # {"scope": "/pipeline/step_0", "registry": ...}

Key properties:

  • Opt-in: No axes = current behavior unchanged
  • No grammar change: use axes_type / with_axes today; class-statement keywords could be future sugar
  • Inheritance: Per-key MRO resolution, leftmost wins unless overridden
  • Not core identity: CPython’s isinstance/issubclass stay keyed on (B, S); axes are framework-level metadata

Why not just metaclasses? Metaclasses can stash metadata, but every framework invents its own dunders. A uniform __axes__ surface makes detection, tooling, and interop predictable.


Working Prototype

I have a working implementation:

from parametric_axes import axes_type, with_axes

MyStep = axes_type("MyStep", (Step,), {},
                   scope="/pipeline/step_0",
                   registry=STEP_REGISTRY)

MyStep.__axes__   # {"scope": "/pipeline/step_0", ...}
MyStep.__scope__  # convenience attribute

Features: inheritance works, __axes__ is a MappingProxyType, optional TypedDict schema for static checkers.

Prototype: GitHub - trissim/ObjectState: Generic lazy dataclass configuration framework with dual-axis inheritance and contextvars-based resolution (MIT)


Typing Interaction

  • Axes are runtime metadata, orthogonal to __annotations__
  • Static tools MAY read __axes__ to validate known keys via optional schema
  • Unknown axes are not type errors unless framework opts into validation
  • Tiny protocol for checkers: class HasAxes(Protocol): __axes__: Mapping[str, Any]

Current Positions (seeking feedback)

  • Extend type(): opt-in axes parameter is the core proposal; no new construct needed.
  • Schemas: framework-defined by default; a tiny optional standard schema could exist, but not required.
  • Static checkers: runtime-only by default; opt-in schema when provided.

Open Question

  • Any MRO edge cases beyond per-key resolution we should pin down?

I have a draft PEP if there’s interest. Happy to hear whether this aligns with typing’s goals.


Background: I’ve formalized why frameworks need extensible axes and why Python is uniquely suited for this. Happy to share the formal analysis if useful, but didn’t want to bury the proposal in theory. Paper found here

Sorry, I am not going to read an 80page paper to understand your proposal, and you should not expect people to do that.

What is an axes? I genuinely do not understand the fundamental proposal you are making.

To me it seems to just be some extra parameters that can be passed to type. This is already possible via **kwargs, which is available both for the class-statement and the type function.

What is the practical, fundamental (not theoretical) benefit of adding more complexity to the general type system of python that can’t already be reached by individual frameworks by using **kwargs?

Ideally create direct comparisons of real code (ideally a simplified real and well-known example), one without axes, one with to show a clear benefit and make the proposal understandable.

2 Likes

No need to read the paper to understand the proposal. Here’s the core issue with real OpenHCS code.


Real Example: Plugin Registration

I maintain OpenHCS (microscopy automation). We have ~5 microscope handlers that auto-register themselves. Here’s the actual pattern today:

# Real code from openhcs/microscopes/imagexpress.py
class ImageXpressHandler(MicroscopeHandler):
    _microscope_type = 'imagexpress'  # <- this is the registry key
    _metadata_handler_class = None     # <- secondary registration
    
    def microscope_type(self) -> str:  # <- wait, is this related?
        return 'imagexpress'

A metaclass reads _microscope_type and registers the class:

# openhcs/core/auto_register_meta.py (simplified)
class AutoRegisterMeta(ABCMeta):
    def __new__(mcs, name, bases, attrs):
        cls = super().__new__(mcs, name, bases, attrs)
        key = getattr(cls, '_microscope_type', None)
        if key:
            MICROSCOPE_HANDLERS[key] = cls
        return cls

The problem: Looking at ImageXpressHandler.__dict__, you see:

_microscope_type        # registry metadata
_metadata_handler_class # more metadata  
microscope_type         # actual method (returns same string!)
parser                  # real attribute
root_dir                # real property

Which of these are “metadata for framework machinery” vs “actual class behavior”? You can’t tell programmatically. Neither can a type checker.


With __axes__

class ImageXpressHandler(MicroscopeHandler, 
                         axes={"registry_key": "imagexpress"}):
    
    @property
    def microscope_type(self) -> str:
        return self.__axes__["registry_key"]  # or just return the string

Now introspection is trivial:

ImageXpressHandler.__axes__   # {"registry_key": "imagexpress"}
ImageXpressHandler.__dict__   # {microscope_type, parser, root_dir, ...}

Framework machinery reads __axes__. Application code reads __dict__. No collision, no guessing.


Why not **kwargs to type()?

You can already pass kwargs:

MyClass = type("MyClass", (Base,), {}, registry="foo")  # works today!

But where does registry="foo" go? Into __dict__, lost among methods. There’s no standard place to find it. Every framework invents _registry_, __registry__, FORMAT_NAME, _microscope_type

__axes__ is just "here’s the standard place for that metadata.

By default it doesn’t go anywhere. The metaclass (or baseclass) can freely decide where to put it.

Ok, so it would be better named __metadata__ (using the more common name that dataclasses uses for the exact same thing)? And the only real point of the proposal is to have a standard place where metadata can be stuffed?

Or is there supposed to be some other behavior that isn’t reached by having __metadata__ as a whole follow default MRO?

1 Like

The difference is per-key inheritance.

# __metadata__ with normal MRO (whole-dict):
class Base:
    __metadata__ = {"scope": "/base", "priority": 1}

class Child(Base):
    __metadata__ = {"scope": "/child"}

Child.__metadata__  # {"scope": "/child"} — priority is gone
# __axes__ with per-key MRO:
Base = axes_type("Base", (), {}, scope="/base", priority=1)
Child = axes_type("Child", (Base,), {}, scope="/child")

Child.__axes__["scope"]     # "/child" — overridden
Child.__axes__["priority"]  # 1 — inherited from Base

Each key inherits independently. Override one, keep the rest. That’s the semantic difference—not just naming.

If __metadata__ were specified with per-key inheritance, it would be equivalent. Happy to call it __metadata__ if that’s preferred.

It sounds like you want collections.ChainMap. Do something along the lines of: in __init_subclass__/__new__, do cls.__metadata__ = ChainMap(cls.__metadata__, *super().__metadata__.maps)). Keys will try the class metadata, then fall back to the parent metadata, and so on.

It’s not clear how this relates to typing annotations or type checking tools, or why it’s something that’s so generally needed that it needs to be in Python core.

Right, ChainMap in __init_subclass__ is exactly what frameworks do today. The proposal is to standardize that pattern so frameworks don’t each invent their own version.

Currently: Framework A uses ChainMap, Framework B uses a descriptor, Framework C uses a metaclass. All solving the same problem, none interoperable, tooling can’t find any of them without framework-specific knowledge.

The question is whether that pattern is common enough to warrant a standard surface. If yes, __axes__ (or __metadata__). If no, we continue with every framework rolling its own.

Are we expected to know what B and S mean or do we have to read the lengthy paper to find out?

1 Like

No need to read the paper, it’s defined in the OP and python docs. B is bases (__bases__), S is namespace (__dict__). Directly from type(name, bases, namespace). N is not semantically functional. It is stored on the type object and used for semantic presentation, not semantic identity.

The issue is that the comment you just posted is fully incomprehensible to 90% of people. What do you mean with “semantically functional”? It very much has an effect I can observe? What is the difference between semantic presentation and semantic identity? Identity in python is fully unrelated to these parameters? What’s a semantic axes?

You do not need to give me the answer to this question; I am pretty sure I know these answer, but it’s irrelevant to the proposal.

I would suggest to rewrite your proposal with zero reference to theory. Just use actual examples of what behavior you are suggesting and for which kind of problems it’s a good idea. I am sure your paper is well written; That doesn’t make it a good or helpful addition to a proposal for a python feature.

1 Like

Thanks for the feedback. I want to make sure I’m addressing your concerns correctly.

You asked for “actual examples of real code” without theory, I believe that’s what posts #3 and #5 contain:

Post #3: Real OpenHCS plugin registration code showing:

  • Current pattern: _microscope_type class attribute + metaclass
  • Problem: can’t distinguish framework metadata from class behavior in __dict__
  • Proposed: axes={"registry_key": "imagexpress"} with introspectable __axes__

Post #5: Per-key inheritance example showing the concrete difference:

# Current: whole-dict replacement
class Child(Base):
    __metadata__ = {"scope": "/child"}  # priority from Base is gone

# Proposed: per-key inheritance  
Child.__axes__["scope"]     # "/child" — overridden
Child.__axes__["priority"]  # 1 — inherited from Base

If these examples aren’t landing, I’d genuinely like to understand what’s missing. Is it:

  1. The use case isn’t clear? (Frameworks need metadata that inherits per-key, not as a blob)
  2. The mechanism isn’t clear? (ChainMap-style resolution, but standardized)
  3. Why it should be in core? (So tooling can find it without framework-specific knowledge)

Happy to expand on whichever part would help. I may be assuming too much background, just let me know where the gap is.

No, no, those are good examples. I am pretty sure I now have a good understanding of your proposal. I am less sure if I agree that it would see widespread usage and that it needs to be builtin. My point is only about the topic starting post which is supposed to be a complete proposal, but it is difficult to understand for most people.

(also a gentle reminder to please not just copy-paste LLM output and instead use your own words in accordance with this sites guidelines)

1 Like

Glad the examples landed.

On widespread usage, I’d reframe the question slightly. Python doesn’t limit function parameters to 5 because most functions use fewer. It provides the general mechanism and lets usage follow. The question isn’t how many frameworks need this today, but whether per-key inheritable metadata is a correct abstraction.

The paper argues yes, not as opinion, but as a consequence of how type systems compose. When you have N independent metadata dimensions (scope, registry, priority, etc.), flattening them into a single namespace loses information. You can recover it with ChainMap, descriptors, or metaclasses, but every framework reinvents that wheel.

The proposal is small: one new attribute (__axes__), per-key MRO inheritance, opt-in. Frameworks that don’t need it ignore it. Frameworks that do get a standard surface instead of inventing their own conventions.

As noted in the OP, I have a draft PEP ready. Happy to post it or iterate on the framing here first, whichever is more useful.

1 Like

I have no use for this feature personally, but I can see the benefit based on the examples you gave. And if I try to read past all the theory, your proposal seems fairly simple and general.

One suggestion I would make is that you try hard to tone down the theoretical and abstract arguments, in favour of the real-world examples and practical benefits and usage patterns. As you’ve probably noticed, this community has a limited tolerance for abstract theory, but we’re very much in favour of simple, practical solutions to real problems :slightly_smiling_face:

By the way, this seems like it’s a runtime feature, not a typing one, so maybe the Ideas category would be more appropriate than the Typing category?

4 Likes

Please yes! I get the impression there’s a solid idea behind all the theoretical language but I’m at a bit of a loss…

3 Likes

Thank you everyone, this is helpful. I’ll repost in Ideas with a cleaner framing focused on the practical examples. Appreciate the feedback here, it helped me understand what was landing and what wasn’t.

3 Likes