Skip to content

Commit 1ae7160

Browse files
Merge runtime type-checking wrappers into a single layer (BEAM-9489)
1 parent a2a14f5 commit 1ae7160

2 files changed

Lines changed: 103 additions & 47 deletions

File tree

sdks/python/apache_beam/typehints/typecheck.py

Lines changed: 52 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -84,25 +84,64 @@ def teardown(self):
8484
return self.dofn.teardown()
8585

8686

87-
class OutputCheckWrapperDoFn(AbstractDoFnWrapper):
88-
"""A DoFn that verifies against common errors in the output type."""
89-
def __init__(self, dofn, full_label):
87+
class RuntimeTypeCheckWrapperDoFn(AbstractDoFnWrapper):
88+
"""A DoFn wrapper performing runtime type-checking of inputs and outputs.
89+
90+
This single wrapper performs the work that was previously split between
91+
two nested wrappers (``TypeCheckWrapperDoFn`` wrapped inside
92+
``OutputCheckWrapperDoFn``): type-checking of inputs and outputs against
93+
declared type hints, verification against common output errors (such as
94+
returning a plain ``str``, ``bytes`` or ``dict`` from a ``ParDo``), and
95+
labeling of raised ``TypeCheckError`` messages with the offending
96+
transform's full label. Merging the wrappers removes one level of Python
97+
call indirection per processed element when ``--runtime_type_check`` is
98+
enabled.
99+
"""
100+
def __init__(self, dofn, type_hints, full_label):
90101
super().__init__(dofn)
91102
self.full_label = full_label
103+
self._process_fn = self.dofn._process_argspec_fn()
104+
if type_hints.input_types:
105+
input_args, input_kwargs = type_hints.input_types
106+
self._input_hints = getcallargs_forhints(
107+
self._process_fn, *input_args, **input_kwargs)
108+
else:
109+
self._input_hints = None
110+
# TODO(robertwb): Multi-output.
111+
self._output_type_hint = type_hints.simple_output_type(full_label)
92112

93113
def wrapper(self, method, args, kwargs):
94114
try:
95-
result = method(*args, **kwargs)
115+
result = self._type_check_result(method(*args, **kwargs))
96116
except TypeCheckError as e:
97-
# TODO(BEAM-10710): Remove the 'ParDo' prefix for the label name
98-
error_msg = (
99-
'Runtime type violation detected within ParDo(%s): '
100-
'%s' % (self.full_label, e))
101-
_, _, tb = sys.exc_info()
102-
raise TypeCheckError(error_msg).with_traceback(tb)
117+
raise self._add_label(e)
118+
else:
119+
return self._check_type(result)
120+
121+
def process(self, *args, **kwargs):
122+
try:
123+
if self._input_hints:
124+
actual_inputs = inspect.getcallargs(self._process_fn, *args, **kwargs) # pylint: disable=deprecated-method
125+
for var, hint in self._input_hints.items():
126+
if hint is actual_inputs[var]:
127+
# self parameter
128+
continue
129+
_check_instance_type(hint, actual_inputs[var], var, True)
130+
result = self._type_check_result(self.dofn.process(*args, **kwargs))
131+
except TypeCheckError as e:
132+
raise self._add_label(e)
103133
else:
104134
return self._check_type(result)
105135

136+
def _add_label(self, e):
137+
"""Returns a TypeCheckError labeled with this transform's full label."""
138+
# TODO(BEAM-10710): Remove the 'ParDo' prefix for the label name
139+
error_msg = (
140+
'Runtime type violation detected within ParDo(%s): '
141+
'%s' % (self.full_label, e))
142+
_, _, tb = sys.exc_info()
143+
return TypeCheckError(error_msg).with_traceback(tb)
144+
106145
@staticmethod
107146
def _check_type(output):
108147
if output is None:
@@ -120,36 +159,6 @@ def _check_type(output):
120159
'iterable. %s was returned instead.' % type(output))
121160
return output
122161

