Repository navigation
Conversation
TypedAttributeSet.__init_subclass__() compared every public name in dir(cls) against cls.__annotations__. On Python 3.10 and later, __annotations__ only ever contains the annotations declared in the class itself, so any attribute inherited from a base class was reported as "missing its type annotation". This made subclassing a typed attribute set impossible, even though the class docstring states that the check applies to "every public attribute of every subclass", and the typed attribute system is built around wrapping and extending the documented providers and attribute sets. The check now gathers annotations across the whole MRO. Attributes that a class does declare itself are still required to be annotated, and the three built-in sets (SocketAttribute, FileStreamAttribute, TLSAttribute) are unaffected since they declare every public attribute directly. The annotations are collected without evaluating them, so unlike get_type_hints() this does not require the annotation names to be resolvable at class creation time. Adds four tests in tests/test_typedattr.py covering subclassing with new attributes, an empty subclass, and that the annotation guard still fires both for a plain set and for the attributes a subclass declares itself. Three of them fail without this patch. Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
The pull request link was written with a double backtick at the start instead of the single backtick the RST link syntax needs, which left an inline literal without a matching end-string. The docs build runs with -W, so this failed the build with "Inline literal start-string without end-string" at versionhistory.rst:46. Co-authored-by: CommandCodeBot <noreply@commandcode.ai>
|
A note on the current CI status, since most of it is unrelated to this patch. The first run failed the The remaining red marks on the latest run are not caused by this patch:
|
agronholm
left a comment
There was a problem hiding this comment.
It looks like this PR comes with too many tests, not all of them focusing on this particular fix. Keep only the relevant tests (1 might be enough).
| def test_typedattr_set_missing_annotation() -> None: | ||
| """ | ||
| Test that a public attribute without a type annotation is rejected. | ||
|
|
||
| """ | ||
|
|
||
| with pytest.raises( | ||
| TypeError, match="^Attribute 'attr' is missing its type annotation$" | ||
| ): | ||
|
|
||
| class BadAttributeSet(TypedAttributeSet): | ||
| attr = typed_attribute() | ||
|
|
||
|
|
||
| def test_typedattr_set_subclassing() -> None: | ||
| """ | ||
| Test that an attribute set can be subclassed, and that the attributes inherited | ||
| from the base set are not reported as missing annotations. | ||
|
|
||
| """ | ||
|
|
||
| class BaseAttributeSet(TypedAttributeSet): | ||
| attr1: int = typed_attribute() | ||
|
|
||
| class DerivedAttributeSet(BaseAttributeSet): | ||
| attr2: str = typed_attribute() | ||
|
|
||
| assert DerivedAttributeSet.attr1 is BaseAttributeSet.attr1 | ||
| assert DerivedAttributeSet.attr2 is not None | ||
| assert "attr2" in dir(DerivedAttributeSet) | ||
|
|
||
|
|
||
| def test_typedattr_set_empty_subclass() -> None: | ||
| """ | ||
| Test that a subclass that adds no attributes of its own is accepted. | ||
|
|
||
| """ | ||
|
|
||
| class BaseAttributeSet(TypedAttributeSet): | ||
| attr1: int = typed_attribute() | ||
|
|
||
| class DerivedAttributeSet(BaseAttributeSet): | ||
| pass | ||
|
|
||
| assert DerivedAttributeSet.attr1 is BaseAttributeSet.attr1 | ||
|
|
||
|
|
||
| def test_typedattr_set_subclass_missing_annotation() -> None: | ||
| """ | ||
| Test that the annotation check still applies to the attributes declared by the | ||
| subclass itself. | ||
|
|
||
| """ | ||
|
|
||
| class BaseAttributeSet(TypedAttributeSet): | ||
| attr1: int = typed_attribute() | ||
|
|
||
| with pytest.raises( | ||
| TypeError, match="^Attribute 'attr2' is missing its type annotation$" | ||
| ): | ||
|
|
||
| class DerivedAttributeSet(BaseAttributeSet): | ||
| attr2 = typed_attribute() |
There was a problem hiding this comment.
Only test_typedattr_set_subclassing actually exercises the fix. The other tests either cover pre-existing behavior or go through the same code path, and the asserts in the remaining test can never fail (creating the class without a TypeError is the test). Suggest trimming down to this:
| def test_typedattr_set_missing_annotation() -> None: | |
| """ | |
| Test that a public attribute without a type annotation is rejected. | |
| """ | |
| with pytest.raises( | |
| TypeError, match="^Attribute 'attr' is missing its type annotation$" | |
| ): | |
| class BadAttributeSet(TypedAttributeSet): | |
| attr = typed_attribute() | |
| def test_typedattr_set_subclassing() -> None: | |
| """ | |
| Test that an attribute set can be subclassed, and that the attributes inherited | |
| from the base set are not reported as missing annotations. | |
| """ | |
| class BaseAttributeSet(TypedAttributeSet): | |
| attr1: int = typed_attribute() | |
| class DerivedAttributeSet(BaseAttributeSet): | |
| attr2: str = typed_attribute() | |
| assert DerivedAttributeSet.attr1 is BaseAttributeSet.attr1 | |
| assert DerivedAttributeSet.attr2 is not None | |
| assert "attr2" in dir(DerivedAttributeSet) | |
| def test_typedattr_set_empty_subclass() -> None: | |
| """ | |
| Test that a subclass that adds no attributes of its own is accepted. | |
| """ | |
| class BaseAttributeSet(TypedAttributeSet): | |
| attr1: int = typed_attribute() | |
| class DerivedAttributeSet(BaseAttributeSet): | |
| pass | |
| assert DerivedAttributeSet.attr1 is BaseAttributeSet.attr1 | |
| def test_typedattr_set_subclass_missing_annotation() -> None: | |
| """ | |
| Test that the annotation check still applies to the attributes declared by the | |
| subclass itself. | |
| """ | |
| class BaseAttributeSet(TypedAttributeSet): | |
| attr1: int = typed_attribute() | |
| with pytest.raises( | |
| TypeError, match="^Attribute 'attr2' is missing its type annotation$" | |
| ): | |
| class DerivedAttributeSet(BaseAttributeSet): | |
| attr2 = typed_attribute() | |
| def test_typedattr_set_subclassing() -> None: | |
| """ | |
| Test that an attribute set can be subclassed, and that the attributes inherited | |
| from the base set are not reported as missing annotations. | |
| """ | |
| class BaseAttributeSet(TypedAttributeSet): | |
| attr1: int = typed_attribute() | |
| class DerivedAttributeSet(BaseAttributeSet): | |
| attr2: str = typed_attribute() |
Changes
Fixes #.
TypedAttributeSet.__init_subclass__()compared every public name indir(cls)againstcls.__annotations__. Since Python 3.10,__annotations__only ever contains theannotations declared in the class itself, never the inherited ones, so every attribute
inherited from a base class was reported as missing its type annotation:
That made subclassing a typed attribute set impossible, even though the class docstring
says the check applies to "every public attribute of every subclass", and extending the
documented providers and attribute sets is the natural way to use the typed attribute
system. It only went unnoticed because all three built-in sets (
SocketAttribute,FileStreamAttribute,TLSAttribute) declare every public attribute directly.The check now gathers the annotations across the whole MRO. The attributes a class
declares itself are still required to be annotated, and the built-in sets are unaffected.
The annotations are collected without being evaluated, so unlike
get_type_hints()thisdoes not require the annotation names to be resolvable at class creation time, which
would otherwise turn an unresolvable forward reference into a
NameError.Checklist
tests/) which would fail without your patchdocs/), in case of behavior changes or newfeatures
docs/versionhistory.rst).