"""Cable Ingestion specific utility functions.""" import csv import hashlib from os import path import re import tarfile import tempfile from flows import s3 from flows.cable_ingestion import config from flows.exceptions import EmptyGeneratorError _rentrak_filename_re = re.compile( '^rentrak_theorchard_subscriptions_' '(?P\d{8})_(?P\d{8})_(?P\d{14})' '\.(ctl|tar\.gz)$') _rentrak_backfill_filename_re = re.compile( '^rentrak_theorchard_subscriptions_' '(?P\d{8})_(?P\d{8})_(?P\d{14})' '\.(ctl\.gz|tar\.gz)$') def verify_rentrak_data_file(filename): """Verify the filename is a Rentrak data file. Args: filename (str): filename to verify. Returns: bool: True if valid filename. """ filename = path.basename(filename) if config.BACKFILL_RUN: return _rentrak_backfill_filename_re.fullmatch(filename) is not None return _rentrak_filename_re.fullmatch(filename) is not None def verify_rentrak_data_file_date_range(date_end, date_start, filename): """Verify the filename is in a date range. Param date_end is considered exclusive in the range, and start is inclusive. This is to have similar logic to slices. Args: date_end (str): YYYYMMDD format date. date_start (str): YYYYMMDD format date. filename (str): filename to verify. Returns: bool: True if in range. """ file_dates = path.basename(filename).split('_') file_start = int(file_dates[3]) file_end = int(file_dates[4]) date_start = int(date_start) date_end = int(date_end) return date_end > file_start and date_start <= file_end def filter_files_by_date_range(date_end, date_start, files): """Filter out raw data specific files by date range. Args: date_end (str): YYYYMMDD format date. date_start (str): YYYYMMDD format date. files (iterable): list of filename strings. Yields: str: filename that matches the pattern and date range. """ return ( f for f in files if ( verify_rentrak_data_file(f) and verify_rentrak_data_file_date_range(date_end, date_start, f))) def filter_file_pairs(files, tolerance=3): """Filter files from input that are paired tarball and ctl files. Pairing is valid when the daterange in the filenames match. However, the timestamp of the filenames do not need to match. The tolerance parameter adjusts the sensitivity of the timestamp matching. Args: files (iterable): filenames. tolerance (int): magnitude of the timestamp resolution to ignore. Yields: dict: matching filename pairs, with extension as keys. """ ctl_extension = '.ctl.gz' if config.BACKFILL_RUN else '.ctl' pairs = {} for filename in files: if filename.endswith('.tar.gz'): key = filename[:-(7 + tolerance)] pair_key = 'tar.gz' elif filename.endswith(ctl_extension): key = filename[:-(len(ctl_extension) + tolerance)] pair_key = 'ctl' else: continue pairs.setdefault(key, {}) pair = pairs[key] pair[pair_key] = filename if 'tar.gz' in pair and 'ctl' in pair: yield pair def extract_ctl_data(file_handler): """Get all metadata info from a Rentrak ctl file. Args: filename (file): file handler of ctl file. Returns: generator: dictionary with csv filename, hash, and size. """ return ( convert_ctl_data(line) for line in csv.reader(file_handler, delimiter='|')) def convert_ctl_data(csv_line): """Convert a single csv line from the ctl into a dict. Args: csv_line (list): csv reader provided line to convert. Returns: dict: annotated data of the csv line. """ filename, md5, size = csv_line return {'name': filename, 'md5': md5, 'size': int(size)} def extract_rentrak_metadata(file_handler): """Get the metadata of files from inside the rentrak data tarball. Args: filename (file): file handler of tarball file. Returns: generator: metadata dictionaries of files inside the tarball. """ with tarfile.TarFile.open(fileobj=file_handler) as fh: for member in fh.getmembers(): yield convert_rentrak_metadata(member, fh) def convert_rentrak_metadata(tarinfo, tarfile): """Construct the metadata of the file inside a tarball. Args: tarinfo (tarfile.TarInfo): tarfile member object. tarfile (tarfile.TarFile): tarfile handler object, used for extraction. Returns: dict: metadata of the file inside the tarball. """ data = tarfile.extractfile(tarinfo) md5 = hashlib.md5() md5.update(data.read()) return { 'md5': md5.hexdigest(), 'name': tarinfo.name, 'size': tarinfo.size} def get_rows_from_tarball(url, **csv_params): """Get CSV rows from a S3 tarball. Args: url: S3 URL. csv_params: parameters to pass to the csv reader. Raises: EmptyGeneratorError: no rows in any csv file listed in the manifest. Yields: list: row of csv data. """ has_rows = False s3_object = s3.get_object(url) with tempfile.TemporaryFile() as tfh: s3_object.download_fileobj(tfh) tfh.seek(0) tar_object = tarfile.open(fileobj=tfh) members = sorted( (member for member in tar_object), key=lambda m: m.name) for member in members: csv_fh = tar_object.extractfile(member) csv_strings = (l.decode('utf-8') for l in csv_fh) csv_lines = csv.reader(csv_strings, **csv_params) for line_number, line in enumerate(csv_lines): if line_number == 0: continue has_rows = True yield normalize_csv_columns(line, config.RAW_CSV_COLUMN_TYPES) tar_object.close() if not has_rows: raise EmptyGeneratorError def normalize_csv_columns(row, mapping): """Return a row from the csv with None values for empty cells. Args: row (list): row of data from the cable feed. mapping (iterable): column index, callable type cast map. Returns: list: normalized row. """ new_row = [] for index, col in enumerate(row): col = col.strip() if not len(col): new_row.append(None) continue for indexes, caster in mapping: if index in indexes: col = caster(col) break new_row.append(col) return new_row def extract_filename_dates(filename): """Extract the dates and timestamp of a rentrak file's name. Args: filename (str): rentrak filename. Returns: dict: date range and timestamp. """ match = _rentrak_filename_re.fullmatch(filename) if not match: return None return match.groupdict()