PEP 835: Shorthand syntax for Annotated type metadata

PEP 835 Update: Spec Alignment, Format.TYPE, and Rendered Draft

Thank you all for the extensive stress-testing and feedback over the last few weeks. The discussion here has been very useful to refine this PEP.

I’ve just pushed a major update to the draft in my personal fork. I’m waiting a bit before upstreaming the changes to gather more feedback. You can read the fully rendered version here, as well as the diff.

Summary of Changes

  • Terminology & Spec Alignment: Consolidated terminology to provide clear definitions for the concepts used throughout the PEP (Type Expression, Type Metadata, Non-Typing Annotation, Symbol Decorator) and align better with the typing spec. This clarifies that @ is strictly scoped to Type Expressions.

  • Forward References & None: Clarified that the newly introduced Format.TYPE handles None @Metadata structurally. This eliminates the need to modify NoneType.__matmul__, keeping standard runtime execution perfectly safe. The “Supporting None” open issue has been removed.

  • Future Work: Added extensive examples of how this syntax perfectly maps to potential future Symbol Decorators (e.g., annotating parameters/fields directly). Also added a JSR-308 inspired proposal for PEP 746, demonstrating how intersection types (&) could enforce target-based constraints for metadata.

  • Structure & Formatting: Reordered all sections to strictly comply with the PEP 1 template and added the mandatory “Security Implications” section.

  • Rejected Ideas: Expanded to address the alternative syntaxes discussed in this thread (e.g., <@, brackets, soft keywords) and moved the __rmatmul__ rationale here.

A Note on Thread Context and Feedback

This thread has been fantastic for catching edge cases. Just a heads up: to keep the document focused, I’ve centered it strictly on the canonical design rather than listing every alternative we’ve covered.

If you spot a critical technical flaw I’ve missed, please feel free to DM me. I also want to leave my inbox open to maintainers of type checkers (Mypy, Pyright, ty, pyrefly…) and libraries (Pydantic, FastAPI, cattrs, msgspec…) or anyone who might prefer to discuss implementation details outside the busy public thread.

Thanks again to everyone who has helped shape this proposal!

7 Likes

The addition of Format.TYPE seems like a pretty big deal and could almost be its own PEP, I feel.

I see that the existing Format.FORWARDREF can already parse list[undefined] as list[ForwardRef('undefined')]. I’m actually wondering why it doesn’t also already parse int | undefined as int | ForwardRef('undefined')? It can abstract the __getitem__ but not the __or__, but why not? Would it be backwards compatible to make Format.FORWARDREF behave like the proposed Format.TYPE?

3 Likes

It can resolve list[undefined] to list[ForwardRef('undefined')] because list.__class_getitem__ is defined on arbitrary objects.

>>> list.__class_getitem__(object())
list[<object object at 0x10bf60810>]

This isn’t the case for type.__or__, which would be needed for list | undefined to resolve.

>>> type.__or__(list, object())
NotImplemented

You can use a metaclass that does support __or__ for arbitrary objects to demonstrate this.

>>> from annotationlib import call_evaluate_function, Format
>>> from typing import Union
>>> class MetaUnionize(type):
...     def __or__(self, other):
...         return Union[self, other]
... class Unionize(metaclass=MetaUnionize):
...     pass
...
>>> type t = Unionize | unknown
>>> call_evaluate_function(t.evaluate_value, format=Format.FORWARDREF)
__main__.Unionize | ForwardRef('unknown')

Note that I still think adding a new Format.TYPE is a mistake, most of the reasons I’ve had for this earlier haven’t changed.

6 Likes

Coming back to this after an attempt to get permission for us to make public the current affected tooling…

The key problem with the latest proposal is that it breaks an existing behavior relationship with annotations.

At work, we have our own internal typechecker and our own typeshed. This came about after years of advocating fixing certain issues in the type system that had caused us issues, and basically being told it won’t ever be fixed. There’s not much motivation to make this typechecker public, and I was told we’d just patch or drop any libraries that we rely on that use the new annotation format if this is accepted.

