Skip to content

Fix two gaps in Protocol checking: property members are skipped, and lookup errors escape check_type - #598

Open
feiiiiii5 wants to merge 3 commits into
agronholm:masterfrom
feiiiiii5:fix/protocol-attribute-error
Open

feiiiiii5 wants to merge 3 commits into
agronholm:masterfrom
feiiiiii5:fix/protocol-attribute-error

Conversation

@feiiiiii5

Copy link
Copy Markdown

Changes

Two gaps in check_protocol (src/typeguard/_checkers.py), both in how a protocol member is looked up on the value being checked.

1. A member declared as a property is not checked at all.

The checker dispatches on two things: whether the member has an annotation, and whether getattr(origin_type, member) is callable. A property has no annotation, and what the protocol body holds for it is a property descriptor, which is not callable — so neither branch matched and the member was skipped:

class P(Protocol):
    @property
    def prop(self) -> int: ...

class Unrelated:
    pass

check_type(Unrelated(), P)   # accepted

The same protocol written as a method or as an annotation (prop: int) correctly rejects Unrelated(). A false pass is the worst direction for this: the point of check_type(value, SomeProtocol) is to establish that the value satisfies the protocol.

2. A lookup error other than AttributeError escapes check_type().

The two getattr probes only handle AttributeError. A __getattr__ that raises something else, or a property whose getter raises, is legal Python, and the exception came out of check_type unchanged — so a caller following the documented "Raises TypeCheckError if there's a problem" would not catch it:

class Hostile:
    def __getattr__(self, name): raise ValueError("nope")

check_type(Hostile(), P)    # ValueError: nope

Both probes now convert a non-AttributeError into a TypeCheckError that keeps the original as __cause__, so the diagnosis survives and the two cases stay distinguishable: absent (has no attribute named ...) versus unreadable (reading its ... attribute raised ...).

Only Exception is caught, so KeyboardInterrupt and friends still propagate — there is a test for that boundary.

What I deliberately did not change

For an unannotated property member I only require presence. Checking the return type would go beyond what this function already does for methods, where parameter and return assignability is still an open TODO a few lines above.

If you would rather these exceptions keep propagating — hasattr() does too, so there is a precedent — then only the first change is wanted and I will split it out; the else: branch is the whole of it.

Validation

On 123f1ab, reverting only _checkers.py and running the new tests:

The eight failures on the base commit
FAILED test_attribute_lookup_error_is_wrapped[ValueError]
FAILED test_attribute_lookup_error_is_wrapped[KeyError]
FAILED test_attribute_lookup_error_is_wrapped[RuntimeError]
FAILED test_attribute_lookup_error_is_wrapped[RecursionError]
FAILED test_method_lookup_error_is_wrapped[ValueError]
FAILED test_method_lookup_error_is_wrapped[RecursionError]
FAILED test_property_member_is_required
FAILED test_property_raising_is_wrapped
8 failed, 1 passed

The one that passes is test_base_exception_from_lookup_still_propagates, which asserts the boundary the patch must not cross, so it passes both before and after.

Full suite: 539 passed on 123f1ab, 548 passed with the patch — the difference is exactly the 9 new cases. ruff check, ruff format --check and mypy src/typeguard are clean (mypy reports one pre-existing typeshed import error in _decorators.py, present on the base commit too).

Checklist

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

Updating the changelog

Two entries added under **UNRELEASED**. Following the note in the template, the pull request link is added in a follow-up commit once the PR number exists.

check_protocol dispatches on two things: whether the member has an annotation,
and whether getattr(origin_type, member) is callable. A member declared as a
property has no annotation, and what the protocol body holds for it is a
property descriptor, which is not callable -- so neither branch matched and the
member was never checked. An object with no such attribute at all was accepted:

    class P(Protocol):
        @Property
        def prop(self) -> int: ...

    class Unrelated:
        pass

    check_type(Unrelated(), P)   # accepted

The same protocol written as a method, or as `prop: int`, correctly rejects it.

The second gap is at the same two getattr() probes, which only handled
AttributeError. A __getattr__ that raises something else, or a property whose
getter raises, is legal Python, and the exception escaped check_type() instead
of being reported as TypeCheckError -- so a caller following the documented
"raises TypeCheckError if there's a problem" would not catch it. Both probes now
convert a non-AttributeError into a TypeCheckError that keeps the original as
__cause__, so the diagnosis survives. Only Exception is caught, so
KeyboardInterrupt and friends still propagate.

Presence is all that is established for an unannotated property member, which
matches how method return types are already handled here (see the TODO above
about assignability checks).
@coveralls

