Repository navigation
Fix two gaps in Protocol checking: property members are skipped, and lookup errors escape check_type - #598
Fix two gaps in Protocol checking: property members are skipped, and lookup errors escape check_type#598feiiiiii5 wants to merge 3 commits into
Conversation
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).
…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.
|
While reviewing my own diff I found the first commit over-reached, and pushed The 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: TypeCheckErrorSo 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 Three-way,
Full suite 554 passed, 5 skipped, 9 xfailed; One limit worth naming, since the branch is now narrow: a |
|
Nudging this since it's been sitting unreviewed for a while. All checks are green on 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 The Happy to rebase onto |
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
propertyis 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 apropertydescriptor, which is not callable — so neither branch matched and the member was skipped:The same protocol written as a method or as an annotation (
prop: int) correctly rejectsUnrelated(). A false pass is the worst direction for this: the point ofcheck_type(value, SomeProtocol)is to establish that the value satisfies the protocol.2. A lookup error other than
AttributeErrorescapescheck_type().The two
getattrprobes only handleAttributeError. A__getattr__that raises something else, or a property whose getter raises, is legal Python, and the exception came out ofcheck_typeunchanged — so a caller following the documented "Raises TypeCheckError if there's a problem" would not catch it:Both probes now convert a non-
AttributeErrorinto aTypeCheckErrorthat 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
Exceptionis caught, soKeyboardInterruptand 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
TODOa 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; theelse:branch is the whole of it.Validation
On
123f1ab, reverting only_checkers.pyand running the new tests:The eight failures on the base commit
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 --checkandmypy src/typeguardare clean (mypy reports one pre-existingtypeshedimport error in_decorators.py, present on the base commit too).Checklist
tests/) added which would fail without your patchdocs/, in case of behavior changes or newfeatures)
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.