However, the existing relationship between what is alowed in an annotation, and what the standardized type system accepts has allowed us the freedom to opt out of the standard for static typing.

This breaks that relationship. Currently, if someone tries to use a tool that expects a “type system annotation”, but gets something that is a valid python annotation while not being a valid standard type system annotation one of two things happens:

  • If they inspect the actual annotation and parse it statically: The tool itself recognizes the annotation isn’t what it expects to be there, and has visibility to error.

  • If they inspect the resulting value of the annotation at runtime: This may or may not work, but again, the visibility is there if it doesn’t. In our case, the tools works fine, because we’ve gone out of our way to make sure all of the conveniences implemented (like allowing unwrapped literal numbers) result in types that are shaped correctly at runtime to work with runtime inspection. M[1, 2] @ M[2, 3] injects some literals while validating there’s a shared dimension.[1] Runtime inspection of evaluating the annotation works, because we’re able to implement compatibility for tools that get the value.

In both of those cases, one side or the other gets to ensure compatibility with expectations.

The new format proposal breaks that. It sticks a middleman into the equation that assumes without validating that the valid python annotation is also a valid type system annotation. It doesn’t error in the case of a validly implemented operator it doesnt understand, it reinterprets that operator.

So at runtime, the actual value would be a valid python annotation, and might be implemented for compatability with tools that expect to be able to retrieve them at runtime.

Statically, it would be invalid to tools expecting a standard type system annotation.

And at runtime, if fetched via annotationlib with the proposed format, it would be something that does the wrong thing for both sides of the equation, while informing neither of them of it.


  1. It does more than this, but I don’t want to cross the line on what I can share or explain the more generalized tensor contractions or how we handle typevariables. ↩︎

3 Likes

The discussion is getting quite long, so I wanted to snapshot the current sentiment w.r.t. the PEP idea. Please do vote in the little poll below. Poll will close on July 15. Thanks!

Poll terminology:

USING - I am currently actively using or am planning/expecting to use `Annotated` in foreseeable future, either at work, in a hobby project, or in any other way. This includes both library authors and library users.

LIKE - I think that the general idea in the PEP will have positive impact on my experience with Python. I either support the PEP in its current form or I believe it can be improved so that I would support it.

  • I am USING Annotated and I LIKE the PEP
  • I am NOT USING Annotated and I LIKE the PEP
  • I am USING Annotated and I DON’T LIKE the PEP
  • I am NOT USING Annotated and I DON’T LIKE the PEP
0 voters
2 Likes

Disclosure – I use pydantic both free-standing and with FastAPI & Typer. I also experimented with doing something similar to FastAPI for other frameworks. I understand typing.Annotated pretty well but I have not used any of the mypy extensions. I only use it for metadata that is accessed at runtime.

I think that PEP is solving the wrong problem so I am -1 on it. The problem isn’t the spelling of typing.Annotated, the problem is that verbosity that results from inlining the annotations into parameter lists.

I tend to agree with the discussions around annotations as they are used today being only loosely related to types. At the same time, Python’s type system is only advisory. Annotations can describe aspects of runtime typing behaviors but they are also heavily used to attach runtime information to names. The core problem is that the mixture of the two. For example, FastAPI implements runtime dependency injection using annotations on parameter “types”. It also recommends “sharing the Annotated dependencies” so you see patterns like:

DatabaseConnection = t.Annotated[
    psycopg.AsyncConnection[RowType],
    fastapi.Depends(_get_connection),
]
ItemId = t.Annotated[
    int,
    pydantic.Field(description='Uniquely identifies an item')
]


class GetResult(pydantic.BaseModel):
    item_id: ItemId

@router.get('/{item_id}')
async def get_item(item_id: ItemId, *, db: DatabaseConnection) -> GetResult: ...

The pydantic.Field and fastapi.Depends annotations are used purely for runtime behavior which is shoehorning information into the “type”. I tend to think of it as attaching information to the name binding (db) instead of the type which works for me. As long as the static typing machinery knows that db is an psycopg.AsyncConnection instance, I’m okay with the FastAPI dependency injection magic that gets the value in there so I can get on with writing my application functionality.