coveralls commented Sep 27, 2026 •

Copy link
Copy Markdown

Coverage Status

coverage: 94.888% (+0.04%) from 94.853% — feiiiiii5:fix/protocol-attribute-error into agronholm:master

…ated attribute

The previous commit required a protocol member to be present on the subject
whenever it was neither annotated nor callable. get_protocol_members returns
more than that: an attribute a protocol body defines by plain assignment shows
up in it, and those are not members as far as the type checkers are concerned.

    class Template(Protocol):
        kind = "point"      # mypy: "All protocol members must have explicitly
                            # declared types" -- rejected at the declaration
    class Minimal: ...     # pyright: Minimal() is assignable to Template
                            # typeguard before this commit: accepted

So a protocol carrying a concrete default or a concrete helper started rejecting
subjects that do not have it, which implementations are not required to, since
they need not inherit from the protocol. That made the checker stricter than
both mypy and pyright on the same input, and stricter than its own base head.

The branch now fires only for a property, which is the one member kind the spec
allows that carries no annotation -- the protocol body holds a property object,
which is not callable, so neither existing branch matched it and it went
unchecked. An assignment with a declared type is still a member, default or
not, exactly as before.

Three-way check, Protocol tests only:

    base 123f1ab   8 failed, 41 passed   the property and lookup tests
    previous head     5 failed, 44 passed   the unannotated-attribute tests
    this commit      49 passed

Full suite 554 passed, 5 skipped, 9 xfailed. ruff check, ruff format --check and
mypy all clean.
@feiiiiii5

Copy link
Copy Markdown
Author

While reviewing my own diff I found the first commit over-reached, and pushed 7b1d1a6 to narrow it.

The else branch I added required a member to be present on the subject whenever it was neither annotated nor callable. That covers more than the type checkers treat as a member: get_protocol_members also returns attributes a protocol body defines by plain assignment.

class Template(Protocol):
    kind = "point"   # mypy: error "All protocol members must have explicitly
                     # declared types" -- rejected at the declaration itself

class Minimal: ...
t: Template = Minimal()   # pyright:               no error
                         # typeguard @ 123f1abc7e: no error
                         # typeguard @ bf0d0954:  TypeCheckError

So a protocol carrying a concrete default, or a concrete helper beside its real members, began rejecting subjects that lack it — and an implementation need not carry those, because it is not required to inherit from the protocol. On the same input the checker had become stricter than mypy, stricter than pyright, and stricter than its own base head, which is the opposite of what a runtime checker should do when the static ones disagree with it.

The branch now fires only for a property: the one member kind the spec allows that carries no annotation. What the protocol body holds is a property object, which is not callable, so neither existing branch matched it and it went unchecked — that is the real gap. An assignment with a declared type stays a member, default or not, exactly as before.

Three-way, TestProtocol only:

property + lookup tests unannotated-attribute tests
base 123f1abc7e 8 failed pass
bf0d0954 pass 5 failed
7b1d1a6 pass pass

Full suite 554 passed, 5 skipped, 9 xfailed; ruff check, ruff format --check and mypy clean. The two changelog entries still describe exactly what is fixed, so I left them as they are.

One limit worth naming, since the branch is now narrow: a functools.cached_property in a protocol body is still unchecked, because it is neither a property nor callable. That is the same false negative this PR set out to remove, not a new one — I would rather leave it visible than widen the branch again on a guess. Happy to add it if you read that as a member too.

@feiiiiii5

Copy link
Copy Markdown
Author

Nudging this since it's been sitting unreviewed for a while. All checks are green on a795f18d: the full test matrix (3.10–3.15), coverage (+0.04% to 94.9%), pre-commit.ci, and the docs build.

Two things I'd like your call on rather than assume:

Should these be one PR or two? The two gaps are independent — protocol property members being skipped, and lookup errors escaping check_type. They share a test file and a changelog but not a code path. If you would rather review them separately, say so and I'll split the second one out; I kept them together because both are "a @runtime_checkable Protocol with non-method members does not do what its declaration promises", but that grouping is my judgement, not yours.

The else branch I added (narrowed in 7b1d1a6 after I found my first commit over-reached) currently requires the member to be present on the subject. That means a Protocol declaring a property the object lacks entirely falls through to whatever the check would have done anyway, rather than raising. That is deliberate — a missing attribute and an unannotated one are different situations — but if you would rather it raised, that is a small change and I would want a test pinning the distinction first.

Happy to rebase onto main if that would help; I have not done so unprompted since the branch is not behind.

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