Skip to content

Commit 8c51e8d

Browse files
committed
change design to handle instances inside dofn
1 parent 0b0f215 commit 8c51e8d

1 file changed

Lines changed: 106 additions & 60 deletions

File tree

sdks/python/apache_beam/yaml/yaml_mapping.py

Lines changed: 106 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -62,25 +62,6 @@
6262
except ImportError:
6363
MiniRacer = None
6464

65-
66-
class _JsThreadContext:
67-
def __init__(self):
68-
self._local = threading.local()
69-
70-
def get_funcs(self):
71-
if not hasattr(self._local, 'funcs'):
72-
self._local.funcs = {}
73-
return self._local.funcs
74-
75-
def __getstate__(self):
76-
return {}
77-
78-
def __setstate__(self, state):
79-
self._local = threading.local()
80-
81-
82-
_js_contexts = _JsThreadContext()
83-
8465
_JS_DATE_ISO_REGEX = re.compile(
8566
r'^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$')
8667

@@ -240,10 +221,90 @@ def js_to_py(obj):
240221
return obj
241222

242223

224+
class JsFilterDoFn(beam.DoFn):
225+
def __init__(self, udf_code, function_name):
226+
self.udf_code = udf_code
227+
self.function_name = function_name
228+
self.ctx = None
229+
230+
def setup(self):
231+
self.ctx = MiniRacer()
232+
self.ctx.eval(self.udf_code)
233+
234+
def process(self, element):
235+
row_as_dict = py_value_to_js_dict(element)
236+
result = self.ctx.call(self.function_name, row_as_dict)
237+
result = js_to_py(result)
238+
if result:
239+
yield element
240+
241+
242+
class JsMapToFieldsDoFn(beam.DoFn):
243+
def __init__(self, fields, original_fields, input_schema):
244+
self.fields = fields
245+
self.original_fields = original_fields
246+
self.input_schema = input_schema
247+
self.ctx = None
248+
self.field_funcs = {}
249+
self.passthrough_fields = []
250+
251+
def setup(self):
252+
self.ctx = MiniRacer()
253+
script = []
254+
for name, expr in self.fields.items():
255+
if isinstance(expr, str) and expr in self.input_schema:
256+
self.passthrough_fields.append((name, expr))
257+
continue
258+
259+
if isinstance(expr, str):
260+
expr = {'expression': expr}
261+
262+
if 'expression' in expr:
263+
e = expr['expression']
264+
code = f"var func_{name} = (__row__) => {{ " + " ".join([
265+
f"const {n} = __row__.{n};" for n in self.original_fields if n in e
266+
]) + f" return ({e}); }}"
267+
script.append(code)
268+
self.field_funcs[name] = f"func_{name}"
269+
elif 'callable' in expr:
270+
code = f"var func_{name} = {expr['callable']}"
271+
script.append(code)
272+
self.field_funcs[name] = f"func_{name}"
273+
elif 'path' in expr and 'name' in expr:
274+
path = expr['path']
275+
func_name = expr['name']
276+
udf_code = FileSystems.open(path).read().decode()
277+
script.append(udf_code)
278+
self.field_funcs[name] = func_name
279+
280+
if script:
281+
self.ctx.eval("\n".join(script))
282+
283+
def process(self, element):
284+
row_as_dict = py_value_to_js_dict(element)
285+
result_dict = {}
286+
287+
# Handle passthrough fields
288+
for name, src in self.passthrough_fields:
289+
result_dict[name] = row_as_dict.get(src)
290+
291+
# Handle JS fields
292+
for name, func_name in self.field_funcs.items():
293+
res = self.ctx.call(func_name, row_as_dict)
294+
result_dict[name] = js_to_py(res)
295+
296+
yield dicts_to_rows(result_dict)
297+
298+
243299
# TODO(yaml) Consider adding optional language version parameter to support
244300
# ECMAScript 5 and 6
245-
def _expand_javascript_mapping_func(
246-
original_fields, expression=None, callable=None, path=None, name=None):
301+
def _get_javascript_udf_code(
302+
original_fields,
303+
function_name="func",
304+
expression=None,
305+
callable=None,
306+
path=None,
307+
name=None):
247308

