Add syntax for annotating a function/class

I am proposing adding syntax intended for annotating a function or class itself, not the function’s return type.

Specifically, I’m proposing a :: operator analogous to ->, and its annotation would get stored in the function/class’s __annotations__ under the key "__self__", though of course feel free to suggest alternative spellings for both the operator and key.

For example:

def foo() :: "a":
    ...

class Thing :: "a":
    ...

print(foo.__annotations__)
print(Thing.__annotations__)

Would print out:

{'__self__': 'a'}
{'__self__': 'a'}

Side note: I went with "__self__" because it has to live alongside annotations for class variables in a class’s __annotations__.

For functions, :: can be used alongside ->, though if both are specified, :: must be first. So def bar() :: "a" -> None: ... is fine, but def bar() -> None :: "a": ... is not. I think it makes sense for “an attribute for the function” to bind more tightly than “an attribute for the function’s return value” (also, with this spelling, putting the -> first looks like it’s annotating the annotation).

As far as “why add this”, that’s unfortunately difficult since we’re talking about arbitrary metadata I’m intentionally not prescribing a meaning to.

Obviously, it’s not directly useful for typehinting. Nobody needs class Foo :: type: ... to know that Foo is a type. But it could be used in other ways.

If code is currently using return value annotations def foo() -> Annotated[None, 5]: ... to both typehint the return value and store some other arbitrary value of no consequence for typechecking, it gives you an alternative, namely def foo() :: 5 -> None: ....

And of course classes can’t currently do that all, even with Annotated.


If you actually want to know the specific thing that spawned this suggestion, it was this draft PEP and its accompanying discussion about a typing.sealed.

One of the rejected ideas in it was to explicitly list all the options, but that was rejected because forward reference concerns made it impossible since it wasn’t being used in an annotation, because there’s no obvious place for the annotation to live, and any syntax changes would be too specific for one feature.

Well, this would be the obvious place for something like that.

class Node() :: Sealed[Leaf, Branch]:
    ...

Because it’s actually an annotation, it’s subject to the deferred evaluation so it wouldn’t choke on forward references.

Of course, that’s simply the inspiration, I’m not (currently) proposing a typing.Sealed like that. This thread is simply about the syntax.


Additionally, while I don’t feel strongly about this either way (because I don’t see how it could be useful), but it makes sense syntactically, so I’ll throw it out here - annotating a class’s bases. Maybe something like:

class Base:
    ...

class Derived(Base: "a"):
    ...

print(Base.__annotations__)
print(Derived.__annotations__)

would print:

{}
{'__bases__': {<class '__main__.Base'>: 'a'}}

If this has been discussed to death, I apologize - I could not find any prior discussions. Searching for things like “class annotations” and “function annotations” gives a ton of results.

Thanks.

Could you explain what this is for with an example use case? You refer to the discussion of a “sealed” type, which as far as I can tell is not related to anything you’ve described.

I don’t think I see an idea here worth pursuing, but without knowing the motivation it’s hard to even say that firmly.

Sealed itself essentially is an example use case, though obviously not exactly as described in the PEP, since it wasn’t written expecting new syntax.

The short answer for why I brought it up is that there is that if you wanted some version of typing.sealed/typing.Sealed functionality that isn’t magic - where you had to explicitly enumerate the variants (instead of having the type checker do it implicitly) - there is currently no good syntax to allow for that.

The reason why comes down to circular references. To do it, you’d want an actual annotation, and there’s no place for such an annotation currently. This new syntax for class/function annotations would be that one obvious location for such an annotation.


Feel free to skip to the end of this section if you’ve read over the PEP.

I didn’t go into it deeply because the sealed was only a pre-PEP and I’m not actually proposing it here, but essentially what it as written wanted is to be able to:

from typing import sealed

@sealed
class Node: ...

class Leaf(Node): ...
class Branch(Node): ...

And type checkers would treat Node as a union of Leaf and Branch.

This would require type checkers to be able to find the derived classes automatically (which is why it’s scoped to the file).

In the rejected ideas, it considers explicitly enumerating the variants, but it’s rejected because there’s no good way to do it. Because sealed is a decorator, and decorators run eagerly, you run into circular reference problems.

It gave an example with a Sealed generic base class:

from typing import Sealed

class Node(Sealed[Leaf, Branch]): ...

class Leaf(Node): ...
class Branch(Node): ...

But as it says in the rejection rationale:

