| # | |
| # 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 argparse | |
| import json | |
| import os | |
| import sys | |
| import unittest | |
| import yaml | |
| import apache_beam as beam | |
| from apache_beam.io.filesystems import FileSystems | |
| # The following imports force the registration of JDBC logical types. | |
| # When running a Beam YAML pipeline, the expansion service handles JDBCIO using | |
| # Java transforms, bypassing the Python module (`apache_beam.io.jdbc`) that | |
| # registers these types. These imports load the module, preventing a | |
| # "logical type not found" error. | |
| from apache_beam.io.jdbc import JdbcDateType # pylint: disable=unused-import | |
| from apache_beam.io.jdbc import JdbcTimeType # pylint: disable=unused-import | |
| from apache_beam.transforms import resources | |
| from apache_beam.yaml import yaml_testing | |
| from apache_beam.yaml import yaml_transform | |
| from apache_beam.yaml import yaml_utils | |
| def _preparse_jinja_flags(argv): | |
| """Promotes any flags to --jinja_variables based on --jinja_variable_flags. | |
| This is to facilitate tools (such as dataflow templates) that must pass | |
| options as un-nested flags. | |
| """ | |
| parser = argparse.ArgumentParser(allow_abbrev=False) | |
| parser.add_argument( | |
| '--jinja_variable_flags', | |
| default=[], | |
| type=lambda s: s.split(','), | |
| help='A list of flag names that should be used as jinja variables.') | |
| parser.add_argument( | |
| '--jinja_variables', | |
| default={}, | |
| type=json.loads, | |
| help='A json dict of variables used when invoking the jinja preprocessor ' | |
| 'on the provided yaml pipeline.') | |
| jinja_args, other_args = parser.parse_known_args(argv) | |
| if not jinja_args.jinja_variable_flags: | |
| return argv | |
| jinja_variable_parser = argparse.ArgumentParser(allow_abbrev=False) | |
| # Guard against jinja_variable_flags colliding with pipeline options. | |
| # If a flag collides with a known pipeline option, skip it and require | |
| # the variable to be provided via --jinja_variables JSON instead. | |
| try: | |
| from apache_beam.options.pipeline_options import PipelineOptions | |
| _pipeline_option_names = set(PipelineOptions([]).get_all_options().keys()) | |
| except Exception: | |
| _pipeline_option_names = set() | |
| for flag_name in jinja_args.jinja_variable_flags: | |
| if flag_name.replace('-', '_') in _pipeline_option_names: | |
| continue | |
| jinja_variable_parser.add_argument('--' + flag_name) | |
| jinja_flag_variables, pipeline_args = jinja_variable_parser.parse_known_args( | |
| other_args) | |
| jinja_args.jinja_variables.update( | |
| ** | |
| {k: v | |
| for (k, v) in vars(jinja_flag_variables).items() if v is not None}) | |
| if jinja_args.jinja_variables: | |
| pipeline_args = pipeline_args + [ | |
| '--jinja_variables=' + json.dumps(jinja_args.jinja_variables) | |
| ] | |
| return pipeline_args | |
| def _parse_arguments(argv): | |
| parser = argparse.ArgumentParser(allow_abbrev=False) | |
| parser.add_argument( | |
| '--yaml_pipeline', | |
| '--pipeline_spec', | |
| help='A yaml description of the pipeline to run.') | |
| parser.add_argument( | |
| '--yaml_pipeline_file', | |
| '--pipeline_spec_file', | |
| help='A file containing a yaml description of the pipeline to run.') | |
| parser.add_argument( | |
| '--json_schema_validation', | |
| default='generic', | |
| help='none: do no pipeline validation against the schema; ' | |
| 'generic: validate the pipeline shape, but not individual transforms; ' | |
| 'per_transform: also validate the config of known transforms') | |
| parser.add_argument( | |
| '--jinja_variables', | |
| default=None, | |
| type=json.loads, | |
| help='A json dict of variables used when invoking the jinja preprocessor ' | |
| 'on the provided yaml pipeline.') | |
| parser.add_argument( | |
| '--tests', | |
| '--test', | |
| action=argparse.BooleanOptionalAction, | |
| help='Run the tests associated with the given pipeline, rather than the ' | |
| 'pipeline itself.') | |
| parser.add_argument( | |
| '--fix_tests', | |
| action=argparse.BooleanOptionalAction, | |
| help='Update failing test expectations to match the actual ouput. ' | |
| 'Requires --test_suite if the pipeline uses jinja formatting.') | |
| parser.add_argument( | |
| '--create_test', | |
| action=argparse.BooleanOptionalAction, | |
| help='Automatically creates a regression test for the given pipeline, ' | |
| 'adding it to the pipeline spec or test suite dependon on whether ' | |
| '--test_suite is given. ' | |
| 'Requires --test_suite if the pipeline uses jinja formatting.') | |
| parser.add_argument( | |
| '--test_suite', | |
| help='Run the given tests against the given pipeline, rather than the ' | |
| 'pipeline itself. ' | |
| 'Should be a file containing a list of yaml test specifications.') | |
| return parser.parse_known_args(argv) | |
| def _pipeline_spec_from_args(known_args): | |
| if known_args.yaml_pipeline_file and known_args.yaml_pipeline: | |
| raise ValueError( | |
| "Exactly one of yaml_pipeline or yaml_pipeline_file must be set.") | |
| elif known_args.yaml_pipeline_file: | |
| with FileSystems.open(known_args.yaml_pipeline_file) as fin: | |
| pipeline_yaml = fin.read().decode() | |
| elif known_args.yaml_pipeline: | |
| pipeline_yaml = known_args.yaml_pipeline | |
| else: | |
| raise ValueError( | |
| "Exactly one of yaml_pipeline or yaml_pipeline_file must be set.") | |
| return pipeline_yaml | |
| def run(argv=None): | |
| options, constructor, display_data = build_pipeline_components_from_argv(argv) | |
| with beam.Pipeline(options=options, display_data=display_data) as p: | |
| print('Building pipeline...') | |
| constructor(p) | |
| print('Running pipeline...') | |
| def run_tests(argv=None, exit=True): | |
| known_args, pipeline_args, _, pipeline_yaml = _build_pipeline_yaml_from_argv( | |
| argv) | |
| pipeline_spec = yaml.load(pipeline_yaml, Loader=yaml_transform.SafeLineLoader) | |
| options = _build_pipeline_options(pipeline_spec, pipeline_args) | |
| if known_args.create_test and known_args.fix_tests: | |
| raise ValueError( | |
| 'At most one of --create_test and --fix_tests may be specified.') | |
| elif known_args.create_test: | |
| result = unittest.TestResult() | |
| tests = [] | |
| else: | |
| if known_args.test_suite: | |
| with open(known_args.test_suite) as fin: | |
| test_suite_holder = yaml.load( | |
| fin, Loader=yaml_transform.SafeLineLoader) or {} | |
| else: | |
| test_suite_holder = pipeline_spec | |
| test_specs = test_suite_holder.get('tests', []) | |
| if not isinstance(test_specs, list): | |
| raise TypeError('tests attribute must be a list of test specifications.') | |
| elif not test_specs: | |
| raise RuntimeError( | |
| 'No tests found. ' | |
| "If you haven't added a set of tests yet, you can get started by " | |
| 'running your pipeline with the --create_test flag enabled.') | |
| tests = [ | |
| yaml_testing.YamlTestCase( | |
| pipeline_spec, test_spec, options, known_args.fix_tests) | |
| for test_spec in test_specs | |
| ] | |
| suite = unittest.TestSuite(tests) | |
| result = unittest.TextTestRunner().run(suite) | |
| if known_args.fix_tests or known_args.create_test: | |
| update_tests(known_args, pipeline_yaml, pipeline_spec, options, tests) | |
| if exit: | |
| # emulates unittest.main() | |
| sys.exit(0 if result.wasSuccessful() else 1) | |
| else: | |
| if not result.wasSuccessful(): | |
| raise RuntimeError(result) | |
| def update_tests(known_args, pipeline_yaml, pipeline_spec, options, tests): | |
| if known_args.test_suite: | |
| path = known_args.test_suite | |
| if not os.path.exists(path) and known_args.create_test: | |
| with open(path, 'w') as fout: | |
| fout.write('tests: []') | |
| elif known_args.yaml_pipeline_file: | |
| path = known_args.yaml_pipeline_file | |
| else: | |
| raise RuntimeError( | |
| 'Test fixing only supported for file-backed tests. ' | |
| 'Please use the --test_suite flag.') | |
| with open(path) as fin: | |
| original_yaml = fin.read() | |
| if path == known_args.yaml_pipeline_file and pipeline_yaml.strip( | |
| ) != original_yaml.strip(): | |
| raise RuntimeError( | |
| 'In-file test fixing not yet supported for templated pipelines. ' | |
| 'Please use the --test_suite flag.') | |
| updated_spec = yaml.load(original_yaml, Loader=yaml.SafeLoader) or {} | |
| if known_args.fix_tests: | |
| updated_spec['tests'] = [test.fixed_test() for test in tests] | |
| if known_args.create_test: | |
| if 'tests' not in updated_spec: | |
| updated_spec['tests'] = [] | |
| updated_spec['tests'].append( | |
| yaml_testing.create_test(pipeline_spec, options)) | |
| updated_yaml = yaml_utils.patch_yaml(original_yaml, updated_spec) | |
| with open(path, 'w') as fout: | |
| fout.write(updated_yaml) | |
| def _build_pipeline_yaml_from_argv(argv): | |
| argv = _preparse_jinja_flags(argv) | |
| known_args, pipeline_args = _parse_arguments(argv) | |
| pipeline_template = _pipeline_spec_from_args(known_args) | |
| search_paths = [] | |
| if known_args.yaml_pipeline_file: | |
| search_paths.append(FileSystems.split(known_args.yaml_pipeline_file)[0]) | |
| pipeline_yaml = yaml_transform.expand_jinja( | |
| pipeline_template, known_args.jinja_variables or {}, search_paths) | |
| return known_args, pipeline_args, pipeline_template, pipeline_yaml | |
| def _build_pipeline_options(pipeline_spec, pipeline_args): | |
| return beam.options.pipeline_options.PipelineOptions( | |
| pipeline_args, | |
| pickle_library='cloudpickle', | |
| **yaml_transform.SafeLineLoader.strip_metadata( | |
| pipeline_spec.get('options', {}))) | |
| def build_pipeline_components_from_argv(argv): | |
| (known_args, pipeline_args, pipeline_template, | |
| pipeline_yaml) = _build_pipeline_yaml_from_argv(argv) | |
| display_data = { | |
| 'yaml': pipeline_yaml, | |
| 'yaml_jinja_template': pipeline_template, | |
| 'yaml_jinja_variables': json.dumps(known_args.jinja_variables), | |
| } | |
| options, constructor = build_pipeline_components_from_yaml( | |
| pipeline_yaml, | |
| pipeline_args, | |
| known_args.json_schema_validation, | |
| known_args.yaml_pipeline_file, | |
| ) | |
| return options, constructor, display_data | |
| def build_pipeline_components_from_yaml( | |
| pipeline_yaml, pipeline_args, validate_schema='generic', pipeline_path=''): | |
| pipeline_spec = yaml.load(pipeline_yaml, Loader=yaml_transform.SafeLineLoader) | |
| options = _build_pipeline_options(pipeline_spec, pipeline_args) | |
| def constructor(root): | |
| if 'resource_hints' in pipeline_spec.get('pipeline', {}): | |
| # Add the declared resource hints to the "root" spec. | |
| root._current_transform().resource_hints.update( | |
| resources.parse_resource_hints( | |
| yaml_transform.SafeLineLoader.strip_metadata( | |
| pipeline_spec['pipeline']['resource_hints']))) | |
| yaml_transform.expand_pipeline( | |
| root, | |
| pipeline_spec, | |
| validate_schema=validate_schema, | |
| pipeline_path=pipeline_path, | |
| ) | |
| return options, constructor | |
| if __name__ == '__main__': | |
| import logging | |
| logging.getLogger().setLevel(logging.INFO) | |
| if '--tests' in sys.argv or '--test' in sys.argv: | |
| run_tests() | |
| else: | |
| run() |