I don’t tend to find solving the spelling of typing.Annotated as a problem that has to be solved. I’ve just adapted my usage of it to hide behind type aliases so that it doesn’t clutter up parameter lists.

Editorial note – I may have omitted a required type keyword in the snippet. I bounce between Python versions so my fingers haven’t remembered when I should be using it.

6 Likes

Hard disagree. that is just another layer of indirection we don’t need. that makes it even HARDER to read that a type is an int, not easier.

My ‘dislike’ of the PEP largely stems from the fact that it’s chosen to reuse @, but isn’t about how the symbol looks. @ in a normal expression has a different, defined, meaning and this reuse causes problems that wouldn’t exist if a new unused symbol or symbol pair were used.

In order to try and work around the problem this creates, the PEP proposes a new annotation format that doesn’t solve existing problems with PEP-649 annotations but instead creates new problems and quirks along with the churn of yet another new way of obtaining annotations.

As the PEP seems determined to go ahead with this operator reuse I’m going to try to be constructive and illustrate the issues this new annotation format creates and propose a smaller, internal change to Format.FORWARDREF that could still support it (without breaking the ‘shaped’ types at runtime - or at least providing them with an escape hatch).


Problems with the current Format.TYPE proposal

My biggest objection to the current PEP is to making @ ‘work’ in the case where objects are known and the operation should fail. This may seem nice, but as a consequence this will essentially break anything that currently handles type annotations using VALUE and FORWARDREF.

  1. Supporting None @ <object> creates new ‘valid’ annotations without undefined names that fail under VALUE.
>>> from annotationlib import get_annotations, Format
>>> 
>>> class Example:
...     a: None @ "Arbitrary String"
...     
>>> get_annotations(Example, format=Format.VALUE)
Traceback (most recent call last):
  File "<python-input-15>", line 1, in <module>
    get_annotations(Example, format=Format.VALUE)
    ~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/var/home/ducksual/src/temp/cpython/Lib/annotationlib.py", line 1189, in get_annotations
    ann = _get_dunder_annotations(obj)
  File "/var/home/ducksual/src/temp/cpython/Lib/annotationlib.py", line 1381, in _get_dunder_annotations
    ann = _BASE_GET_ANNOTATIONS(obj)
  File "<python-input-14>", line 2, in __annotate__
    a: None @ "Arbitrary String"
       ~~~~~^~~~~~~~~~~~~~~~~~~~
TypeError: unsupported operand type(s) for @: 'NoneType' and 'str'
>>> get_annotations(Example, format=Format.TYPE)
{'a': None @'Arbitrary String'}

VALUE fails even if there are no forward references due to the annotation using the new syntax.

As Format.TYPE also allows for forward references this means VALUE can no longer be used as a way of checking for forward references if you also potentially use the new syntax. There is currently a pattern in some places where annotations will be gathered as Format.FORWARDREF and then later when it’s necessary to know all of the information, they are re-evaluated as Format.VALUE and its success or failure indicates if the reference is resolvable - as Format.TYPE supports forward references you can’t make this distinction if you’re relying on the new syntax.

  1. The current implementation also supports arbitrary_object @ arbitrary_object which leads to some other strange behaviour.
>>> s1 = "StringRef"
>>> s2 = "Other String"
>>> 
>>> class ExampleVariable:
...     a: s1 @ s2
...     
>>> get_annotations(ExampleVariable, format=Format.TYPE)
{'a': ForwardRef('StringRef', is_class=True) @'Other String'}

Note that the ForwardRef created in this case is unresolvable but you could consider that an implementation bug. Using the literal strings just fails as it does under annotations now (as it should). For some reason if the LHS is a string, literal or otherwise, it always gets converted into a broken ForwardRef.

  1. The format currently assumes any kind of subscripting that fails makes a GenericAlias for some reason?
>>> class Example:
...     a: str[int, float]
...     
>>> get_annotations(Example, format=Format.TYPE)
{'a': str[int, float]}
>>>
>>> # Weird dict example
>>> d = {'a': "String a"}
>>> 
>>> class Example:
...     a: d['a']
...     b: d['b']
...     
>>> get_annotations(Example, format=Format.FORWARDREF)
{'a': 'String a', 'b': ForwardRef("d['b']", is_class=True, owner=<class '__main__.Example'>)}
>>> get_annotations(Example, format=Format.TYPE)
{'a': 'String a', 'b': {'a': 'String a'}['b']}