This cannot work because Leaf must be defined before Node and Node must be defined before Leaf. This is a not an annotation, so lazy annotations cannot save it. Perhaps, the subclasses in the enumerated list could be strings, but that severely hurts the ergonomics of this feature.

If the enumerated list was in an annotation, it could be made to work, but there is no natural place for the annotation to live.

In order for this feature to work with an explicit list of variants, it really needs to actually be an annotation - not a base class, not a decorator. An annotation. And annotations need to live somewhere.

And, currently, there’s just not a reasonable place for such an annotation to live. The only possible location would be, as it mentions:

class Node:
    __sealed__: Leaf | Branch

class Leaf(Node): ...
class Branch(Node): ...

(It’s Leaf | Branch here and not Sealed[Leaf, Branch] presumably because it would be redundant to specify sealed twice, e.g. __sealed__: Sealed[Leaf, Branch].)

But that’s just unnecessarily awkward. It would make more sense to annotate the class directly, not a magic class variable. Yes, it ends up in the class’s __annotations__ either way, but that doesn’t mean it’s not a bit goofy.

The problem is of course that there is no syntax for annotating a class directly. And that syntax is directly what I’m proposing. Using the generic formulation, you could have:

# if you're skipping, start from the paragraph above me

from typing import Sealed

class Node :: Sealed[Leaf, Branch]: ...

class Leaf(Node): ...
class Branch(Node): ...

It’s an actual annotation on the class, not a decorator that wishes it were an annotation or a class variable being co-opted for special purposes.

This syntax would work extremely well for this purpose. Even if you are okay with it being implicit and making type checkers find derived types, it still works well with this syntax, it would just be class Node :: Sealed: ... instead.


And, no, for the record, Sealed is not the only thing this could be used for. There are a few other decorators that, in my opinion, would’ve been more naturally represented as an annotation on the function/method/class rather than as a decorator if function/method/class annotations were actually a thing that existed.

The most obvious one is actually typing.final. All it does is squirrel away an attribute for introspection purposes and return the exact same class/method that was passed to it. Doesn’t that sound like something a class/method annotation would be a more natural fit for than a decorator?

In fact, if this syntax existed, it is likely that typing.final wouldn’t even exist, since from my reading of PEP 591, the main reason they introduced both typing.final (for classes/methods) and typing.Final (for variables/attributes) instead of using only one name is because @Final looked weird.

Instead of:

from typing import final

@final
class Base:
    ...

class Derived(Base):  # Error: Cannot inherit from final class "Base"
    ...

It could’ve just been:

from typing import Final

class Base :: Final:
    ...

class Derived(Base):  # Error: Cannot inherit from final class "Base"
    ...

Now, yes, it is true that, my exact proposal as written would treat class Thing :: 5: ... as roughly the same as:

class Thing:
    __self__: 5

Except that __self__ wouldn’t be in __dict__, only in __annotations__, so you could argue that my insistence that it’s an actual annotation directly for the class is perhaps overblown, and I don’t exactly disagree. But having syntax for it just conceptually cleaner.

And that actually reminds me one thing I forgot to mention in my original post.

It could potentially be problematic, because it means that this syntax would be using the same key as the annotation for an actual class variable named __self__, (e.g.):

class Thing :: 5:
    __self__: 6

Would Thing.__annotations__["__self__"] be 5 or 6? Unclear. So, perhaps it would be better not to special case "__self__" for this feature and instead use the class itself as the key instead of any specific string, and have that be the convention. As in:

class Thing :: 5:
    __self__: 6

print(Thing.__annotations__)

would print something like

{<class '__main__.Thing'>: 5, '__self__': 6}

I didn’t suggest that in my initial post because when I was messing around with this idea, I noticed that mypy says that __annotations__ has type dict[str, Any] and I wasn’t sure about widening the key type, so I went with "__self__". But I did intend to mention it at least, I just forgot.

If neither of those keys are acceptable, a third option for the key in __annotations__ would be a some specific dunder name that isn’t a valid Python identifier.

It seems to me that since you’re suggesting a syntactic alternative to Annotated (which also works for classes) - is there any reason not to utilise Annotated for this?

E.g.,

def foo() :: "info" -> str:
    ...

Results in the type of foo being Annotated["info", Callable[[], str]]? Or Callable[[], Annotated["info", str]]? Which would allow backwards compatability and allow it to be used in situations which already occur returning Annotated.

Regardless, it seems to me that the more interesting part of the suggestion is providing a standard way to add structured, lazily evaluated metadata to a class which can be evaluated statically.