248309
if MiniRacer is None:
249310
raise ValueError(
@@ -255,39 +316,17 @@ def _expand_javascript_mapping_func(
255316
if not path.endswith('.js'):
256317
raise ValueError(f'File "{path}" is not a valid .js file.')
257318
udf_code = FileSystems.open(path).read().decode()
319+
return udf_code, name
258320
elif expression:
259-
udf_code = f"var func = (__row__) => {{ " + " ".join([
321+
udf_code = f"var {function_name} = (__row__) => {{ " + " ".join([
260322
f"const {n} = __row__.{n};" for n in original_fields if n in expression
261323
]) + f" return ({expression}); }}"
324+
return udf_code, function_name
262325
elif callable:
263-
udf_code = f"var func = {callable}"
264-
265-
udf_key = str(uuid.uuid4())
266-
267-
def js_wrapper(row):
268-
funcs = _js_contexts.get_funcs()
269-
270-
if udf_key not in funcs:
271-
ctx = MiniRacer()
272-
ctx.eval(udf_code)
273-
# We use ctx.call for efficiency.
274-
# Note: This might return strings for Date objects instead of datetime.
275-
if expression or callable:
276-
funcs[udf_key] = lambda x: ctx.call("func", x)
277-
else:
278-
funcs[udf_key] = lambda x: ctx.call(name, x)
279-
280-
func = funcs[udf_key]
281-
row_as_dict = py_value_to_js_dict(row)
282-
try:
283-
result = func(row_as_dict)
284-
except Exception as exn:
285-
raise RuntimeError(
286-
f"Error evaluating JavaScript expression: {exn}") from exn
287-
result = js_to_py(result)
288-
return dicts_to_rows(result)
289-
290-
return js_wrapper
326+
udf_code = f"var {function_name} = {callable}"
327+
return udf_code, function_name
328+
else:
329+
raise ValueError("Must specify expression, callable, or path.")
291330

292331

293332
def _expand_python_mapping_func(
@@ -394,14 +433,10 @@ def _as_callable(original_fields, expr, transform_name, language, input_schema):
394433
explicit_type = expr.pop('output_type', None)
395434
_check_mapping_arguments(transform_name, **expr)
396435

397-
if language == "javascript":
398-
func = _expand_javascript_mapping_func(original_fields, **expr)
399-
elif language in ("python", "generic", None):
436+
if language in ("python", "generic", None):
400437
func = _expand_python_mapping_func(original_fields, **expr)
401438
else:
402-
raise ValueError(
403-
f'Unknown language for mapping transform: {language}. '
404-
'Supported languages are "javascript" and "python."')
439+
raise ValueError(f'Language {language} not supported in this context.')
405440

406441
if explicit_type:
407442
if isinstance(explicit_type, str):
@@ -640,8 +675,17 @@ def _PyJsFilter(
640675
error_handling: Whether and where to output records that throw errors when
641676
the above expressions are evaluated.
642677
""" # pylint: disable=line-too-long
643-
keep_fn = _as_callable_for_pcoll(pcoll, keep, "keep", language or 'generic')
644-
return pcoll | beam.Filter(keep_fn)
678+
if language == 'javascript':
679+
if isinstance(keep, str):
680+
keep = {'expression': keep}
681+
udf_code, function_name = _get_javascript_udf_code(
682+
[f.name for f in schema_from_element_type(pcoll.element_type).fields],
683+
**keep
684+
)
685+
return pcoll | beam.ParDo(JsFilterDoFn(udf_code, function_name))
686+
else:
687+
keep_fn = _as_callable_for_pcoll(pcoll, keep, "keep", language or 'generic')
688+
return pcoll | beam.Filter(keep_fn)
645689

646690

647691
def is_expr(v):
@@ -713,10 +757,12 @@ def _PyJsMapToFields(
713757
""" # pylint: disable=line-too-long
714758
input_schema, fields = normalize_fields(
715759
pcoll, fields, drop or (), append, language=language or 'generic')
760+
original_fields = list(input_schema.keys())
761+
716762
if language == 'javascript':
717763
options.YamlOptions.check_enabled(pcoll.pipeline, 'javascript')
718-
719-
original_fields = list(input_schema.keys())
764+
return pcoll | beam.ParDo(
765+
JsMapToFieldsDoFn(fields, original_fields, input_schema))
720766

721767
return pcoll | beam.Select(
722768
**{

0 commit comments

Comments
 (0)