In Optional class and protocol fields and methods · Issue #601 · python/typing · GitHub, I proposed some potential semantics for modeling non-required attributes. The goal is giving users a way to annotate attributes that may not be initialized or methods that may not be present on a class.
I’ve had some trouble trying to extend the semantics to support methods, so I’m cross-posting it here to get more feedback and see if anyone has some good ideas. I’d also like to hear opinions on whether the proposal in its current form is worth pursuing further.
To save you from scrolling through the whole thread, here is the proposal in one continuous text:
NotRequired
The NotRequired
qualifier will be allowed for attribute annotations in classes and protocols.
Structural Typechecking
If a protocol has a NotRequired attribute, structural typechecking will depend on finality:
- If the class is final, it can omit the attribute, declare it normally, or declare it with NotRequired
- If the class is not final, it must declare the attribute normally or with NotRequired; the attribute may NOT be omitted
class Proto(Protocol):
x: NotRequired[int]
class Class1:
pass
@final
class Class2:
pass
class Class3:
x: NotRequired[int]
class Class4:
x: int
a: Proto = Class1() # not OK
b: Proto = Class2() # OK
c: Proto = Class3() # OK
d: Proto = Class4() # OK
The requirement that implementing classes must declare the attribute as NotRequired (instead of allowing it to be omitted) is motivated by this example from @JelleZijlstra
class P(Protocol):
a: int
b: NotRequired[str]
def user(p: P) -> None:
if hasattr(p, 'b'):
print(len(p.b))
class A:
a: int
def __init__(self) -> None:
self.a = 3
class B(A):
b: int
def __init__(self) -> None:
super().__init__()
self.b = 1
def accept_a(a: A) -> None:
user(a) # OK, A has an attribute a of the right type and doesn't have an attribute b
accept_a(B()) # boom
@runtime_checkable
Attributes annotated with NotRequired in a runtime_checkable protocol will be skipped when checking an object at runtime. The current runtime checkable behavior only checks for the presence of an attribute with the right name without checking the type.
Overrides
Subclasses may only remove the NotRequired
qualifier from an overridden attribute.
Final
NotRequired will not be compatible with the Final qualifier in the same type annotation, since attributes with the latter are required to be initialized so NotRequired wouldn’t do anything.
ReadOnly
PEP767 may introduce read-only attributes. Subclasses will be allowed to override ReadOnly attributes to remove NotRequired
(making a non-required attribute required).
class Class1:
x: ReadOnly[NotRequired[int]]
class Class2(Class1):
x: ReadOnly[int] # OK
class Class3(Class1):
x: ReadOnly[NotRequired[int]] # OK
Pattern Matching
Not-required attributes are not allowed to be used with __match_args__
, and may not be matched in general.
Assignment/Deletion
There will be no changes to assignment or deletion behavior at runtime. For the purposes of typechecking, assignment to NotRequired attributes will work the same as attributes annotated without the qualifier.
Currently, despite all attributes being “required”, none of the major typecheckers prevent attributes from being deleted. This behavior will stay the same, and both regular and NotRequired attributes will be deletable.
Access
There will be no changes to access behavior at runtime. Typecheckers may error if the attribute is accessed without being narrowed (using a hasattr
call or assigning to it) or if getattr
is used without a default.
This would be similar to emitting errors for accessing non-required TypedDict keys, and narrowing would work the same way (Mypy and Pyre don’t support this kind of error/narrowing, but Pyright does).
Given the lack of standardization of the equivalent typechecking behavior for TypedDicts, I think we probably want to make this behavior optional for now.
class Class1:
x: NotRequired[int]
c = Class1()
if hasattr(c, "x"):
c.x # OK
c.x # not OK (optional)
Uninitialized Attribute Checks
Typecheckers including Mypy, Pyright, and Pyre, can check for uninitialized attributes, though this is generally a best-effort check and opt-in/experimental in some cases. Typecheckers should not raise an error if the uninitialized attribute is annotated with NotRequired.
Dataclasses
There are 2 options here:
- Ban this type qualifier in dataclasses
- Have some special handling based on inspecting the type, just like we do for
ClassVar
. We could create a new typeUninitializedType
that’s inhabited by a single value (similar toNoneType
orEllipsisType
). It wouldn’t have a use outside of dataclasses, but if it’s passed as an argument to the constructor or used as a default for a dataclass then the attribute could be left uninitialized, like in the below example:
@dataclass
class Foo:
x: NotRequired[int]
# generated __init__ method would look like this
def __init__(self, x: int | dataclass.UninitializedType = dataclass.Uninitialized): ...
@not_required
decorator for methods
NotRequired
works decently for attributes, but modeling methods with an equivalent decorator is tricky. This is because methods declared in the class body are always bound in the class, even abstract ones, so there’s no way of declaring a type for a method without adding the method to the class.
We could just not allow this to be used with methods, but that would limit the expressiveness of the feature.
There is a way to have this decorator, but only have it work in a limited context, as follows:
- The decorator will only work in Protocols (except in stubs where it can be used anywhere). When a concrete class inherits from a protocol, the decorator does nothing and the method will be present as usual.
- In a normal file, the only way for a concrete class to match the protocol without declaring the method is for the class to be final. In a stub file, you can pair the decorator with
@type_checking_only
in any concrete class.
I have mixed feelings on this section since it’s pretty confusing to even write this out.
Effect for Users
When implementing all the required proposed features as described above (but none of the optional ones), this is what changes.
Compared to leaving the attribute unannotated:
- We know what type the attribute is when accessing/assigning (instead of giving an unknown attribute error/defaulting to Any)
- We can restrict the type of the attribute in a subclass
Compared to annotating the attribute with an unqualified type:
- No typechecker complaints on uninitialized attributes
- The presence of the qualifier documents that the attribute may be absent
Things the proposed semantics do NOT guarantee:
- Absence of NotRequired means that the attribute is present
- Attribute accesses to NotRequired attributes must be gated
- Banning deletion of required attributes