"""iTunes Marketshare tasks.""" import csv from datetime import datetime from io import StringIO import os import re import subprocess from boto3.exceptions import S3UploadFailedError from garcon import task from garcon_contrib.dynamo_feed_status import garcon_feed_status from feed_ingestion.flows.itunes_marketshare import config from feed_ingestion.tasks import bootstrap as reload from feed_ingestion.util import itunes_reporter from feed_ingestion.util.aws import s3 as s3utils from feed_ingestion.util.itunes_reporter import ReporterException from feed_ingestion.util.sentry_util import send_error_or_warning STOP_RESPONSE = {'stop': True} # Aliases for different column names in the source S2_*.txt files. MARKET_SHARE_INPUT_COLUMN_ALIASES = { # one of the column aliases: original column name 'Total Subscription Days': 'Total Subscriber Days', } # Columns that should be aggregated from input files. MARKET_SHARE_AGGREGATE_COLUMNS = [ 'Revenue', 'Total Subscriber Days' ] # Files since 2017-04 contain date ranges in the middle of the product data. # We have to ignore those lines. DATE_RE = r'\d\d/\d\d/\d\d\d\d' DATE_RANGE_RE = r'{0}-{0}'.format(DATE_RE) @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) s3_preprocessed_path = config.s3['preprocessed'] preprocessed_filename = config.s3['preprocessed_filename'].format( date=date_obj) return { 'feed_name': config.feed_name, 'secrets_path': config.secrets_path, 'date': date, 'staging_raw_table': config.snowflake_table_names['staging_raw'], 's3_archive_path': s3_archive_path, 's3_preprocessed_path': s3_preprocessed_path, 'file_pattern': config.file_pattern, 'itunes_reports': config.itunes_reports, 'preprocessed_filename': preprocessed_filename } @task.decorate(timeout=1000) def get_vendors_and_regions(activity, vendor, report_type): """Execute iTunes Reporter to get available regions for each vendor. Args: activity (ActivityWorker): The Garcon activity worker. vendor (str): Name of processed vendor. report_type (str): Name of processed report. Returns: dict: context for the task, available regions by vendor. """ report_regions = {} reporter = itunes_reporter.get_reporter(vendor_name=vendor) activity.logger.info(f'Running getVendorsAndRegions for {vendor}') try: region_list = reporter.get_available_regions(report_type=report_type) except subprocess.TimeoutExpired as exception: if os.environ.get('SENTRY_DSN'): send_error_or_warning(exception) activity.logger.info( f'Was not able to getVendorsAndRegions for {vendor}: \ {exception}') region_list = [] except ReporterException as exception: if os.environ.get('SENTRY_DSN'): send_error_or_warning(exception) activity.logger.info( f'Was not able to getVendorsAndRegions for {vendor}: \ {exception}') region_list = [] report_regions[vendor] = region_list return report_regions @task.decorate(timeout=1000) def process_drop_files( activity, feed_name, date, s3_archive_path, s3_preprocessed_path, preprocessed_filename, source_files_dict): """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. s3_preprocessed_path (str): S3 path to the preprocessed files location. preprocessed_filename (str): Filename of processed file. source_files_dict (dict): Dict containing metadata of files. Returns: source_files_dict (dict): Dict containing metadata of processed file. """ csv_obj = _process_data(s3_archive_path, source_files_dict, date) try: source_files_dict = s3utils.upload_processed_to_s3( csv_obj, '{}{}'.format(s3_preprocessed_path, preprocessed_filename), expected_bucket_owner=config.expected_bucket_owner ) except S3UploadFailedError as e: garcon_feed_status.set_overall_status( feed_name, date, garcon_feed_status.STATUS_NOT_AVAILABLE) activity.logger.error( 'Cannot upload files to {path}. {exception_body}'.format( path=s3_preprocessed_path, exception_body=e)) raise e activity.logger.info('Successfully processed drop files for {}'.format( date)) return source_files_dict def _process_data(s3_archive_path, source_files_dict, date): """Process input data and add additional fields. Args: s3_archive_path (str): S3 path to the archive location. source_files_dict (dict): Dict with files metadata. Returns: StringIO: Processed csv data. """ rows = [] for filename, content in s3utils.get_source_files_content( s3_archive_path, source_files_dict): intro, countries, products = _get_sections(content) # Skip if incorrect report type if 'Apple Music Cover Sheet' not in intro: continue report_header = _get_report_header(filename, intro, countries) products_line_by_line = [ line for line in str(products).replace('\t\t', '').split('\n') if line != '\t' and line] range_prods = _get_product_ranges( products_line_by_line, report_header) for product in [products_line_by_line[start:end] for (start, end) in range_prods]: for row in _parse_product(report_header, product): rows.append(row) csv_obj = StringIO() writer = csv.DictWriter( csv_obj, fieldnames=_get_fieldnames(date), delimiter='\t') writer.writeheader() writer.writerows(rows) return csv_obj def _get_fieldnames(date): """Get fieldnames for processed file.""" if date >= '2023-08-01': return config.fieldnames['2023-08-01'] return config.fieldnames['default'] def _get_product_ranges(products_line_by_line, report_header): """Get product ranges. Generate list of tuples of product start:end. Args: products_line_by_line (list): List of lines from report file. report_header (dict): Report header. Returns: list: list of products ranges. """ if len(report_header['countries']) == 1: prods = [line.replace('\t', '') for line in products_line_by_line if line.isupper() and line] prod_index = [products_line_by_line.index(prod + '\t') for prod in prods] else: prods = [line for line in products_line_by_line if line.isupper() and line] prod_index = [products_line_by_line.index(prod) for prod in prods] prod_index.append(-1) range_prods = [(prod_index[i], prod_index[i + 1]) for i in range(len(prod_index) - 1)] return range_prods def _get_report_header(filename, intro, countries): """Get report header. Args: filename (str): File name. intro (list): Report's intro section. countries (str): Report's countries section. Returns: dict: Report header. """ date_range = _find_content(intro, 'Period').split('\t')[-1] report_header = { 'currency': _find_content(intro, 'Currency').split('\t')[-1], 'countries': [country for country in countries.split('\t') if country], 'start_date': date_range.split('-')[0], 'end_date': date_range.split('-')[-1], 'distributor': filename.split('_')[-3], 'filename': filename } return report_header def _get_sections(content): """Get file sections. Args: content (str): File content. Returns: tuple: Tuple of file (into, countries and product) sections. """ chunks = content.replace('\r', '').split('\n') intro = chunks[0:4] # there is new field 'Eligible Accounts' in chunks[1] since 2019-03-01, # which is skipped for now countries = chunks[5] products = '\n'.join(chunks[9:]) return intro, countries, products def _normalize(row): """Normalize column values. Args: row (dict): Preprocessed row. Returns: dict: Normalized row. """ for col in ['Label Proportionate Share', 'Paid Subscriber Market Share']: if col in row and row[col] != '': row[col] = float(row[col]) * 100 return row def _parse_product(report_header, product): """Parse product from the report file. Args: report_header (dict): Report header. product (list): Product data. Returns: list: List of processed rows. """ lines = [line.split('\t') for line in product if line] product_name = lines[0][0].strip() rows = [] for country in report_header['countries']: new_row = {'country': country} if 'LINEAR RADIO' in product_name: new_row['product'] = 'LINEAR RADIO' else: new_row['product'] = product_name new_row['start_date'] = report_header['start_date'] new_row['end_date'] = report_header['end_date'] new_row['currency'] = report_header['currency'] new_row['distributor'] = report_header['distributor'] new_row['filename'] = report_header['filename'] for line in lines[1:]: new_row = _populate_column(new_row, line, report_header) new_row = _set_product_flags(new_row) new_row = _normalize(new_row) rows.append(new_row) return rows def _populate_column(row, line, report_header): """Populate product's column line. Args: row (dict): Row to populate. line (list): Product's line. report_header (dict): Report header. Returns: dict: Updated row. """ col_name = line[0].strip() col_values = line[1:] col_name = str(MARKET_SHARE_INPUT_COLUMN_ALIASES.get( col_name, col_name)) if col_name and not re.match(DATE_RANGE_RE, col_name): new_val = col_values[report_header[ 'countries'].index(row['country'])] if new_val == '-': return row if col_name in MARKET_SHARE_AGGREGATE_COLUMNS: row[col_name] = row.get(col_name, 0) + float(new_val) else: row[col_name] = float(new_val) return row def _set_product_flags(row): """Set boolean flags based on product type (is_trial, etc.). Args: row (dict): Preprocessed row. Returns: dict: The row updated with product flag attributes. """ row['is_trial'] = 'TRIAL' in row['product'] row['is_multiuser'] = 'FAMILY' in row['product'] row['is_adsup'] = 'LINEAR RADIO' == row['product'] row['is_bundle'] = False return row def _find_content(content, substring): """Find a string by substring. Args: content (list): List of strings to search in. substring (str): Search string. Returns: str: String containing substring or None. """ for s in content: if substring in s: return s return None