123-
124-
class TypeCheckWrapperDoFn(AbstractDoFnWrapper):
125-
"""A wrapper around a DoFn which performs type-checking of input and output.
126-
"""
127-
def __init__(self, dofn, type_hints, label=None):
128-
super().__init__(dofn)
129-
self._process_fn = self.dofn._process_argspec_fn()
130-
if type_hints.input_types:
131-
input_args, input_kwargs = type_hints.input_types
132-
self._input_hints = getcallargs_forhints(
133-
self._process_fn, *input_args, **input_kwargs)
134-
else:
135-
self._input_hints = None
136-
# TODO(robertwb): Multi-output.
137-
self._output_type_hint = type_hints.simple_output_type(label)
138-
139-
def wrapper(self, method, args, kwargs):
140-
result = method(*args, **kwargs)
141-
return self._type_check_result(result)
142-
143-
def process(self, *args, **kwargs):
144-
if self._input_hints:
145-
actual_inputs = inspect.getcallargs(self._process_fn, *args, **kwargs) # pylint: disable=deprecated-method
146-
for var, hint in self._input_hints.items():
147-
if hint is actual_inputs[var]:
148-
# self parameter
149-
continue
150-
_check_instance_type(hint, actual_inputs[var], var, True)
151-
return self._type_check_result(self.dofn.process(*args, **kwargs))
152-
153162
def _type_check_result(self, transform_results):
154163
if self._output_type_hint is None or transform_results is None:
155164
return transform_results
@@ -289,11 +298,9 @@ def visit_transform(self, applied_transform):
289298
if isinstance(transform.fn, core.CombineValuesDoFn):
290299
transform.fn.combinefn = self._wrapped_fn
291300
else:
292-
transform.fn = transform.dofn = OutputCheckWrapperDoFn(
293-
TypeCheckWrapperDoFn(
294-
transform.fn,
295-
transform.get_type_hints(),
296-
applied_transform.full_label),
301+
transform.fn = transform.dofn = RuntimeTypeCheckWrapperDoFn(
302+
transform.fn,
303+
transform.get_type_hints(),
297304
applied_transform.full_label)
298305

299306

sdks/python/apache_beam/typehints/typecheck_test.py

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,12 @@
3434
from apache_beam import Pipeline
3535
from apache_beam.options.pipeline_options import PipelineOptions
3636
from apache_beam.options.pipeline_options import TypeOptions
37+
from apache_beam.pipeline import PipelineVisitor
3738
from apache_beam.testing.test_pipeline import TestPipeline
3839
from apache_beam.testing.util import assert_that
3940
from apache_beam.testing.util import equal_to
4041
from apache_beam.typehints import decorators
42+
from apache_beam.typehints import typecheck
4143
from apache_beam.typehints import with_input_types
4244
from apache_beam.typehints import with_output_types
4345

@@ -105,7 +107,7 @@ def test_wrapper_pass_through(self):
105107
# not the same one that actually runs in the pipeline (it is serialized
106108
# here and deserialized in the worker).
107109
with tempfile.TemporaryDirectory() as tmp_dirname:
108-
path = os.path.join(tmp_dirname + "tmp_filename")
110+
path = os.path.join(tmp_dirname, "tmp_filename")
109111
dofn = MyDoFn(path)
110112
result = self.p | beam.Create([1, 2, 3]) | beam.ParDo(dofn)
111113
assert_that(result, equal_to([1, 2, 3]))
@@ -300,5 +302,52 @@ def process(self, element, *args, **kwargs):
300302
self.p.run().wait_until_finish()
301303

302304

305+
@with_input_types(int)
306+
@with_output_types(int)
307+
class _AddOneDoFn(beam.DoFn):
308+
def process(self, element):
309+
yield element + 1
310+
311+
312+
class RuntimeTypeCheckWrapperDoFnTest(unittest.TestCase):
313+
"""Tests for the merged runtime type-checking wrapper (BEAM-9489)."""
314+
def test_visitor_applies_single_wrapper_layer(self):
315+
# A single RuntimeTypeCheckWrapperDoFn should wrap the user's DoFn
316+
# directly, with no intermediate wrapper layers.
317+
p = beam.Pipeline(options=PipelineOptions(runtime_type_check=True))
318+
_ = (
319+
p | beam.Create([1, 2]) | 'TypedStep' >> beam.ParDo(_AddOneDoFn()))
320+
p.visit(typecheck.TypeCheckVisitor())
321+
322+
wrapped_dofns = []
323+
324+
class _Collector(PipelineVisitor):
325+
def visit_transform(self, applied_transform):
326+
transform = applied_transform.transform
327+
if isinstance(transform, beam.ParDo) and isinstance(
328+
getattr(transform, 'fn', None), typecheck.AbstractDoFnWrapper):
329+
wrapped_dofns.append(transform.fn)
330+
331+
p.visit(_Collector())
332+
self.assertTrue(wrapped_dofns)
333+
for wrapper in wrapped_dofns:
334+
self.assertIsInstance(wrapper, typecheck.RuntimeTypeCheckWrapperDoFn)
335+
# The wrapped DoFn must be the user's DoFn, not another wrapper.
336+
self.assertNotIsInstance(wrapper.dofn, typecheck.AbstractDoFnWrapper)
337+
338+
def test_wrapper_labels_type_check_errors(self):
339+
dofn = typecheck.RuntimeTypeCheckWrapperDoFn(
340+
_AddOneDoFn(), _AddOneDoFn().get_type_hints(), 'MyStep')
341+
with self.assertRaisesRegex(
342+
typecheck.TypeCheckError,
343+
r'Runtime type violation detected within ParDo\(MyStep\)'):
344+
dofn.process('not-an-int')
345+
346+
def test_wrapper_preserves_results(self):
347+
dofn = typecheck.RuntimeTypeCheckWrapperDoFn(
348+
_AddOneDoFn(), _AddOneDoFn().get_type_hints(), 'MyStep')
349+
self.assertEqual(list(dofn.process(1)), [2])
350+
351+
303352
if __name__ == '__main__':
304-
unittest.main()
353+
unittest.main()

0 commit comments

Comments
 (0)