Note that that’s not a literal dict for ‘b’ under Format.TYPE, that’s a generic alias of a dictionary. I don’t know what purpose this functionality is supposed to serve? If you’re evaluating an annotation, what use is it to make a generic alias of something if you don’t know what the origin is[1]? This just makes it more complicated to evaluate the forward reference later.

I think this behaviour of making things work between objects that are known and where the failure is known at runtime should be dropped.

As far as I can tell this behaviour primarily exists to support the rare case of annotating something which can only have the value None but None is more likely to be part of a union at which point the union will support @. I wouldn’t be surprised if the most common place where None @ <annotated detail> would occur would be in error due to writing T | None @ <annotated detail> and getting bitten by operator precedence attaching it to None rather than the union.

If it is necessary to annotate None on its own it’s possible to use a type alias, the actual NoneType or the existing Annotated[...] syntax but I don’t see this as a common or serious enough problem to make it worth breaking __annotations__ and the VALUE format over.

Linters and/or type checkers should indicate that None @ <thing> doesn’t work if someone does try to use it. Upgrade tools should not ‘upgrade’ Annotated[None, ...] to the new syntax.


Problems with PEP-649 annotations that are made more difficult to fix with Format.TYPE

Adding a new format that’s subtly different makes the ‘annotation intermediary’ problem that already exists under PEP-649 annotations worse.

This is where something from library A has to get the annotations but they end up used by library B (like the earlier dataclasses and cattrs example).

There are essentially currently 4 states an annotation under PEP-649 exists in:

  1. Fully resolved, as retrieved from __annotations__ or Format.VALUE
  2. Completely unresolved, as they exist inside the __annotate__ function but inaccessible separately.
  3. Partially resolved, as retrieved from Format.FORWARDREF
  4. Resolved into strings, as retrieved from Format.STRING

The current proposal adds another state which is:

  1. Partially resolved with additional “structural” assumptions which are not guaranteed to hold when names can be resolved with existing annotations.

An existing problem libraries have is that there’s no (stdlib) way to get the completely unresolved annotations that would allow them to be evaluated later. This generally means libraries either ‘leak’ ForwardRef(...) into VALUE and STRING annotations, lose annotations where they’ve modified __annotations__ after a tool has processed a class, or fail to evaluate __annotations__ in cases where it should be possible to succeed. (Note: these are specific examples I’m aware of but I don’t want to call out libraries here.)

The new TYPE format and @ syntax as they currently are adds a new dilemma where Annotated may wrap something the tool does need to check[2] so it would be forced to use the new format which then bakes in the potentially incorrect assumptions made to anything downstream. So this essentially just forces everything to switch to Format.TYPE without adding access to the unresolved annotations that they actually need.

You will end up with some tools supporting it and some tools semi-supporting it when there are no forward references and more churn and compatibility hacks as tools now need to support old annotations, __future__ annotations, Format.FORWARDREF annotations and now Format.TYPE annotations under 3.16.


Proposed change

My suggestion here is then to drop Format.TYPE entirely and allow Format.FORWARDREF to evaluate @ in a more restricted way than the current proposal that will still cover the majority of actual use cases without breaking in the rare cases where @ has some other meaning.

Resolving A @ B in Format.FORWARDREF:

  • If both A and B are unknown → leave this as ForwardRef('A @ B')
    • Both objects are opaque, there’s nothing to be gained by resolving this to Annotated[ForwardRef('A'), ForwardRef('B')]
    • Resolving this to Annotated can only break cases where this isn’t what it will eventually resolve to.
    • It’s also easier to resolve later as one ForwardRef than as separate objects inside Annotated
  • If A is known and B is known or unknown → Resolve A @ B normally
    • If A is a type this becomes Annotated[A, ...] as expected at runtime, there is no need to special case
    • In the same way that Format.FORWARDREF fails now, if A @ B fails it gets wrapped in a ForwardRef
  • If A is unknown and B is known → check B.__rmatmul__
    • If there is no B.__rmatmul__ then resolve to Annotated[ForwardRef('A'), B]
    • If it exists, resolve to ForwardRef('A @ B')
    • If type actually defines __rmatmul__ in the final version of this it will need to be special cased here
    • This is the only actual change to how Format.FORWARDREF resolves annotations

