Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@
* (Python) `KeyedModelHandler` and `MaybeKeyedModelHandler` now apply the `element_size_fn` and `batch_length_fn` of the wrapped `ModelHandler` to examples, instead of to `(key, example)` pairs ([#40644](https://github.com/apache/beam/issues/40644)).
* (Python) DataFrame API: `drop_duplicates`, `duplicated` and `nunique` no longer fail on rows with NaN values or on categorical columns with unused categories, and `nunique(dropna=False)` now counts NaN ([#40570](https://github.com/apache/beam/issues/40570)).
* (Java) BigQuery Storage Write API `TableRow` conversion now honors `allowMissingRequiredFields` when building protocol-buffer messages, so callers that pass `true` with required descriptors get partial messages instead of a conversion-time `UninitializedMessageException` ([#40189](https://github.com/apache/beam/issues/40189)).
* (Python) Type hints with a `typing.Protocol` that is not `@runtime_checkable`, such as `def fn(x: MyProtocol)` in `beam.Map(fn)`, no longer make pipeline or runtime type checking fail with a `TypeError` ([#20986](https://github.com/apache/beam/issues/20986)).
* Fixed X (Java/Python) ([#X](https://github.com/apache/beam/issues/X)).

## Security Fixes
Expand Down
14 changes: 14 additions & 0 deletions sdks/python/apache_beam/typehints/typecheck_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
# ruff: noqa: UP006
import os
import tempfile
import typing
import unittest
from typing import Iterable
from typing import Tuple
Expand Down Expand Up @@ -134,6 +135,19 @@ def test_wrapper_pipeline_type_check(self):
with self.assertRaisesRegex(ValueError, r'int.*is not iterable'):
_ = self.p | beam.Create([1, 2, 3]) | beam.ParDo(dofn)

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()
Comment on lines +138 to +149

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: Could we also add a corresponding test in PerformanceRuntimeTypeCheckTest right below to make sure the --performance_runtime_type_check path stays covered too?



class PerformanceRuntimeTypeCheckTest(unittest.TestCase):
def setUp(self):
Expand Down
13 changes: 13 additions & 0 deletions sdks/python/apache_beam/typehints/typed_pipeline_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,19 @@ def fn(element: int) -> typehints.Optional[int]:
result = [1, 2, 3] | beam.Map(fn)
self.assertCountEqual([None, 2, 3], result)

def test_typed_map_protocol_input(self):
# https://github.com/apache/beam/issues/20986
class HasUpper(typing.Protocol):
def upper(self) -> str:
...

def fn(element: HasUpper) -> str:
return element.upper()

with TestPipeline() as p:
result = p | beam.Create(['a', 'b']) | beam.Map(fn)
assert_that(result, equal_to(['A', 'B']))

def test_inferred_type_of_tuple_slice(self):
# https://github.com/apache/beam/issues/20899
rows = [('day1', 'a', 1), ('day2', 'a', 2), ('day1', 'b', 3)]
Expand Down
15 changes: 15 additions & 0 deletions sdks/python/apache_beam/typehints/typehints.py
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,16 @@ def is_typing_generic(type_param):
return isinstance(type_param, typing._GenericAlias)


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))
Comment on lines +371 to +378

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 validate_composite_type_param(type_param, error_msg_prefix):
"""Determines if an object is a valid type parameter to a
:class:`CompositeTypeHint`.
Expand Down Expand Up @@ -429,6 +439,8 @@ def check_constraint(type_constraint, object_instance):
pass
elif not isinstance(type_constraint, type):
raise RuntimeError("bad type: %s" % (type_constraint, ))
elif _is_unchecked_protocol(type_constraint):
pass
elif not isinstance(object_instance, type_constraint):
raise SimpleTypeHintError

Expand Down Expand Up @@ -1536,6 +1548,9 @@ def is_consistent_with(
# Cannot check unsupported parameterized generic which will cause issubclass
# to fail with an exception.
return False
elif _is_unchecked_protocol(base):

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

# Protocols are matched structurally, which issubclass cannot check.
return True
Comment on lines +1551 to +1553

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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])?

if use_beartype:
try:
return is_subhint(sub, base)
Expand Down
Loading