"""Line Data Ingestion Workflow.""" from datetime import datetime import os import shutil from tempfile import NamedTemporaryFile import zipfile import boto3 from boto3.exceptions import S3UploadFailedError from boto3.s3.transfer import TransferConfig from botocore.exceptions import ClientError 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 snowflake_connector.etl_connector import SQLLoader from feed_ingestion import logger from feed_ingestion.flows.helpers import get_sf_config from feed_ingestion.flows.helpers import handle_http_download_error from feed_ingestion.flows.line import config from feed_ingestion.flows.line.line_api import LineAPI from feed_ingestion.flows.line.snowflake_executor import LINE from feed_ingestion.tasks import check_status from feed_ingestion.util import task_status from feed_ingestion.util.sentry_util import send_error_or_warning STOP_RESPONSE = {'stop': True} # Load SQL templates sql_loader = SQLLoader(__file__) @task.decorate(timeout=1000) def bootstrap(activity, date, licensor, dw_config=None, reload=False): """Bootstrap workflow by getting the correct configurations. Args: activity (ActivityWorker): The activity worker. date (str): Reporting date (YYYY-MM-DD). licensor (str): one of config.licensors Returns: dict: Context. """ date_obj = (datetime.strptime( date, '%Y-%m-%d') if date else datetime.today()) date = date_obj.strftime('%Y-%m-%d') activity.logger.info('Bootstrap flow: {}'.format(date_obj)) assert licensor in config.licensors, f'Invalid licensor: {licensor}' feed_name = '_'.join([config.feed_name, licensor]) if reload == 'True': activity.logger.info( 'Delete status for feed: {} {} '.format(feed_name, date)) garcon_feed_status.delete_status(feed_name, date) else: overall_status = garcon_feed_status.get_overall_status( feed_name, date) if overall_status == garcon_feed_status.STATUS_INGESTED: return STOP_RESPONSE licensor_config = config.licensors[licensor] zipped_path = licensor_config['zipped_archive'].format(date=date_obj) archive_path = licensor_config['archive'].format(date=date_obj) zipped_filename = licensor_config['zipped_filename'].format(date=date_obj) zipped_key_name = f'{zipped_path}{zipped_filename}' source_files_dict = { report: licensor_config['filename'].format(date=date_obj, type=report) for report in config.reports} s3_archive_path = f's3://{config.data_bucket}/{archive_path}' return dict( feed_name=feed_name, licensor=licensor, date=date_obj.strftime('%Y-%m-%d'), zipped_key_name=zipped_key_name, zipped_filename=zipped_filename, archive_path=archive_path, zipped_path=zipped_path, source_files_dict=source_files_dict, s3_archive_path=s3_archive_path, reports=config.reports, kwargs=dict( licensor=licensor, ), ) @task.decorate(timeout=3600) @check_status() def grab_drop_files( activity, feed_name, licensor, date, zipped_filename, zipped_path): """Upload files to archive location. Args: activity (ActivityWorker): The activity worker. feed_name (str): Feed name of workflow execution for status updates. licensor (str): licensor date (str): Reporting date (YYYY-MM-DD). zipped_filename (str): Name of the report to ingest. zipped_path (str): Archive path on S3. """ full_path = 's3://{bucket}/{zipped_path}'.format( bucket=config.data_bucket, zipped_path=zipped_path) remove_files_from_path(activity, full_path, False) try: _grab_resource_wrapper( feed_name, licensor, zipped_path, date, zipped_filename) except RequestException as e: return _handle_connection_error(feed_name, zipped_filename, date, e) garcon_feed_status.set_overall_status( feed_name, date, garcon_feed_status.STATUS_DOWNLOADED) def _grab_resource_wrapper( feed_name, licensor, zipped_path, date, zipped_filename): """Grab a single resource. Args: feed_name (str): Feed name of workflow execution for status updates. licensor (str): licensor zipped_path (str): Archive path on S3. date (str): Reporting date (YYYY-MM-DD). zipped_filename (str): Name of the report to ingest. Returns: None | STOP_RESPONSE: Returns None if download was successful or STOP_RESPONSE otherwise. """ api_config = config.licensors[licensor]['line_api_credentials'] line_api = LineAPI( api_config['client_id'], api_config['client_secret']) try: _grab_resource( feed_name, line_api, zipped_path, date, zipped_filename) except HTTPError as e: if e.response.status_code == 404: return else: return _handle_download_error(feed_name, zipped_filename, date, e) def _grab_resource( feed_name, line_api, zipped_path, date, zipped_filename): """Download data from Line API and upload to S3. Args: feed_name (str): Feed name of workflow execution for status updates. line_api (LineAPI): Line API instance. zipped_path (str): Archive path on S3. date (str): Date. """ with NamedTemporaryFile('wb') as file: line_api.request_to_file(file, date) try: _upload_resource(zipped_path, zipped_filename, file) except S3UploadFailedError as e: _handle_upload_error(feed_name, zipped_path, date, e) @task.decorate(timeout=3600) @check_status() def unzip_files( activity, feed_name, date, zipped_key_name, zipped_filename, archive_path): """Unzip files and upload them on s3. Args: activity (ActivityWorker): The activity worker. feed_name (str): The name of the feed. date (str): Reporting date (YYYY-MM-DD). zipped_key_name (str): Destination S3 path to the archive location. zipped_filename (str): Name of zipped archive. archive_path (str): The archive_path where files from a zip archive will be placed. Returns: (dict): {'stop': True} if number of files in a zip archive is less than expected. Raises: ClientError: If copy failed. """ local_folder = os.path.join(os.path.curdir, f'file_stage_{date}') os.makedirs(local_folder, exist_ok=True) local_file = f'{local_folder}/{zipped_filename}' s3 = boto3.client('s3') s3.download_file(config.data_bucket, zipped_key_name, local_file) with zipfile.ZipFile(local_file, 'r') as zf: zf.extractall(path=local_folder) file_list = zf.namelist() if len(file_list) < len(config.reports): activity.logger.info( f'There is no enough files in the archive for {date}') # delete status to try upload file again in the next run garcon_feed_status.delete_status(feed_name, date) garcon_feed_status.set_overall_status( feed_name, date, garcon_feed_status.STATUS_NOT_INGESTED) shutil.rmtree(local_folder) return {'stop': True} activity.logger.info('Start uploading files list - {}'.format( file_list)) try: for filename in file_list: s3.upload_file( f'{local_folder}/{filename}', config.data_bucket, f'{archive_path}{filename}') except ClientError as e: activity.logger.error(f'Uploading on s3 failed {date} on {filename}') raise e finally: # delete status to try upload file again in the next run garcon_feed_status.delete_status(feed_name, date) garcon_feed_status.set_overall_status( feed_name, date, garcon_feed_status.STATUS_NOT_INGESTED) shutil.rmtree(local_folder) @task.decorate(timeout=3600) def create_temp_staging_raw_table( activity, feed_name, date, report, temp_staging_raw_table): """Create temp staging raw table. Args: activity (ActivityWorker): The activity worker. feed_name (str): The name of the feed. date (str): Reporting date (YYYY-MM-DD). report (str): Name of report (place, pref, ect). temp_staging_raw_table (str): The name of the temp table. """ if task_status.is_completed_task( feed_name, date, 'staging_raw_table_tasks'): return with LINE(get_sf_config(config.secrets_path)) as sf_executor: sf_executor.create_temp_staging_raw_table( temp_staging_raw_table, report_type=report) activity.logger.info( 'Created temp staging raw table for: {}'.format(report)) @task.decorate(timeout=3600) def load_temp_staging_raw_table( activity, feed_name, date, s3_dir_path, source_files_dict, report, temp_staging_raw_table): """Load data into temp staging raw table. Args: activity (ActivityWorker): The activity worker. feed_name (str): The name of the feed. date (str): Reporting date (YYYY-MM-DD). s3_dir_path (str): Path of files in S3. source_files_dict (dict): Dict of source files. report (str): Name of report (place, pref, ect). temp_staging_raw_table (str): The name of the temp table. """ if task_status.is_completed_task( feed_name, date, 'staging_raw_table_tasks'): return with LINE(get_sf_config(config.secrets_path)) as sf_executor: file_path = '{}{}'.format(s3_dir_path, source_files_dict[report]) sf_executor.load_temp_staging_raw_table( temp_staging_raw_table, None, file_path, error_limit=1 ) activity.logger.info( 'Loaded temp staging raw table for: {}'.format(report)) @task.decorate(timeout=3600) def load_staging_raw_table( activity, feed_name, date, temp_staging_raw_table, report, staging_raw_table, kwargs): """Load data into staging_raw table. Args: activity (ActivityWorker): The activity worker. feed_name (str): The name of the feed. date (str): Reporting date (YYYY-MM-DD) temp_staging_raw_table (str): The name of the temp table. report (str): Name of report (place, pref, ect). staging_raw_table (str): The name staging raw table prefix. kwargs (dict): required is licensor """ if task_status.is_completed_task( feed_name, date, 'staging_raw_table_tasks'): return with LINE(get_sf_config(config.secrets_path)) as sf_executor: sf_executor.clean_staging_raw_table( date, staging_raw_table, licensor=kwargs['licensor']) sf_executor.load_staging_raw_table( temp_staging_raw_table, staging_raw_table, report, date, licensor=kwargs['licensor']) activity.logger.info('Loaded staging raw table for: {}'.format(report)) @task.decorate(timeout=3600) def delete_temp_staging_raw_table(activity, temp_staging_raw_table): """Delete data from temp staging raw tables. Args: activity (ActivityWorker): The activity worker. feed_name (str): The name of the feed. date (str): Reporting date (YYYY-MM-DD). temp_staging_raw_table (str): The name of the temp table. """ with LINE(get_sf_config(config.secrets_path)) as sf_executor: sf_executor.drop_temp_staging_raw_table(temp_staging_raw_table) activity.logger.info( 'Deleted temp staging raw table: {}'.format(temp_staging_raw_table)) 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 _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 _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 Line API ' 'while requesting {file_type} report.'.format( file_type=file_type, exception_code=exc.response.status_code if exc.response else exc, exeption_reason=exc.response.reason if exc.response else '')) if os.environ.get('SENTRY_DSN'): send_error_or_warning(exc) return STOP_RESPONSE def _handle_download_error(feed_name, file_type, date, exc): """Handle LINE API download error. Args: feed_name (str): Feed name of workflow execution for status updates. file_type (str): Spotify API resource type.date (str): Reporting date (YYYY-MM-DD). 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 LINE API. ' '{exception_body}'.format( file_type=file_type, exception_body=str(exc))) return handle_http_download_error(exc, raise_error=False)