This would allow things like unknown @ Field(...) to work while preventing M[1, 2] @ M[2, 3] from breaking even if M is not yet defined when the annotations are retrieved. I think this would be much less disruptive than adding a new format that requires a greater number of additional assumptions about what’s in an annotation.

If necessary we could also add a decorator along the lines of @no_annotated_syntax in either typing or annotationlib that wraps the __annotate__ function and prevents this resolution under Format.FORWARDREF.

Something equivalent could be done for | if it’s deemed necessary but I’m not sure it is. Sure you can get some runtime information out about the type but are there actually runtime tools that can do anything with partial information about a type? I would expect most need the entire type to be resolvable when they operate, the same for if type intersections were ever added.


As a side note on the current implementation I really don’t like the change from T @ extra to T @extra in the representation. The original PEP had this as T @ extra. The operator that’s used nearly always belongs to T, it seems wrong to attach the symbol to the object that doesn’t define it.

The PEP claims this is similar to decorators which are associated with matadata but I’ve never associated decorators with “metadata”. I usually expect them to perform some kind of runtime transformation on whatever they’re decorating (for example: @property, @staticmethod, @dataclass, @cache, …) I would suggest changing this back.


  1. This may sound hypocritical as my Reannotate library supports get_origin and get_args but those exist specifically for manipulating an unevaluated annotation - it essentially exists to convert InitVar[Vector] to Vector in the __init__ annotation while still maintaining the STRING representation and the correct resolution in the other formats. ↩︎

  2. Say ClassVar for instance, which dataclasses currently doesn’t handle but attrs and my own ducktools-classbuilder do handle unwrapping. I believe there’s a PR to add support for Annotated[ClassVar[...], ...] to dataclasses but it doesn’t work right now. ↩︎

11 Likes

Hi David,

Thanks for the detailed analysis. I think your main concern comes down to the gap between Python’s static typing rules and standard runtime evaluation.

The typing spec defines operators through grammar rules, but runtime evaluation relies on __dunder__ methods. NoneType is a great example of where this breaks down. None | None is a perfectly valid type expression, but as you pointed out, it crashes under Format.VALUE because NoneType doesn’t implement __or__. If we rely entirely on Format.FORWARDREF, a basic is_optional(t) check fails on Unknown | None simply because the left side is unresolved. By evaluating structurally, Format.TYPE ensures the Optional is captured properly.

The objections you raise apply to any operator used in a type expression. The friction between static semantics and runtime execution is not unique to @. We hit the exact same limits with | for unions and will face them again with the proposed & for intersections. Because NoneType intentionally omits __matmul__ to avoid hiding matrix multiplication bugs, valid type expressions like None @Metadata will inevitably crash under Format.VALUE. Format.TYPE is designed to work around this, bridging between static typing and runtime evaluation for all type operators.

On the implementation side, the current Format.TYPE diff is a proof of concept. I appreciate your review and hope to lean on your feedback for the production version.

I admit I was a bit surprised by some of your broader architectural objections, as we explicitly detailed the rationale for these exact tradeoffs in the PEP. The reasons we cannot rely solely on Format.FORWARDREF are broken down in the Forward References section, specifically how it stringifies unresolvable names and traps metadata inside an opaque string. The runtime boundary logic is detailed in Handling of None.

The PEP 649 complexities you highlighted are real, but they predate this proposal. Fully solving deferred evaluation might require more than just a new Format. This deserves a dedicated conversation. If you want to start a separate thread or draft a standalone PEP to address it, I would be happy to collaborate.

In the meantime, if you feel your alternative is not fully represented in the Rejected Ideas section, I would welcome a suggested edit there to ensure the community can see the full design space we navigated.

