Repository navigation
Add support for external annotations in the typing module #600
Description
Activity
I like this proposal. It will allow easy experimentation with type system features. I have two questions:
- Why do we need to allow the single-argument form
Annotated[int]? - Should we allow subscripting annotated types, for example currently one can write
Vec = List[Tuple[T, T]](whereTis a type variable), and thenVec[int]will be equivalent toList[Tuple[int, int]]. Should we allow (and how)Vec = Annotated[List[Tuple[T, T]], MaxLen(10)]; v: Vec[int]?
- Why do we need to allow the single-argument form
Also an organizational question: do we need a (mini-)PEP for this? I would say yes (this post can be a starting point for a draft). @gvanrossum what do you think?
@ilevkivskyi
_ We don't really need the single-argument form, I was just trying to be consistent withUnion
_ Yes, supporting generic aliases would be great and would allow us to give more of a first class feeling to some of the Annotated type when we're experimenting.I'm happy to turn this into a PEP if you think that this is the way forward.
We don't really need the single-argument form
OK, let's remove it.
I'm happy to turn this into a PEP if you think that this is the way forward.
I think this is the best way forward (in particular because it aims at improving cross-typechecker compatibility). If you will start working on it, then I would recommend skipping the dataclass example, it doesn't look very convincing TBH.
If you haven't already considered this, I think
Annotated[None, ...]should be allowed for cases where you want the type to be inferred, but still want to add metadata.Another use case for this:
Annotated[T, ClassVar, Final]; the current approach (e.g.ClassVar[T]) is kind of hacky sinceClassVar/Finalaren't really types, just specially-cased versions ofAnnotated.Nonemay be a valid type, so I would rather proposeAnnotated[..., Immutable]with literal..., see also #276.I was referred here from #604. Indeed, this would cover a usecase there too. But for me this looks a bit too verbose, the same concern as for communicated in #482 .
I like the idea of "PEP" in the sense of "more thorough document", though I'm not sure if it would be Python-as-a-language wide PEP, or just local PEP for "typing" project/module.
In either case, considering and discussing different alternatives is a must for PEP, and I'd like a list of other options considered and comments re: them.
Here's my 2 cents:
- Why instead
Annotated[Type, Ann1, Ann2, ...]not have(Type, Ann1, Ann2, ...)? By unbreaking grammar a bit, this would allow to annotate entire functions by putting just comma-separate annotations in return annotation position, e.g.:
def foo() -> NoReturn, NoRaise: # Perhaps an infinite loop?For function params/variables, parens would be required, but it's still less visual noise than
Annotated, which is also a pretty long word, which will easily cause need for line wrapping when annotating e.g. a class method with more than a couple params.- I find it interesting that in the description above, a typo with parens instead of square brackets is made:
Currency = Annotated( TypedDict('Currency', {'dollars': float, 'pounds': float}, total=False), TaggedUnion, )So, given that
Annotatedisn't really a typing annotation itself, but a kind of meta-annotation, perhaps blindly followingtypingtypes syntax isn't a requirement, and other alternatives can be considered. I'm not sure about implementation complications, but imagine that by overriding__new__and/or__call__it's doable. Using call syntax would allow to use keyword arguments for example.Let me clarify again that the above is just to list possible alternative solutions. I'm clearly in favor of p.1 ;-). And while using keyword args is always cute, this statement from the original description:
We do not need namespaces for annotations since the class used by the annotations acts as a namespace.
Indeed, we can't namespace keywords. So, effectively we're trading:
Annotate(T, very_readable_but_not_namespaced_keyword="10s")with
Annotate[T, my_module.VeryReadableAnnotationName("10s")]So, choosing between original proposal and p.2, I'm in favor of the original proposal: namespaces are important to avoid incompatibilities and mess.
But even more so I'm in favor of p.1, and would be keen to hear criticism of it.
- Why instead
Why instead
Annotated[Type, Ann1, Ann2, ...]not have(Type, Ann1, Ann2, ...)?There are several reasons that I see:
- Annotated types can appear in a nested position, that can cause confusions:
Callable[[A, B], C]is too similar toCallable[[(A, B)], C], but the meaning would be very different. - With the
(...)syntax users cannot create generic aliases, for exampleVec = Annotated[List[Tuple[T, T]], MaxLen(10)]; Vec[int]will work, but the same will not work with(...)syntax. - This will be simpler for people who use annotations for runtime purposes (they will not need to expect a tuple in any position, we can use the same API as for other typing constructs).
- Annotated types can appear in a nested position, that can cause confusions:
There are several reasons that I see:
...I see, makes sense. The only thing then is to see if someone would be able to come up with something shorter than
Annotated. Though fairly speaking one can just doA = Annotatedor whatever, so that's covered too.Though fairly speaking one can just do
A = Annotatedor whatever, [...]Exactly.
I also rather like this proposal. I don't think a relevant PEP has been created yet?
One thing worth considering here is how
Annotatedwould interact withtyping.get_type_hints().Having
@struct.packed class Student(NamedTuple): name: Annotated[str, struct.ctype("<10s")]
I see three ways of approaching this:
get_type_hints()returns exactly what's stored in the annotations, so:
get_type_hints(Student) == {'name': Annotated[str, struct.ctype("<10s")]}
get_type_hints()returns only types and then:
get_type_hints(Student) == {'name': str}
- A flag is introduced to control what's
get_type_hints()gonna return, for example:
get_type_hints(Student) == {'name': str} # probably get_type_hints(Student, what=ONLY_TYPES) == {'name': str} get_type_hints(Student, what=ONLY_EXTRA) == {'name': [struct.ctype("<10s")]} get_type_hints(Student, what=ALL) == {'name': Annotated[str, struct.ctype("<10s")]}
I'm a fan of the second variant, it'd require a new function or functions to be introduced so that client code can get the relevant subset of the annotations.
The PEP draft has been posted on Python-ideas a while ago, see https://mail.python.org/pipermail/python-ideas/2019-January/054908.html
IIUC the current process is that you need to find a core dev sponsor for the PEP (I can be one, or maybe @gvanrossum?) and then make a PR to the python/peps repo taking into account the comments the comments that appeared on Python-ideas.
Re
get_type_hints(), I am in favor of the third variant with a boolean flag (likeinclude_extras) that will beFalseby default to maintain current behavior.Reacted by no@ilevkivskyi: Thanks for your involvement, I was a bit slammed with work but I am finally getting back to this.
We'll turn this into a proper PEP real soon and I really appreciate your feedback. We'd love to have onboard either as a sponsor or a co-author if you're interested. The points I see that currently need addressing are:Come up with a compelling example of using
Annotatedas an alias.As noted the syntax is pretty cumbersome unless we use aliases. e.g.:
T = typevar('T') Opaque = Annotated[T, MyLib.Opaque()] @dataclass class V: name: str uid: Opaque[bytes]
I am not sure that this example is a compelling enough one.
Remove the dataclass example.
I thought it was compelling but I'm obviously not objective...
get_type
How would
get_type_hints(Student, what=ONLY_EXTRA)work with nested annotations?@struct.packed class Student(NamedTuple): names: List[Annotated[str, struct.ctype("<10s")]]
I guess that means that we should go with
include_extras(or no args at all)?Reacted by Jakub Stasiak, valtron and Lukas VoegtleCome up with a compelling example of using
Annotatedas an aliasI'd like to provide an example for this (disclaimer: I'm not objective here either, as I co-maintain the library in question): Injector (a dependency injection framework) has a way to mark constructor parameters are noninjectable (so that the framework won't attempt to provide a value and it's obvious to the reader /documentation value/). Currently the way it's done is:
# ... @noninjectable('user_id') def __init__(self, service: Service, user_id: int): # ...
With Annotated and generic alias support we could do this:
# Injector code T = TypeVar('T') Noninjectable = Annotated[T, some_marker_which_does_not_matter_here] # client code # ... def __init__(self, service: Service, user_id: Noninjectable[int]): # ...
Reacted by till, valtron, Antonin Delpeuch and Dylan ModesittAnother example I thought about just now: a mutability/immutability marker so that external tools could statically verify that code is not mutating something it shouldn't (pure theoretical thing though).
# ok def fun1(data: Dict[str, int]) -> None: data['asd'] = 1 # error def fun2(data: Immutable[Dict[str, int]]) -> None: data['asd'] = 1
Reacted by till, valtron, Antonin Delpeuch and Dylan ModesittReacted by Eyal Gruss12 remaining items
re-posting because my previous reply wasn't formatted properly
There are a couple of corner cases that make handling
TypeVars in the annotations themselves a bit tricky.- Passing values to annotations:
"Some info here"is not a type and we probably should not accept fillingTypeVars with values that aren't types. We could leverage the newLiteraltype:
Doc = Annotated[T1, constants.DOC, T2] def fn(arg: Doc[int, Literal["Some info here"]]) -> None: ...
This seems somewhat kludgy and will place strict restrictions on the types of the literals you can pass as arguments (
Literaldoesn't support tuples...).-
Substituting
TypeVars nested in annotations. Ideally arguments toAnnotatedwould be self-contained; it's confusing to haveconstants.DOCapply to the argument directly following it. We now need to know the arity of the arguments toAnnotatedif we want to tell them apart. You'd probably want something more along those lines:Doc = Annotated[T1, annotations.Doc[T2]]
It's not entirely clear how we'd let
get_type_hints()performTypeVarsubsitutions. PEP-593 was written in part to support non type checking use cases (database query mapping, RPC parameter marshalling) so we wouldn't want to force all the arguments toAnnotatedto be valid types.I see a couple of scenarios:
-
get_type_hints()only performsTypeVarsubstitution on annotations that are classes inheriting fromGeneric. -
We go all out and add
LiteralVarandGenericValueto enable complex substitutions.LiteralVars enable us to accepts literals as arguments. We can restrict the type of the literals we accept by passing a typeLiteralVar's constructor.GenericValuetakes a callable and a list of arguments (which can be instances ofLiteralVarorGenericValue). We expandGenericValue[fn, args...]by- replacing any
TypeVarorLiteralVarthat appear inargs... - calling
fnon the expanded arguments
- replacing any
E.g.:
DOCSTRING = LiteralVar("DOCSTRING", str) Doc = Annotated[T, GenericValue[annotations.Doc, DOCSTRING]] def fn(arg: Doc[int, "Some info here"]) -> None: ...
Doc[int, "Sone info here"]would get expanded toDoc[int, Annotations.Doc("Some info here")].
-
As we expand
typingto be more flexible, we're slowly backing ourselves into template meta-programming... Meta-programming is a rabbit hole we do not want to explore within the scope of this PEP.I'd love to see python support constructs such as c++11's
constexpr* as it would address a lot of the issues we have with aliases, variable substitutions and literals. This would warrant a whole new PEP and require significant changes in the language itself.- Passing values to annotations:
Another use case for this PR in mypy 0.730:
You can ignore only errors with specific error codes on a particular line by using a
# type: ignore[code, ...]comment.... which would be
Annotated[..., Ignore(code)]here.- added 4 commits that reference this issue
on Oct 16, 2019 What is the proper way to access extra annotations at runtime ? The
typing.get_type_hints(include_extras=True)function just gives back the result ofAnnotated[T, x]and I couldn't find a way to get the annotations -- in either the PEP or the upcoming documentation.From the source code, annotations are stored in the
__metadata__attribute, but since this is a dunder attribute, it shouldn't be accessed according to this documentationPerhaps
__metadata__should be added to the doc ?EDIT: Added the link to the doc essentially reserving all dunder names in any context
Reacted by Conchylicultor- On 14 Jul 2020, at 20:26, QuentinSoubeyran ***@***.***> wrote: What is the proper way to access extra annotations at runtime ? The typing.get_type_hints(include_extras=True) function just gives back the result of Annotated[T, x] and I couldn't find a way to get the annotations -- in either the PEP or the upcoming documentation. From the source code, annotations are stored in the __metadata__ attribute, but since this is a dunder attribute, it doesn't quite look like an official API. Perhaps __metadata__ should be added to the doc ? — You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub, or unsubscribe.What’s your use case? I’m using get_type_hints(…, include_extras=True) and it’s working fine so you may want something specific here but it’s not clear what it is. Best, Jakub
I'd like to get all the
xinAnnotated[T, x, ...]at runtime to test if a particular one is present. After re-reading the doc and checking the code,typing.get_args()is exactly what I want -- sorry for missing it and bothering everyone !Reacted by Ondrej BaranovičNice& well keep it up
What is the proper way to check whether a typing annotation is a
Annotated?annotation = Annotated[int, 123] # The naive way does not work isinstance(annotation, Annotated) # Works but rely on implementation details isinstance(annotation, typing._AnnotatedAlias) # Works but feel fragile hasattr(annotation, '__metadata__')
get_type_hints(fn, include_extras=True)returns annotated but does not say anything of whether a specific item isAnnotatedor not. Also the Annotation could be nested insideDict[str, List[Annotated[...]]]. Similarlyget_args(Annotated[int, int])andget_args(Tuple[int, int])will return the same value, so it can't be used to check whether an annotation isAnnotated.@Conchylicultor You can use
typing.get_origin()(after retrieving type hints withtyping.get_type_hints(obj, include_extras=True)). Here is also how to retrieve the type and annotations from the annotated type hint:from typing import Annotated, get_origin, get_args MyType = Annotated[int, 123, "metadata"] assert get_origin(MyType) is Annotated assert get_args(MyType)[0] == int assert get_args(MyType)[1:] == (123, "metadata")
@QuentinSoubeyran it seems to work. Thanks. I guess I got confused because
Annotated[int, ...].__origin__ is intwhileget_origin(Annotated[int, ...]) is AnnotatedClosing this since the PEP has been accepted and implemented.
We propose adding an
Annotatedtype to the typing module to decorate existing types with context-specific metadata. Specifically, a typeTcan be annotated with metadataxvia the typehintAnnotated[T, x]. This metadata can be used for either static analysis or at runtime. If a library (or tool) encounters a typehintAnnotated[T, x]and has no special logic for metadatax, it should ignore it and simply treat the type asT. Unlike theno_type_checkfunctionality that current exists in thetypingmodule which completely disables typechecking annotations on a function or a class, theAnnotatedtype allows for both static typechecking ofT(e.g., via MyPy or Pyre, which can safely ignorex) together with runtime access toxwithin a specific application. We believe that the introduction of this type would address a diverse set of use cases of interest to the broader Python community.Motivating examples:
READING binary data
The
structmodule provides a way to read and write C structs directly from their byte representation. It currently relies on a string representation of the C type to read in values:The documentation suggests using a named tuple to unpack the values and make this a bit more tractable:
However, this recommendation is somewhat problematic; as we add more fields, it's going to get increasingly tedious to match the properties in the named tuple with the arguments in
unpack.Instead, annotations can provide better interoperability with a type checker or an IDE without adding any special logic outside of the
structmodule:dataclasses
Here's an example with dataclasses that is a problematic from the typechecking standpoint:
Even though one might expect that
mylistis a class attribute accessible viaC.mylist(likeC.myintis) due to the assignment syntax, that is not the case. Instead, the@dataclassdecorator strips out the assignment to this attribute, leading to anAttributeErrorupon access:This can lead to confusion for newcomers to the library who may not expect this behavior. Furthermore, the typechecker needs to understand the semantics of dataclasses and know to not treat the above example as an assignment operation in (which translates to additional complexity).
It makes more sense to move the information contained in
fieldto an annotation:The main benefit of writing annotations like this is that it provides a way for clients to gracefully degrade when they don't know what to do with the extra annotations (by just ignoring them). If you used a typechecker that didn't have any special handling for dataclasses and the
fieldannotation, you would still be able to run checks as though the type were simply:lowering barriers to developing new types
Typically when adding a new type, we need to upstream that type to the typing module and change MyPy, PyCharm, Pyre, pytype, etc. This is particularly important when working on open-source code that makes use of our new types, seeing as the code would not be immediately transportable to other developers' tools without additional logic (this is a limitation of MyPy plugins, which allow for extending MyPy but would require a consumer of new typehints to be using MyPy and have the same plugin installed). As a result, there is a high cost to developing and trying out new types in a codebase. Ideally, we should be able to introduce new types in a manner that allows for graceful degradation when clients do not have a custom MyPy plugin, which would lower the barrier to development and ensure some degree of backward compatibility.
For example, suppose that we wanted to add support for tagged unions to Python. One way to accomplish would be to annotate TypedDict in Python such that only one field is allowed to be set:
This is a somewhat cumbersome syntax but it allows us to iterate on this proof-of-concept and have people with non-patched IDEs work in a codebase with tagged unions. We could easily test this proposal and iron out the kinks before trying to upstream tagged union to
typing, MyPy, etc. Moreover, tools that do not have support for parsing theTaggedUnionannotation would still be able able to treatCurrencyas aTypedDict, which is still a close approximation (slightly less strict).Details of proposed changes to
typingSyntax
Annotatedis parameterized with a type and an arbitrary list of Python values that represent the annotations. Here are the specific details of the syntax:Annotatedmust be a validtypingtypeAnnotated[int, ValueRange(3, 10), ctype("char")]Annotatedreturns the underlying value:Annotated[int] == intAnnotated[int, ValueRange(3, 10), ctype("char")] != Annotated[int, ctype("char"), ValueRange(3, 10)]Annotatedtypes are flattened, with metadata ordered starting with the innermost annotation:Annotated[Annotated[int, ValueRange(3, 10)], ctype("char")] ==Annotated[int, ValueRange(3, 10), ctype("char")]``Annotated[int, ValueRange(3, 10)] != Annotated[int, ValueRange(3, 10), ValueRange(3, 10)]consuming annotations
Ultimately, the responsibility of how to interpret the annotations (if at all) is the responsibility of the tool or library encountering the
Annotatedtype. A tool or library encountering anAnnotatedtype can scan through the annotations to determine if they are of interest (e.g., usingisinstance).Unknown annotations:
When a tool or a library does not support annotations or encounters an unknown annotation it should just ignore it and treat annotated type as the underlying type. For example, if we were to add an annotation that is not an instance of
struct.ctypeto the annotation for name (e.g.,Annotated[str, 'foo', struct.ctype("<10s")]), the unpack method should ignore it.Namespacing annotations:
We do not need namespaces for annotations since the class used by the annotations acts as a namespace.
Multiple annotations:
It's up to the tool consuming the annotations to decide whether the client is allowed to have several annotations on one type and how to merge those annotations.
Since the
Annotatedtype allows you to put several annotations of the same (or different) type(s) on any node, the tools or libraries consuming those annotations are in charge of dealing with potential duplicates. For example, if you are doing value range analysis you might allow this:Flattening nested annotations, this translates to:
An application consuming this type might choose to reduce these annotations via an intersection of the ranges, in which case
T2would be treated equivalently toAnnotated[int, ValueRange(-10, 3)].An alternative application might reduce these via a union, in which case
T2would be treated equivalently toAnnotated[int, ValueRange(-20, 5)].In this example whether we reduce those annotations using union or intersection can be context dependant (covarient vs contravariant); this is why we have to preserve all of them and let the consumers decide how to merge them.
Other applications may decide to not support multiple annotations and throw an exception.
related bugs