"""Apple Financial tasks.""" import csv from datetime import datetime from io import StringIO import re from boto3.exceptions import S3UploadFailedError from garcon import task from garcon_contrib.dynamo_feed_status import garcon_feed_status import pandas as pd from snowflake_connector.etl_connector import SnowflakeSQLExecutor, SQLLoader from feed_ingestion.conf.config import merge_configs from feed_ingestion.flows.apple_financial import config from feed_ingestion.flows.apple_financial.snowflake_executor \ import AppleFinancialSF from feed_ingestion.flows.helpers import get_sf_config from feed_ingestion.tasks import bootstrap as reload from feed_ingestion.util.aws.s3 \ import get_source_files_content, upload_processed_to_s3 STOP_RESPONSE = {'stop': True} STAGING_RAW_TASK_ID = 'staging_raw_table_tasks' sql_loader = SQLLoader(__file__) @task.decorate(timeout=1000) @reload.reset_dynamodb_status_on_reload(config.feed_name) def bootstrap(activity, date, reload): """Bootstrap workflow by injecting initial context from config. Args: activity (ActivityWorker): The Garcon activity worker. date (str): Reporting date (YYYY-MM-DD). reload (str or None): If 'True' delete feed status in DynamoDB. Returns: dict: Initial context for the workflow. """ date_obj = (datetime.strptime( date, '%Y-%m-%d') if date else datetime.today()).replace(day=1) date = date_obj.strftime('%Y-%m-%d') s3_archive_path = config.s3['archive'].format(date=date_obj) return { 'feed_name': config.feed_name, 'secrets_path': config.secrets_path, 'date': date, 's3_archive_path': s3_archive_path, 'file_pattern': config.file_pattern, 'apple_reports': config.apple_reports, 'snowflake_error_limit': config.snowflake_error_limit, } @task.decorate(timeout=1000) def process_drop_files(activity, feed_name, date, s3_archive_path, files): """Process and archive drop files on s3. Args: activity (ActivityWorker): The Garcon activity worker. feed_name (str): Name of feed being ingested. date (str): YYYY-MM-DD date of data to process. s3_archive_path (str): S3 path to the archive location. files (dict): Dictionary of Financial files to process. Returns: source_files_dict (dict): Dict containing metadata of processed file. """ activity.logger.info('files: {}'.format(files)) reports_to_ingest = [] for filename, content in get_source_files_content(s3_archive_path, files): activity.logger.info('filename: {}'.format(filename)) reports = process_reports(content) for report in reports: index = filename.find('.') new_filename = filename[:index] + '_' + report + filename[index:] reports_to_ingest.append({ 'report': report, 'filename': new_filename}) try: upload_processed_to_s3( reports[report], '{}{}'.format(s3_archive_path, new_filename), expected_bucket_owner=config.expected_bucket_owner ) except S3UploadFailedError as e: activity.logger.error( 'Cannot upload files to {path}. {exception_body}'.format( path=s3_archive_path, exception_body=e)) garcon_feed_status.set_overall_status( feed_name, date, garcon_feed_status.STATUS_NOT_AVAILABLE) raise e activity.logger.info( 'Successfully processed drop files for {}'.format(new_filename) ) return {'reports_to_ingest': reports_to_ingest} @task.decorate(timeout=1000) def load_staging_raw_table( activity, date, processed_datetime, vendor_id, filename, sfdb_params, staging_raw_table, report_name, licensor, secrets_path=None): """Load data to staging_raw_apple_music_streams. Runs for each vendor separately. Args: activity (ActivityWorker): The garcon activity worker. date (str): YYYY-MM-DD date of rows to delete from staging_raw table. processed_datetime (str): processing timestamp. vendor_id (str): Vendor account to delete from staging_raw table. filename (str): Name of source filename. sfdb_params (dict): Dictionary stores Snowflake params. staging_raw_table (str): The staging raw table name. report_name (str): Name of report being ingested. licensor (str): Name of the licensor to ingest. secrets_path (str): Secrets manager path of the flow. """ activity.logger.info('Deleting {} from {}'.format( date, staging_raw_table)) sql_loader = SQLLoader(__file__, date=date) sf_config = merge_configs(get_sf_config(secrets_path), sfdb_params) AppleFinancialSF(sf_config).execute_query( sql_loader, 'delete_from_staging_raw', { 'db': sf_config['db'], 'schema': sf_config['schema'], 'staging_raw_table': staging_raw_table, 'date': date, 'vendor_id': vendor_id } ) activity.logger.info('Loading {} into {} [{}]'.format( date, staging_raw_table, vendor_id)) params = { 'report_name': report_name, 'licensor': licensor, 'vendor_id': vendor_id, 'processed_datetime': processed_datetime, 'filename': filename, } AppleFinancialSF(sf_config).load_staging_raw_table( date, staging_raw_table, temp_staging_raw_table=get_temp_table_name( date, report_name, vendor_id ), **params) def extend_missing(data, header, expected_cols): """Get report sections, add metadata and fill missing columns. Args: data: List of Lists containing report data in the header order. header (list): List of current column names. expected_cols (list): List of expected column names. Returns: List of Lists containing report data with the missing columns. expected_cols (list): List of expected column names and new header. """ df = pd.DataFrame(data, columns=header) missing_cols = set(expected_cols) - set(header) for col in missing_cols: df[col] = '' return df[expected_cols].values.tolist(), expected_cols def preprocess_content(content): """Remove tab on empty line. Args: content : File content. """ return content.replace('\n\t', '\n') def get_report(report_name, content): """Get report sections, add metadata and fill missing columns. Args: content : File content. report_name (str): Name of report being ingested. Returns: dict: Dictionary with extracted report as a csv object. """ report = {} if report_name in content: # Get indexes of the report sections title_idx = content.find(f'{report_name}\n') column_header_start_idx = content.find('Currency', title_idx) column_header_end_idx = content.find('\n', column_header_start_idx) data_start_idx = column_header_end_idx + 1 data_end_idx = content.find('\n\n', data_start_idx) # Get metadata fiscal_month = re.search('Fiscal Month\t(.*)\n', content).group(1) period = re.search('Period\t(.*)\n', content).group(1) # Get Sections column_header = content[column_header_start_idx:column_header_end_idx] data_section = content[data_start_idx:data_end_idx] report['report_name'] = report_name.replace(' ', '') header = column_header.split('\t') header.extend(['Fiscal Month', 'Period']) # Add metadata data = [] for line in data_section.split('\n'): ln = line.split('\t') ln.extend([fiscal_month, period]) data.append(ln) expected_header = config.expected_reports[ report['report_name'] ]['expected_columns'] # Backfill missing columns with Null if header != expected_header: data, header = extend_missing(data, header, expected_header) # create CSV object csv_obj = StringIO() writer = csv.writer(csv_obj, delimiter='\t') writer.writerow(header) writer.writerows(data) report['csv_obj'] = csv_obj else: report['report_name'] = report_name.replace(' ', '') report['csv_obj'] = None return report @task.decorate(timeout=7200) def process_reports(content): """Process reports. Args: content : File content. Returns: dict: Dictionary of extracted reports. """ preprocessed_content = preprocess_content(content) apple_music_summary = get_report( 'Apple Music Summary', preprocessed_content ) linear_radio_service = get_report( 'Linear Radio Service', preprocessed_content ) apple_music_royalty_calculation = get_report( 'Apple Music Royalty Calculation', preprocessed_content ) apple_music_footer = get_report( 'Apple Music Footer', preprocessed_content ) extracted_reports = { apple_music_summary['report_name']: apple_music_summary['csv_obj'], apple_music_royalty_calculation['report_name']: apple_music_royalty_calculation['csv_obj'], linear_radio_service['report_name']: linear_radio_service['csv_obj'], apple_music_footer['report_name']: apple_music_footer['csv_obj'], } reports_to_process = { k: v for k, v in extracted_reports.items() if v is not None } return reports_to_process def get_temp_table_name(date, report_name, reporter_account): """Generate temp_table_name for specified licensor and report. Args: date (str): Reporting date (YYYY-MM-DD). report_name (str): Name of report. reporter_account (str): A vendor account of the licensor. Returns: str: temp_table_name. """ date_obj = datetime.strptime(date, '%Y-%m-%d') return config.temp_table_name.format( report=report_name, reporter_account=reporter_account, date=date_obj) def get_report_feed_name(feed_name, reporter_account, report): """Generate report feed name. Args: feed_name (str): Name of feed being ingested. reporter_account (str): A vendor account of the licensor. report (str): Name of report. Returns: str: report_feed_name. """ return config.report_feed_name.format( feed_name=feed_name, reporter_account=reporter_account, report=report) @task.decorate(timeout=7200) def drop_temp_table(activity, temp_table_name, sfdb_params, secrets_path=None): """Drop temporary table. Args: activity (ActivityWorker): The garcon activity worker. temp_table_name (str): name of table to drop. sfdb_params (dict): Dictionary stores Snowflake params. secrets_path (str): Secrets manager path of the flow. """ activity.logger.info('Dropping temp table {}'.format(temp_table_name)) sf_config = merge_configs(get_sf_config(secrets_path), sfdb_params) with SnowflakeSQLExecutor(sf_config) as executor: executor.execute_query( sql_loader, 'drop_temp_table', { 'db': sf_config['db'], 'schema': sf_config['schema'], 'table_name': temp_table_name, } )