1 Like

I want to point out that a lot of the discussion and objects to this PEP have been focused on Format.TYPE, and the actual idea that the PEP is named for, that is described in the Abstract, Motivation and a bulk of the Specification has sometimes only received secondary attention.

I think this PEP needs to be split. Neither of these two ideas are uncontroverisal or quick, and they are not actually dependent on each other. I think it hurts both ideas to have a joint PEP: People who opposed @ are not going to support this PEP even if they like Format.TYPE, and the other way around.

I personally didn’t have a strong opinion on Format.TYPE at the beginning and am strongly in favor of @, but I find some of the counterarguments convincing. The fact that I am now mildly opposed to the PEP named “Shorthand syntax for Annotated” despite really like the thing described by that titled shows that this PEP is not in a good state right now.

4 Likes

Till,

My point is that if you insist that Format.TYPE is required as part of the PEP then my position HAS to be to argue that the PEP should be rejected as I think this is a change that makes things worse for users of runtime annotations.

This has nothing to do with being against getting a nicer syntax for Annotated. I’m essentially -0 on that. I don’t think Annotated is worth it, but I only actually care about the runtime issues you introduce with the new annotation format.

If the goal of the PEP is to add a nicer syntax for Annotated then Format.TYPE is unnecessary which is what I attempted to demonstrate. The only case you lose is None @ <thing> which I don’t think is worth breaking VALUE annotations over.

If the goal of the PEP is to completely change how annotations are evaluated at runtime then that’s a different PEP and the change shouldn’t be brought in as a side effect of adding a ‘nicer’ syntax. I can then argue against that without having to be against the new syntax.


My point in bringing it up was to illustrate that the addition of Format.TYPE makes the existing complexities worse.

I made the thread and package implementing the concept last year. There wasn’t much interest so I didn’t think I had the support to pursue an actual PEP.


To be clear the issue with @ is that the information in Annotated may be unrelated to the type and may be instructions for how something is constructed. As such it needs to avoid breaking in cases that look like this:

@dataclasslike
class A:
    bee: B @ Field(repr=False, kw_only=True)
    ...

class B:
    ...

@dataclasslike operates before B is defined, so it needs to resolve this to Annotated in order to correctly construct the class.

However, I’m not sure it matters for A | B and A & B at runtime, by the time you’re actually comparing things to the type you need both A and B to be resolvable.

I admit, I’m surprised you’re surprised.

I thought I’ve made this fairly clear throughout that I don’t consider annotations to be some kind of special type expression that has different semantics to standard runtime. If there are no forward references and an annotation is valid then it should be a valid expression and work under Format.VALUE.

The boundary logic falls down when you’re a tool that’s given a ForwardRef to evaluate. ForwardRef("A @ B").evaluate(format=Format.TYPE) evaluates to Annotated[ForwardRef('A'), ForwardRef('B')].


To be clear, any further feedback on the format will largely be “don’t do this”. Possibly with further examples of why not to do this if necessary, but I really hope you can be convinced to just not do this as part of the PEP.

9 Likes

By intentionally trying to make something valid in an annotation that isn’t valid at runtime, it creates additional problems. This isn’t fixing deferred annotations; it’s having behavior that is inconsistent and incompatible with already existing use.

There’s an assumption this format attempts to rely on that isn’t valid: Namely, that all existing annotations must follow the typing grammar, rather than the language grammar.

The motivation is entirely for None @ Metadata, but when do you ever need to attach metadata to None?

This creates multiple problems, from breaking existing uses that were actually based on matrix multiplication, what the operator was explicitly defined for, to creating things that would be “valid” in an annotation, but not in a TypeForm value.

5 Likes

Hi David, Michael,

I hear you. Adding a new format or changing the semantics of an existing one should be a last resort.

Before we bake a new format into the standard library, I want to ensure we’ve truly exhausted our existing tools. I’m going to pause and get a better understanding of:

  1. Ecosystem Audit: How the big type-directed frameworks consume annotations and where their pain points are (both with and without the @ shorthand).

  2. Testing Existing Formats: How far we can get with the existing formats and exactly where that breaks down.

