Skip to content

Fixed TypedAttributeSet rejecting its own subclasses - #1358

Open
pasmud wants to merge 8 commits into
agronholm:masterfrom
pasmud:gsz/fix-typedattr-set-inherited-annotations
Open

pasmud wants to merge 8 commits into
agronholm:masterfrom
pasmud:gsz/fix-typedattr-set-inherited-annotations

Conversation

@pasmud

@pasmud pasmud commented Sep 28, 2026

Copy link
Copy Markdown

Changes

Fixes #.

TypedAttributeSet.__init_subclass__() compared every public name in dir(cls) against
cls.__annotations__. Since Python 3.10, __annotations__ only ever contains the
annotations declared in the class itself, never the inherited ones, so every attribute
inherited from a base class was reported as missing its type annotation:

>>> class MyTypedAttribute(TypedAttributeSet):
...     string_valued_attribute: str = typed_attribute()
>>> class MyOtherTypedAttribute(MyTypedAttribute):
...     some_float_attribute: float = typed_attribute()
TypeError: Attribute 'string_valued_attribute' is 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() this
does not require the annotation names to be resolvable at class creation time, which
would otherwise turn an unresolvable forward reference into a NameError.

Checklist

  • You've added tests (in tests/) which would fail without your patch
  • You've updated the documentation (in docs/), in case of behavior changes or new
    features
  • You've added a new changelog entry (in docs/versionhistory.rst).

pasmud and others added 3 commits September 28, 2026 15:29
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>
@pasmud

pasmud commented Sep 28, 2026

Copy link
Copy Markdown
Author

A note on the current CI status, since most of it is unrelated to this patch.

The first run failed the docs job because the pull request link in my changelog
entry was written with a double backtick instead of the single backtick the RST link
syntax needs. That has been fixed in c280702, and the docs job now passes.

The remaining red marks on the latest run are not caused by this patch:

  • 13 of the 14 failing jobs failed only in the Upload Coverage step with
    Error: Internal Server Error (500) from Coveralls. The Test with pytest step
    succeeded in every one of them.
  • The one job whose Test with pytest step failed is
    test (ubuntu-latest, pypy3.11-v7.3.23, [trio], false), on
    test_sockets.py::TestTCPStream::test_cancelled_send_does_not_send_the_next_one[trio-ipv6].
    That test sends an 8 MB payload and depends on wait_all_tasks_blocked() timing. It
    passes here on all 6 of its parametrizations, and the same job has flaked on master
    before. All five tests in tests/test_typedattr.py passed in that job.

@agronholm agronholm left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

@agronholm agronholm added this to the 4.16.0 milestone Oct 9, 2026
Comment thread tests/test_typedattr.py
Comment on lines +30 to +92
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()

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

Suggested change
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()

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants