blob: 15c6488d439c78ce8b5f5807ac191bd731487685 [file]
#
# Licensed to the Apache Software Foundation (ASF) under one or more
# contributor license agreements. See the NOTICE file distributed with
# this work for additional information regarding copyright ownership.
# The ASF licenses this file to You under the Apache License, Version 2.0
# (the "License"); you may not use this file except in compliance with
# the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
import contextlib
import glob
import io
import json
import os
import shutil
import tempfile
import unittest
from unittest import mock
import apache_beam as beam
from apache_beam.cli import main as cli
from apache_beam.options.pipeline_options import PipelineOptions
from apache_beam.utils import subprocess_server
try:
import jsonschema
from apache_beam.yaml import main as yaml_main
except ImportError:
jsonschema = None
yaml_main = None
# Paths are formatted with `json.dumps` so Windows backslashes (such as `\U`
# and `\t` in `C:\Users\...\temp`) are escaped as valid YAML strings.
PIPELINE = '''
pipeline:
type: chain
transforms:
- type: ReadFromCsv
config: {path: %(input)s}
- type: Filter
config: {language: python, keep: "age > 20"}
- type: WriteToJson
config: {path: %(output)s}
'''
FAILING_PIPELINE = '''
pipeline:
type: chain
transforms:
- type: ReadFromCsv
config: {path: %(input)s}
- type: MapToFields
config: {language: python, fields: {x: "age // 0"}}
- type: WriteToJson
config: {path: %(output)s}
'''
INVALID_PIPELINE = '''
pipeline:
type: chain
transforms:
- type: NoSuchTransform
'''
def _run_cli(argv):
"""Runs `beam` with argv; returns (exit code, stdout, stderr)."""
out, err = io.StringIO(), io.StringIO()
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
code = cli.main(argv)
return code, out.getvalue(), err.getvalue()
def _read_lines(prefix):
lines = []
for path in glob.glob(prefix + '*'):
with open(path) as f:
lines.extend(f.read().splitlines())
return sorted(lines)
class ParserTest(unittest.TestCase):
def test_usage_errors_exit_with_2(self):
for argv in ([], ['run'], ['nosuchcommand', 'x']):
with self.subTest(argv=argv):
with contextlib.redirect_stderr(io.StringIO()):
with self.assertRaises(SystemExit) as e:
cli.main(argv)
self.assertEqual(e.exception.code, cli.EXIT_USAGE)
def test_missing_file(self):
code, _, err = _run_cli(['run', 'no/such/pipeline.yaml'])
self.assertEqual(code, cli.EXIT_USAGE)
self.assertIn("no such file: 'no/such/pipeline.yaml'", err)
self.assertNotIn('pipeline options go after FILE', err)
def test_missing_file_hints_at_option_order(self):
# argparse takes "dataflow" for FILE when an option uses a space.
code, _, err = _run_cli(['run', '--runner', 'dataflow', 'p.yaml'])
self.assertEqual(code, cli.EXIT_USAGE)
self.assertIn("no such file: 'dataflow'", err)
self.assertIn('pipeline options go after FILE', err)
@unittest.skipIf(
yaml_main is None or jsonschema is None, 'requires the [yaml] extra')
class RunTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.tmp, ignore_errors=True)
self.input = os.path.join(self.tmp, 'users.csv')
with open(self.input, 'w') as f:
f.write('name,age\nAda,36\nLinus,17\nGrace,45\n')
def _pipeline_file(self, template, output='users.json', input_path=None):
path = os.path.join(self.tmp, 'pipeline.yaml')
with open(path, 'w') as f:
f.write(
template % {
'input': json.dumps(input_path or self.input),
'output': json.dumps(os.path.join(self.tmp, output)),
})
return path
def test_runs_like_yaml_main(self):
# The CLI and `python -m apache_beam.yaml.main` write the same output for
# the same pipeline file and options.
path = self._pipeline_file(PIPELINE, 'cli_users.json')
code, out, _ = _run_cli(['run', path, '--runner=DirectRunner'])
self.assertEqual(code, cli.EXIT_OK)
self.assertRegex(out, r'Done\. Ran locally on (Prism|the Python direct)')
reference = self._pipeline_file(PIPELINE, 'reference_users.json')
yaml_main.run(
['--yaml_pipeline_file=' + reference, '--runner=DirectRunner'])
cli_rows = _read_lines(os.path.join(self.tmp, 'cli_users.json'))
self.assertEqual(len(cli_rows), 2)
self.assertEqual(
cli_rows, _read_lines(os.path.join(self.tmp, 'reference_users.json')))
def test_backslash_sequences_in_paths(self):
# Regression test for Windows temp paths (`C:\Users\...\tmp...`), where
# unescaped `\U` and `\t` inside double-quoted YAML strings fail parsing.
tricky_input = os.path.join(self.tmp, r'C_\Users\tmp_users.csv')
os.makedirs(os.path.dirname(tricky_input), exist_ok=True)
shutil.copyfile(self.input, tricky_input)
path = self._pipeline_file(
PIPELINE, 'backslash_users.json', input_path=tricky_input)
code, _, _ = _run_cli(['run', path, '--runner=DirectRunner'])
self.assertEqual(code, cli.EXIT_OK)
self.assertEqual(
len(_read_lines(os.path.join(self.tmp, 'backslash_users.json'))), 2)
def test_pipeline_failure_exits_with_1(self):
path = self._pipeline_file(FAILING_PIPELINE)
code, _, err = _run_cli(['run', path, '--runner=DirectRunner'])
self.assertEqual(code, cli.EXIT_PIPELINE_FAILED)
# Only the command's own message is checked: with an in-process worker,
# Beam's logging also prints the worker's traceback, as it does for
# `python -m apache_beam.yaml.main`.
start = err.index('error: pipeline failed:')
end = err.index('rerun with --verbose', start)
message = err[start:end]
self.assertIn('ZeroDivisionError', message)
self.assertNotIn('Traceback', message)
def test_invalid_pipeline_exits_with_2(self):
path = self._pipeline_file(INVALID_PIPELINE)
code, _, err = _run_cli(['run', path])
self.assertEqual(code, cli.EXIT_USAGE)
self.assertIn('could not build the pipeline:', err)
self.assertIn('NoSuchTransform', err)
self.assertNotIn('Traceback', err)
def test_verbose_shows_traceback(self):
path = self._pipeline_file(INVALID_PIPELINE)
code, _, err = _run_cli(['run', path, '--verbose'])
self.assertEqual(code, cli.EXIT_USAGE)
self.assertIn('Traceback', err)
def test_other_flags_are_passed_through_in_order(self):
path = self._pipeline_file(PIPELINE)
with mock.patch.object(yaml_main,
'build_pipeline_components_from_argv',
side_effect=ValueError('stop')) as build:
_run_cli([
'run',
path,
'--runner=DirectRunner',
'--jinja_variables={"a": 1}',
'--verbose',
'--project',
'p'
])
self.assertEqual(
build.call_args.args[0],
[
'--yaml_pipeline_file=' + path,
'--runner=DirectRunner',
'--jinja_variables={"a": 1}',
'--project',
'p'
])
def test_missing_package_exits_with_4(self):
path = self._pipeline_file(PIPELINE)
with mock.patch.object(yaml_main,
'build_pipeline_components_from_argv',
side_effect=ImportError('No module named x')):
code, _, err = _run_cli(['run', path])
self.assertEqual(code, cli.EXIT_MISSING_PACKAGE)
self.assertIn('No module named x', err)
self.assertIn('pip install', err)
def test_pipeline_context_active_across_build_and_run(self):
# Java expansion services (Sql, Kafka, JDBC, Iceberg, ...) are started
# during `constructor(pipeline)` and queried again for artifacts during
# `pipeline.run()`, so `Pipeline.__enter__`'s subprocess cache and
# `--beam_services` overrides must stay active across both steps and be
# cleaned up afterwards.
path = self._pipeline_file(PIPELINE)
real_build = yaml_main.build_pipeline_components_from_argv
snapshots = {}
def capture(stage):
snapshots[stage] = (
dict(subprocess_server.JavaJarServer._BEAM_SERVICES.replacements),
set(subprocess_server.SubprocessServer._cache._live_owners),
)
def wrapped_build(argv):
options, constructor, display_data = real_build(argv)
def wrapped_constructor(pipeline):
capture('build')
constructor(pipeline)
return options, wrapped_constructor, display_data
result = mock.Mock()
result.wait_until_finish.return_value = cli.PipelineState.DONE
def fake_run(_self):
capture('run')
return result
owners_before = set(subprocess_server.SubprocessServer._cache._live_owners)
services_before = dict(
subprocess_server.JavaJarServer._BEAM_SERVICES.replacements)
with (
mock.patch.object(yaml_main,
'build_pipeline_components_from_argv',
side_effect=wrapped_build),
mock.patch.object(beam.Pipeline, 'run', fake_run),
):
code, _, _ = _run_cli(
['run', path, '--beam_services={"target": "override"}'])
self.assertEqual(code, cli.EXIT_OK)
build_services, build_owners = snapshots['build']
run_services, run_owners = snapshots['run']
self.assertTrue(build_owners - owners_before)
self.assertEqual(build_owners, run_owners)
self.assertEqual(build_services.get('target'), 'override')
self.assertEqual(run_services.get('target'), 'override')
self.assertEqual(
set(subprocess_server.SubprocessServer._cache._live_owners),
owners_before)
self.assertEqual(
dict(subprocess_server.JavaJarServer._BEAM_SERVICES.replacements),
services_before)
# Also verify cleanup when `constructor(pipeline)` raises during build.
def failing_build(argv):
options, _, display_data = real_build(argv)
def failing_constructor(_pipeline):
self.assertGreater(
len(subprocess_server.SubprocessServer._cache._live_owners),
len(owners_before))
raise RuntimeError('expansion failed')
return options, failing_constructor, display_data
with mock.patch.object(yaml_main,
'build_pipeline_components_from_argv',
side_effect=failing_build):
code, _, _ = _run_cli(
['run', path, '--beam_services={"target": "override"}'])
self.assertEqual(code, cli.EXIT_USAGE)
self.assertEqual(
set(subprocess_server.SubprocessServer._cache._live_owners),
owners_before)
self.assertEqual(
dict(subprocess_server.JavaJarServer._BEAM_SERVICES.replacements),
services_before)
def test_no_wait_until_finish(self):
path = self._pipeline_file(PIPELINE)
result = mock.Mock()
result.state = cli.PipelineState.RUNNING
with mock.patch.object(beam.Pipeline, 'run',
return_value=result) as run_mock:
code, out, _ = _run_cli(['run', path, '--no_wait_until_finish'])
self.assertEqual(code, cli.EXIT_OK)
run_mock.assert_called_once()
result.wait_until_finish.assert_not_called()
self.assertIn('not waiting for completion', out)
def test_ctrl_c_cancels_and_exits_with_130(self):
path = self._pipeline_file(PIPELINE)
result = mock.Mock()
result.wait_until_finish.side_effect = KeyboardInterrupt
with mock.patch.object(beam.Pipeline, 'run', return_value=result):
code, _, err = _run_cli(['run', path])
self.assertEqual(code, cli.EXIT_INTERRUPTED)
result.cancel.assert_called_once()
self.assertIn('Cancelled the pipeline.', err)
class HelpersTest(unittest.TestCase):
def test_cancel_failure_is_reported(self):
result = mock.Mock()
result.cancel.side_effect = RuntimeError('job not found')
err = io.StringIO()
with contextlib.redirect_stderr(err):
cli._cancel(result)
self.assertIn(
'Could not cancel the pipeline (job not found)', err.getvalue())
def test_describe_runner(self):
prism_result = type('PipelineResult', (), {})()
type(prism_result).__module__ = (
'apache_beam.runners.portability.portable_runner')
direct_result = object()
cases = [
([], prism_result, 'locally on Prism'),
([], direct_result, 'locally on the Python direct runner'),
(['--runner=DirectRunner'], prism_result, 'locally on Prism'),
(['--runner=dataflow'], direct_result, 'on dataflow'),
]
for argv, result, expected in cases:
with self.subTest(argv=argv, expected=expected):
self.assertEqual(
cli._describe_runner(PipelineOptions(argv), result), expected)
def test_summarize_keeps_the_first_lines(self):
error = RuntimeError(
'Job failed.\n\n' + '\n'.join(f'line {i}' for i in range(20)))
summary = cli._summarize(error)
self.assertTrue(summary.startswith('Job failed.\n line 0'))
self.assertIn('line 8', summary)
self.assertNotIn('line 9', summary)
self.assertTrue(summary.endswith('...'))
def test_summarize_embedded_python_traceback(self):
# The shape of a Prism failure: context, then the worker's traceback.
error = RuntimeError(
'Pipeline job-001 failed in state FAILED: bundle b failed:'
'Traceback (most recent call last):\n'
' File "common.py", line 1, in process\n'
' return fn(x)\n'
'ZeroDivisionError: division by zero\n\n'
'During handling of the above exception, another exception '
'occurred:\n\n'
'Traceback (most recent call last):\n'
' File "common.py", line 2, in process\n'
"ZeroDivisionError: division by zero [while running 'Map']\n")
self.assertEqual(
cli._summarize(error),
'Pipeline job-001 failed in state FAILED: bundle b failed:\n'
" ZeroDivisionError: division by zero [while running 'Map']")
def test_summarize_names_the_type_of_direct_errors(self):
self.assertEqual(
cli._summarize(ZeroDivisionError('division by zero')),
'ZeroDivisionError: division by zero')
self.assertEqual(cli._summarize(RuntimeError('boom')), 'boom')
if __name__ == '__main__':
unittest.main()