Repository navigation
Conversation
A typing.Protocol that is not runtime_checkable makes issubclass and isinstance raise TypeError, so a hint such as def fn(x: MyProtocol) in beam.Map(fn) failed pipeline type checking unless the input type was the same Protocol, and failed every element with runtime type checking. Such a Protocol is matched structurally, which these checks cannot do, so is_consistent_with now treats it as consistent and check_constraint skips it. Fixes apache#20986
There was a problem hiding this comment.
📋 Review Summary
This PR prevents pipeline construction and runtime type checks from raising TypeError when encountering non-@runtime_checkable typing.Protocol type hints by treating them as consistent in is_consistent_with and skipping isinstance in check_constraint. The approach is clean and well-targeted, with one ordering issue in is_consistent_with when the input type is a TypeConstraint.
🔍 General Feedback
_is_unchecked_protocoland its usage incheck_constraintcleanly handle simple and nested composite hints at runtime.- In
is_consistent_with, placing the_is_unchecked_protocol(base)check before theisinstance(sub, RowTypeConstraint)andisinstance(sub, TypeConstraint)branches will ensure protocols also work when the inputPCollectionhas a Beam composite/container type (such asList[...],Tuple[...],Dict[...], orRow).
💡 Tip: Comment /gemini review on this pull request to trigger a new Gemini code review.
| # Cannot check unsupported parameterized generic which will cause issubclass | ||
| # to fail with an exception. | ||
| return False | ||
| elif _is_unchecked_protocol(base): |
There was a problem hiding this comment.
🟡 Medium: Because elif _is_unchecked_protocol(base): is placed after elif isinstance(sub, RowTypeConstraint): (line 1540) and elif isinstance(sub, TypeConstraint): (line 1542), is_consistent_with(sub, base) still returns False whenever sub is a Beam TypeConstraint (for example, List[int], Tuple[int, int], Dict[str, int], Set[int], or RowTypeConstraint—such as in p | beam.Create([[1, 2]]) | beam.Map(fn) where fn is annotated with a Protocol).
Moving the _is_unchecked_protocol(base) check above elif isinstance(sub, RowTypeConstraint): (or right after if isinstance(sub, AnyTypeConstraint) or isinstance(base, AnyTypeConstraint):) ensures that container/row PCollection element types are also considered consistent with an unchecked Protocol.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #40757 +/- ##
==========================================
- Coverage 80.04% 79.89% -0.16%
==========================================
Files 490 493 +3
Lines 82916 83469 +553
==========================================
+ Hits 66373 66688 +315
- Misses 16543 16781 +238
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Assigning reviewers: R: @damccorm for label python. This pull request likely touches a core component ("core" label). Please review with scrutiny. Note: If you would like to opt out of this review, comment Available commands:
The PR bot will only process comments in the main thread (not review comments). |
| elif _is_unchecked_protocol(base): | ||
| # Protocols are matched structurally, which issubclass cannot check. | ||
| return True |
There was a problem hiding this comment.
Because elif _is_unchecked_protocol(base): comes after elif isinstance(sub, RowTypeConstraint): and elif isinstance(sub, TypeConstraint):, container and Row element types (e.g. p | beam.Create([[1, 2]]) | beam.Map(fn) where fn takes a HasLen protocol) still return False and fail pipeline type checking. Could we move this check right after elif isinstance(base, TypeConstraint): and add a test case with a container input (e.g. list[int])?
| def _is_unchecked_protocol(type_param): | ||
| """Determines if an object is a typing.Protocol class that is not | ||
| runtime_checkable, which isinstance() and issubclass() reject. | ||
| """ | ||
| return ( | ||
| isinstance(type_param, type) and | ||
| getattr(type_param, '_is_protocol', False) and | ||
| not getattr(type_param, '_is_runtime_protocol', False)) |
There was a problem hiding this comment.
Worth considering while we're here: even with @typing.runtime_checkable, Python raises TypeError: Protocols with non-method members don't support issubclass() when a Protocol declares attributes or @property members (e.g. x: int), which still crashes both is_subhint(sub, base) and issubclass(sub, base) in is_consistent_with, even though isinstance() in check_constraint works fine at runtime.
Keeping _is_unchecked_protocol for check_constraint, and using a helper in is_consistent_with that checks whether issubclass(object, base) raises TypeError when getattr(base, '_is_protocol', False) is true, would handle both unchecked protocols and @runtime_checkable attribute/property protocols cleanly while keeping runtime isinstance checks active for @runtime_checkable protocols.
| def test_protocol_type_hint(self): | ||
| # https://github.com/apache/beam/issues/20986 | ||
| class HasUpper(typing.Protocol): | ||
| def upper(self) -> str: | ||
| ... | ||
|
|
||
| def fn(e: HasUpper) -> str: | ||
| return e.upper() | ||
|
|
||
| result = self.p | beam.Create(['a', 'b']) | beam.Map(fn) | ||
| assert_that(result, equal_to(['A', 'B'])) | ||
| self.p.run() |
There was a problem hiding this comment.
Nit: Could we also add a corresponding test in PerformanceRuntimeTypeCheckTest right below to make sure the --performance_runtime_type_check path stays covered too?
|
Please also resolve the conflicts |
|
waiting on author |
Fixes #20986
A type hint with a
typing.Protocolthat is not@runtime_checkablemakes the pipeline fail at construction:is_consistent_with(str, HasUpper)callsissubclass, through beartype'sis_subhintor directly, and Python raises this error for such a Protocol. With--runtime_type_checkor--performance_runtime_type_check,check_constraintcallsisinstanceon each element and fails the same way. So a non-runtime Protocol hint works today only when the input type is exactly the same Protocol.A Protocol is matched structurally, and Beam cannot check that with
issubclassorisinstance. With this change,is_consistent_withreturnsTruewhen the base is such a Protocol, andcheck_constraintskips it, as it does forAny. For these Protocols, both calls always raised theTypeErrorbefore. The only caller that caught it is the runtime check of aUnion, which counted it as a mismatch, so a runtime check ofOptional[HasUpper]now also passes for astr. The element type stays the Protocol, so the coder does not change.@runtime_checkableProtocols keep theissubclass/isinstancechecks that they have today.Tests:
test_typed_map_protocol_inputintyped_pipeline_test.pyruns the pipeline above.test_protocol_type_hintintypecheck_test.pyruns it withruntime_type_check=True.Both fail before the change and pass after it on Python 3.11, 3.12, 3.13 and 3.14.
Testing:
pytest apache_beam/typehints/typed_pipeline_test.py apache_beam/typehints/typecheck_test.py -k protocolon Python 3.11, 3.12, 3.13 and 3.14pytest apache_beam/typehints apache_beam/transforms apache_beam/coders apache_beam/pipeline_test.pyon Python 3.12yapf --diffandruff checkon the changed filesThank you for your contribution! Follow this checklist to help us incorporate your contribution quickly and easily:
addresses #123orfixes #456).CHANGES.mdwith noteworthy changes.See the Contributor Guide for more tips on how to make review process smoother.