"""data_landing_zone workflow tasks.""" from collections import defaultdict from datetime import date as date_module from datetime import datetime from itertools import product from multiprocessing.pool import Pool from tempfile import NamedTemporaryFile import boto3 from boto3.exceptions import S3UploadFailedError from boto3.s3.transfer import TransferConfig from garcon import task from garcon_contrib.aws.garcon_s3 import remove_files_from_path from garcon_contrib.dynamo_feed_status import garcon_feed_status from requests import HTTPError from requests import RequestException from data_landing_zone.flows.spotify import config from data_landing_zone.flows.spotify import helpers from data_landing_zone.flows.spotify import logger from data_landing_zone.flows.spotify.helpers import \ handle_http_download_error from data_landing_zone.flows.spotify.spotify_api import \ SpotifyAPI STOP_RESPONSE = {'stop': True} @task.decorate(timeout=1000) def bootstrap(activity, date, reload, reports, licensors): """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 all feed statuses in DynamoDB. reports (str or None): List of the reports to ingest (optional). licensors (str or None): List of the licensors to ingest (optional). Returns: dict: Initial context for the workflow. """ date = date or date_module.today().strftime('%Y-%m-%d') parsed_date = datetime.strptime(date, '%Y-%m-%d') reports_list = _get_context_values(reports, list(config.reports.keys())) licensors_list = _get_context_values( licensors, config.spotify_api_licensors) reports_status_names = defaultdict(dict) archive_paths = defaultdict(dict) for licensor, report_name in product( licensors_list, set(reports_list + config.common_reports)): report_feed_name = _get_feed_name(licensor, report_name) if reload == 'True' and report_name in reports_list: garcon_feed_status.delete_status(report_feed_name, date) if not check_report(report_name, report_feed_name, date): continue reports_status_names[licensor][report_name] = report_feed_name archive_paths[licensor][report_name] = ( config.s3['archive_path'].format( report_name=report_name, date=parsed_date, licensor=licensor)) return { 'feed_name': config.feed_name, 'date': date, 'date_as_in_uuid': date, 'archive_paths': archive_paths, 'reports_status_names': reports_status_names, 'licensors_list': licensors_list} @task.decorate(timeout=36000) # @check_status() def grab_drop_files( activity, feed_name, date, report_name, archive_path, licensor): """Upload files to archive location. Args: activity (ActivityWorker): The activity worker. feed_name (str): Feed name of workflow execution for status updates. date (str): Reporting date (YYYY-MM-DD). report_name (str): Name of the report to ingest. archive_path (str): Archive path on S3 for licensor and report. licensor (str): Name of licensor for downloading files. """ task_id = 'grab_drop_files' if helpers.is_completed_task(feed_name, date, task_id): return full_path = 's3://{bucket}/{archive_path}'.format( bucket=config.data_bucket, archive_path=archive_path) remove_files_from_path(activity, full_path, False) try: res = _grab_licensor_files( feed_name, licensor, date, archive_path, report_name) if STOP_RESPONSE in res: return STOP_RESPONSE except RequestException as e: return _handle_connection_error(feed_name, report_name, date, e) helpers.mark_completed_task(feed_name, date, task_id) garcon_feed_status.set_overall_status( feed_name, date, garcon_feed_status.STATUS_DOWNLOADED) def _handle_connection_error(feed_name, file_type, date, exc): """Handle Spotify API connection error. Args: feed_name (str): Feed name of workflow execution for status updates. date (str): Reporting date (YYYY-MM-DD). file_type (str): Spotify API resource type. exc (RequestException): Thrown exception. """ garcon_feed_status.set_overall_status( feed_name, date, garcon_feed_status.STATUS_NOT_AVAILABLE) logger.error( 'Got {exception_code} {exeption_reason} error from Spotify API ' 'while requesting {file_type} report.'.format( file_type=file_type, exception_code=exc.response.status_code, exeption_reason=exc.response.reason)) return STOP_RESPONSE def _grab_licensor_files(feed_name, licensor, date, archive_path, report_name): """Download files for a specific licensor. Args: feed_name (str): Feed name of workflow execution for status updates. licensor (str): Name of licensor for downloading files. date (str): Reporting date (YYYY-MM-DD). archive_path (str): Archive path on S3. report_name (str): Name of the report to ingest. Returns: list: List of download results. """ if not config.reports[report_name].get('use_countries', False): return [_grab_resource_wrapper( feed_name, licensor, archive_path, date, report_name)] else: args = [ (feed_name, licensor, archive_path, date, report_name, country) for country in config.countries] with Pool(processes=config.pool_size) as pool: res = pool.starmap_async(_grab_resource_wrapper, args) pool.close() pool.join() res = res.get() return res def _grab_resource_wrapper( feed_name, licensor, archive_path, date, report_name, country=None): """Grab a single resource. Args: feed_name (str): Feed name of workflow execution for status updates. licensor (str): Name of licensor for downloading files. archive_path (str): Archive path on S3. date (str): Reporting date (YYYY-MM-DD). report_name (str): Name of the report to ingest. country (str): Country name. Returns: None | STOP_RESPONSE: Returns None if download was successful or STOP_RESPONSE otherwise. """ spotify_api = SpotifyAPI( config.spotify_api_credentials[licensor]['client_id'], config.spotify_api_credentials[licensor]['client_secret'], licensor, config.spotify_api_credentials[licensor]['version']) try: _grab_resource( feed_name, spotify_api, archive_path, date, report_name, country) except HTTPError as e: if (country and e.response.status_code == 404 and country not in config.expected_countries): return else: return _handle_download_error(feed_name, report_name, date, e) def _handle_download_error(feed_name, file_type, date, exc): """Handle Spotify API download error. Args: feed_name (str): Feed name of workflow execution for status updates. date (str): Reporting date (YYYY-MM-DD). file_type (str): Spotify API resource type. exc (HTTPError): Thrown exception. """ garcon_feed_status.set_overall_status( feed_name, date, garcon_feed_status.STATUS_NOT_AVAILABLE) logger.error( 'Cannot download {file_type} from Spotify API. ' '{exception_body}'.format( file_type=file_type, exception_body=str(exc))) return handle_http_download_error(exc) def _dispatch(fd, spotify_api, file_type, date, country=None): """Dispatch Spotify API call. Args: fd (File): File descriptor of result file. spotify_api (SpotifyAPI): Spotify API instance. file_type (str): Spotify API resource type. date (str): Date. country (str): Country for streams resource. """ if file_type == 'tracks': spotify_api.get_tracks_to_file(fd, date) elif file_type == 'users': spotify_api.get_users_to_file(fd, date) elif file_type == 'streams': spotify_api.get_streams_to_file(fd, date, country) elif file_type == 'sub_30_sec_streams': spotify_api.get_sub_30_sec_streams_to_file(fd, date, country) elif file_type == 'aggregated_streams': spotify_api.get_aggregated_streams_to_file(fd, date) def _grab_resource( feed_name, spotify_api, archive_path, date, file_type, country=None): """Download data from Spotify API and upload to S3. Args: feed_name (str): Feed name of workflow execution for status updates. spotify_api (SpotifyAPI): Spotify API instance. archive_path (str): Archive path on S3. date (str): Date. file_type (str): Spotify API resource type. country (str): Country for streams resource. """ parsed_date = datetime.strptime(date, '%Y-%m-%d') with NamedTemporaryFile('wb') as file: _dispatch(file, spotify_api, file_type, date, country) try: filename = config.file_pattern.format( date=parsed_date, country_code='_{}'.format(country) if country else '', licensor=spotify_api.licensor_name, report_name=file_type) _upload_resource(archive_path, filename, file) except S3UploadFailedError as e: _handle_upload_error(feed_name, archive_path, date, e) def _handle_upload_error(feed_name, archive_path, date, exc): """Handle S3 upload Errors. Args: feed_name (str): Feed name of workflow execution for status updates. archive_path (str): Archive path on S3. date (str): Reporting date (YYYY-MM-DD). exc (S3UploadFailedError | HTTPError): Thrown exception. """ garcon_feed_status.set_overall_status( feed_name, date, garcon_feed_status.STATUS_NOT_AVAILABLE) logger.error( 'Cannot upload file to {path}. {exception_body}'.format( path=archive_path, exception_body=exc)) raise exc def _upload_resource(archive_path, filename, fd): """Upload file to S3. Args: archive_path (str): Archive path on S3. filename (str): Filename. fd (File): File descriptor of file to upload. """ s3 = boto3.client('s3') key_name = '{bucket_path}{file_name}'.format( bucket_path=archive_path, file_name=filename) s3.upload_file( fd.name, config.data_bucket, key_name, Config=TransferConfig()) logger.info( '{filename} uploaded to {path}'.format( filename=filename, path=archive_path)) def _get_context_values(values_from_context, possible_values): """Transform values from context into string and check them. Args: values_from_context (str or None): string with comma separator. possible_values (list): Return this if values_from_context is None. Returns: list: A list with values from context. Raises: ValueError: If values are inappropriate. """ if not values_from_context: return possible_values values_list = [ value.strip().lower() for value in values_from_context.split(',')] if not set(values_list).issubset(possible_values): raise ValueError( 'This is incorrect format! Please provide ' 'comma-separated list.\n' '{} was provided.'.format(values_from_context) ) return values_list def _get_feed_name(licensor, report_name): """Generate feed_name for specified licensor and report. Args: licensor (str): Nme of licensor. report_name (str): Name of report. Returns: str: feed_name. """ return '_'.join([config.feed_name, licensor, report_name]) def check_report(report_name, report_feed_name, date): """Check status if report is already ingested. Args: report_name (str): Name of the report to ingest. report_feed_name (str): Feed name for status updates. date (str): Reporting date (YYYY-MM-DD). Returns: (bool): True if report need to be ingested, otherwise False. """ return (report_name in config.common_reports or garcon_feed_status.get_overall_status( report_feed_name, date) != garcon_feed_status.STATUS_INGESTED)