| # | |
| # 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. | |
| # | |
| """Merge phone, email, and mailing address information. | |
| A Dataflow pipeline that merges phone, email, and address information associated | |
| with the same names. Each input "database" is a tab-delimited text file pairing | |
| names with one phone number/email address/mailing address; multiple entries | |
| associated with the same name are allowed. Outputs are a tab-delimited text file | |
| with the merged information and another file containing some simple statistics. | |
| See mergecontacts_test.py for example inputs and outputs. | |
| A demonstration of: | |
| CoGroupByKey | |
| Non-linear pipelines (i.e., pipelines with branches) | |
| """ | |
| # pytype: skip-file | |
| import argparse | |
| import logging | |
| import re | |
| import apache_beam as beam | |
| from apache_beam.io import ReadFromText | |
| from apache_beam.io import WriteToText | |
| from apache_beam.options.pipeline_options import PipelineOptions | |
| from apache_beam.options.pipeline_options import SetupOptions | |
| from apache_beam.testing.util import assert_that | |
| from apache_beam.testing.util import equal_to | |
| def run(argv=None, assert_results=None, save_main_session=True): | |
| parser = argparse.ArgumentParser() | |
| parser.add_argument( | |
| '--input_email', | |
| required=True, | |
| help='Email database, with each line formatted as "name<TAB>email".') | |
| parser.add_argument( | |
| '--input_phone', | |
| required=True, | |
| help='Phonebook, with each line formatted as "name<TAB>phone number".') | |
| parser.add_argument( | |
| '--input_snailmail', | |
| required=True, | |
| help='Address database, with each line formatted as "name<TAB>address".') | |
| parser.add_argument( | |
| '--output_tsv', required=True, help='Tab-delimited output file.') | |
| parser.add_argument( | |
| '--output_stats', | |
| required=True, | |
| help='Output file for statistics about the input.') | |
| known_args, pipeline_args = parser.parse_known_args(argv) | |
| # We use the save_main_session option because one or more DoFn's in this | |
| # workflow rely on global context (e.g., a module imported at module level). | |
| pipeline_options = PipelineOptions(pipeline_args) | |
| pipeline_options.view_as(SetupOptions).save_main_session = save_main_session | |
| with beam.Pipeline(options=pipeline_options) as p: | |
| # Helper: read a tab-separated key-value mapping from a text file, | |
| # escape all quotes/backslashes, and convert it a PCollection of | |
| # (key, value) pairs. | |
| def read_kv_textfile(label, textfile): | |
| return ( | |
| p | |
| | 'Read: %s' % label >> ReadFromText(textfile) | |
| | 'Backslash: %s' % label >> | |
| beam.Map(lambda x: re.sub(r'\\', r'\\\\', x)) | |
| | 'EscapeQuotes: %s' % label >> | |
| beam.Map(lambda x: re.sub(r'"', r'\"', x)) | |
| | 'Split: %s' % label >> beam.Map(lambda x: re.split(r'\t+', x, 1))) | |
| # Read input databases. | |
| email = read_kv_textfile('email', known_args.input_email) | |
| phone = read_kv_textfile('phone', known_args.input_phone) | |
| snailmail = read_kv_textfile('snailmail', known_args.input_snailmail) | |
| # Group together all entries under the same name. | |
| grouped = (email, phone, snailmail) | 'group_by_name' >> beam.CoGroupByKey() | |
| # Prepare tab-delimited output; something like this: | |
| # "name"<TAB>"email_1,email_2"<TAB>"phone"<TAB>"first_snailmail_only" | |
| def format_as_tsv(name_email_phone_snailmail): | |
| (name, (email, phone, snailmail)) = name_email_phone_snailmail | |
| return '\t'.join([ | |
| '"%s"' % name, | |
| '"%s"' % ','.join(email), | |
| '"%s"' % ','.join(phone), | |
| '"%s"' % next(iter(snailmail), '') | |
| ]) | |
| tsv_lines = grouped | beam.Map(format_as_tsv) | |
| # Compute some stats about our database of people. | |
| def without_email(name_email_phone_snailmail): | |
| (_, (email, _, _)) = name_email_phone_snailmail | |
| return not next(iter(email), None) | |
| def without_phones(name_email_phone_snailmail): | |
| (_, (_, phone, _)) = name_email_phone_snailmail | |
| return not next(iter(phone), None) | |
| def without_address(name_email_phone_snailmail): | |
| (_, (_, _, snailmail)) = name_email_phone_snailmail | |
| return not next(iter(snailmail), None) | |
| luddites = grouped | beam.Filter(without_email) # People without email. | |
| writers = grouped | beam.Filter(without_phones) # People without phones. | |
| nomads = grouped | beam.Filter(without_address) # People without addresses. | |
| num_luddites = luddites | 'Luddites' >> beam.combiners.Count.Globally() | |
| num_writers = writers | 'Writers' >> beam.combiners.Count.Globally() | |
| num_nomads = nomads | 'Nomads' >> beam.combiners.Count.Globally() | |
| # Write tab-delimited output. | |
| # pylint: disable=expression-not-assigned | |
| tsv_lines | 'WriteTsv' >> WriteToText(known_args.output_tsv) | |
| # TODO(silviuc): Move the assert_results logic to the unit test. | |
| if assert_results is not None: | |
| expected_luddites, expected_writers, expected_nomads = assert_results | |
| assert_that( | |
| num_luddites, equal_to([expected_luddites]), label='assert:luddites') | |
| assert_that( | |
| num_writers, equal_to([expected_writers]), label='assert:writers') | |
| assert_that( | |
| num_nomads, equal_to([expected_nomads]), label='assert:nomads') | |
| if __name__ == '__main__': | |
| logging.getLogger().setLevel(logging.INFO) | |
| run() |