If the current formats can handle this cleanly, I will drop Format.TYPE from this PEP. If they can’t, we’ll have a baseline to determine the right path forward.

In the meantime, I am encouraging people to send me open-source projects that rely on overloading @, |, or & in places that might get consumed by Pydantic, Hypothesis, or similar tools. Having concrete examples will help ground the analysis.

Every Array API library, including (but not limited to) NumPy, PyTorch, CuPy, and Dask.

Hi Joren,

Do any of those libraries actually overload @ on the metaclass?

PEP 835 adds __matmul__ to builtins.type, while array libraries implement it on their instances. Because they operate in completely different spaces, they shouldn’t intersect:

>>> # Type space: Triggers PEP 835 metadata
>>> T = np.ndarray @Field(...)
>>> type(T)
typing.Annotated

>>> # Value space: Triggers NumPy matrix math
>>> val = arr1 @ arr2
>>> type(val)
numpy.ndarray

Unless these libraries are doing matrix multiplication on uninstantiated class objects, there is no collision. Is there a specific edge case you’re thinking of?

Here’s an edge case I can think of:

field: MyArray @CannotEqual[array1 @ array2]

Assume CannotEqual is some sort of validator or the like, taking literal values. Syntactically I don’t think there’s a way to distinguish these two operator uses. You could maybe require annotation objects to use a call instead of subscript, but that’s backwards incompatible.

Good example.

Annotated[MyArray, array1 @ array2] is a perfectly legitimate annotation at the moment.

I already explained an existing use case to the extent I can say publicly, and how it is incompatible with the assumptions of the new format. I also explained that this isn’t a situation that is open source, and even the context for why we intentionally exist in the space between “not a valid standard static type annotation” and “definitely a valid annotation”.

I don’t know what else you’re asking, unless it’s your intent to say it’s fine to break existing use cases that are known, but the specific code isn’t public.

Here is how this currently works under Format.TYPE (though bear in mind, as I mentioned above, this behavior is subject to change based on the results of the ecosystem audit).

When the annotation can be executed normally (all variables are defined at runtime):
Format.TYPE evaluates the annotation as normal Python code. The @ operator builds an AnnotatedType. The left side of the operator (MyArray) is evaluated as a type. The right side (CannotEqual[...]) is evaluated as a value. Because the nested array1 @ array2 is inside CannotEqual on the value-side, that entire nested expression is also evaluated as a value.

When variables are undefined at runtime:
If Format.TYPE encounters undefined names (like a missing import), it falls back to parsing the expression structurally using ForwardRefs. It preserves the semantics by catching the @ operator and resolving the left side as a type and the right side as a value.

  • If both sides of the outer @ are undefined, it preserves the entire AST branch as a single string: ForwardRef('MyArray @ CannotEqual[array1 @ array2]').
  • If MyArray is defined but CannotEqual is not, the structuralizer resolves the left side to the MyArray class object. For the right side, because CannotEqual is undefined, the structuralizer halts decomposition there. It yields MyArray @ ForwardRef('CannotEqual[array1 @ array2]'). Assuming MyArray doesn’t do any magic with metaclasses, this builds typing.Annotated[MyArray, ForwardRef('CannotEqual[array1 @ array2]')].
  • If MyArray is undefined but CannotEqual (and its inner arrays) are defined, the left side becomes ForwardRef('MyArray'). Because the right side has no undefined variables, it natively executes CannotEqual[array1 @ array2] into a real Python object. The final @ operator binds them, yielding typing.Annotated[ForwardRef('MyArray'), CannotEqual[array1 @ array2]].

Non-public use cases absolutely matter. That’s how most of us keep bread on the table!

I am very aware of your specific use case. The request for open-source examples is strictly practical. I just need code I can run tests against.

Even right now, because Format.TYPE falls back to the native __matmul__, it might actually work pretty well for you. But I want to get a better understanding of the landscape before I ask anyone to test anything and risk wasting their time.

I will probably reach out once I have concrete code that can be run against private codebases to verify. My goal is to find the solution that causes the least amount of churn.