"""Utility functions for file operations.""" import csv import inspect import json import os import sys import zipfile from pathlib import Path import pandas as pd from connectors.logging import log from constants.fields import ( FILTER_FIELDS ) from constants.localization_definitions import ( ITUNES_LANGUAGES ) from config import ( INPUT_FILE_STARTING_ROW_OFFSET, NROWS, OMIT_BAD_ROWS, OUTPUT_FILE_PATH ) def write_json(data, filename): """Write dicitonary data to a JSON file. Args: data (dict): The data to write. filename (str): The filename to write to. Returns: None """ # Make sure that all directories exist Path(filename).parent.mkdir(parents=True, exist_ok=True) with open(filename, 'w') as file: json.dump(data, file, indent=4, ensure_ascii=False) def write_var_to_json(data: dict, filepath: str = None): """Write dictionary data to a JSON file. Args: data (dict): The data to write. filepath (str, optional): The file path to write to. If not provided, a default filename will be generated based on the variable name. May be a filename or a path alone. Defaults to None. Returns: str: The filepath to which the data was written. """ if filepath is None or Path(filepath).is_dir(): # Generate a default filename based on the variable name caller_frame = inspect.currentframe().f_back variable_name = next( (var_name for var_name, var_val in caller_frame.f_locals.items() if var_val is data), "data" ) timestamp = pd.Timestamp.now().strftime('%Y-%m-%dT%H-%M-%S') filename = f"{variable_name}_{timestamp}.json" else: filename = Path(filepath).name # Assemble file name, and make dirs if necessary if not filepath: if OUTPUT_FILE_PATH: filepath = Path(OUTPUT_FILE_PATH) / filename elif Path(filepath).is_dir(): dir_path = Path(filepath) dir_path.mkdir(parents=True, exist_ok=True) filepath = dir_path / filename else: filepath = Path(filepath) # make sure that all directories exist filepath.parent.mkdir(parents=True, exist_ok=True) # Write the data to the file with filepath.open('w') as file: json.dump(data, file, indent=4, ensure_ascii=False) log.info(f'Wrote {len(data)} items to {filepath}') return str(filepath) def format_track_localization_report(data: dict) -> dict: """Format track localization data for a report. Args: data (dict): The data to format. Returns: list: The formatted data. """ # intialize the return data return_rows = [] for track in data: isrc = list(track.keys())[0] tuid = track[isrc].get("tuid", "") track_name = track[isrc].get("trackName", "") track_number = track[isrc].get("trackNumber", "") volume_number = track[isrc].get("volumeNumber", "") participants = [ p["participant"]["name"] for p in track[isrc].get("participations", []) ] participants_str = ", ".join(participants) localizations = track[isrc].get("localizations", []) if not localizations: return_rows.append([ isrc, tuid, track_name, track_number, volume_number, participants_str, "", "", "" ]) else: for loc in localizations: loc_name = loc.get("trackName", "") loc_lang_id = loc.get("iTunesLanguage", {}).get("id", "") loc_lang = next( (lang["language"] for lang in ITUNES_LANGUAGES if lang["language_id"] == loc_lang_id), "" ) loc_version = loc.get("version", "") return_rows.append([ isrc, tuid, track_name, track_number, volume_number, participants_str, loc_name, loc_version, loc_lang_id, loc_lang, ]) return return_rows def format_release_localization_report(data: dict) -> dict: """Format track localization data for a report. Args: data (dict): The data to format. Returns: list: The formatted data. """ # intialize the return data return_rows = [] for release in data: product_id = list(release.keys())[0] product_localizations = release[product_id].get("productLocalizations", []) if not product_localizations: return_rows.append([ product_id, "", "", "", "", "", "", "" ]) else: for loc in product_localizations: loc_name = loc.get("productName", "") loc_lang_id = loc.get("iTunesLanguage", {}).get("id", "") loc_lang = next( (lang["language"] for lang in ITUNES_LANGUAGES if lang["language_id"] == loc_lang_id), "" ) loc_version = loc.get("deliveredVersion", "") return_rows.append([ product_id, loc_name, loc_version, loc_lang_id, loc_lang, ]) return return_rows def format_smithsonian_report(data: dict) -> list: """Format Smithsonian data for a report. Args: data (dict): The data to format. Returns: list: The formatted data. """ return_rows = [] for track in data: tuid = track.get("tuid", "") track_name = track.get("trackName", "") track_number = track.get("trackNumber", "") volume_number = track.get("volumeNumber", "") # Format performers as "Name (Role)" performers = [ f"{p['name']} ({p['role']})" for p in track.get("performers", []) ] performers_str = ", ".join(performers) # Extract participant names participants = [ p["participant"]["name"] for p in track.get("participations", []) ] participants_str = ", ".join(participants) # Extract publisher names publishers = [ p["name"] for p in track.get("publishing", {}).get("publishers", []) ] publishers_str = ", ".join(publishers) if publishers else "" # Append formatted row return_rows.append([ tuid, track_name, track_number, volume_number, performers_str, participants_str, publishers_str ]) return return_rows def parse_output_data_to_report( input_file: str, task: str, output_file: str = None) -> None: """ Parses a JSON file containing track metadata and writes it to a CSV or XLSX file. Args: input_file (str): Path to the input JSON file. task (str): The task that generated the data. output_file (str, optional): Path to the output file (either .csv or .xlsx). If not provided, the output will default to an XLSX file in OUTPUT_FILE_PATH (if defined), otherwise in the same directory as the input file. Defaults to None. """ # Initialize return rows return_rows = [] return_files = [] # Ensure input file exists input_path = Path(input_file) if not input_path.exists(): log.info(f"Error: Input file '{input_file}' not found.") return # Load JSON data try: with open(input_path, "r", encoding="utf-8") as f: data = json.load(f) except json.JSONDecodeError as e: log.info(f"Error: Failed to parse JSON - {e}") return if not data: log.info("Warning: JSON file is empty. No data to process.") return if task == 'SMITHSONIAN': # Define headers with TUID as the first column headers = [ "tuid", "trackName", "trackNumber", "volumeNumber", "performers", "participants", "publishers", ] return_rows = format_smithsonian_report(data) return_files.append((return_rows, headers)) elif task == 'LOCALIZATIONS': track_headers = [ "isrc", "tuid", "trackName", "trackNumber", "volumeNumber", "participants", "localized_track_name", "localized_version", "localized_lang_id", "localized_language", ] release_headers = [ "product_id", "localized_product_name", "localized_version", "localized_lang_id", "localized_language", ] track_return_rows = \ format_track_localization_report(data['track']) product_return_rows = \ format_release_localization_report(data['product']) return_files.append((track_return_rows, track_headers)) return_files.append((product_return_rows, release_headers)) # Determine default output file if none is provided if output_file is None: if OUTPUT_FILE_PATH: output_path = Path(OUTPUT_FILE_PATH) / input_path.stem else: output_path = input_path.with_suffix(".xlsx") else: output_path = Path(output_file) # If output is a directory, save the file inside it if output_path.is_dir(): output_path = output_path / f"{input_path.stem}.xlsx" # Ensure correct file extension if output_path.suffix not in [".csv", ".xlsx"]: output_path = output_path.with_suffix(".xlsx") # Ensure output directory exists output_path.parent.mkdir(parents=True, exist_ok=True) count = 1 # Write data based on file extension if output_path.suffix == ".csv": for return_rows, headers in return_files: temp_output_path = output_path.with_name( f"{output_path.stem}_{count}.csv") count += 1 with open(temp_output_path, "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow(headers) writer.writerows(return_rows) log.info(f"CSV file saved: {temp_output_path}") elif output_path.suffix == ".xlsx": for return_rows, headers in return_files: temp_output_path = output_path.with_name( f"{output_path.stem}_{count}.xlsx") count += 1 df = pd.DataFrame(return_rows, columns=headers) df.to_excel(temp_output_path, index=False, engine="openpyxl") log.info(f"XLSX file saved: {temp_output_path}") else: log.info("Error: Unsupported file format. Use .csv or .xlsx") return str(output_path) def zip_files(*args): """Zip files together.""" for arg in args: if not Path(arg).exists(): log.info(f"Error: File '{arg}' not found.") return # Zip files zip_path = Path(args[0]).with_suffix(".zip") with zipfile.ZipFile(zip_path, "w") as z: for arg in args: z.write(arg, Path(arg).name) log.info(f"Files zipped to: {zip_path}") return str(zip_path) def unzip_file(zip_path: str, output_dir: str = None): """Unzip files to a specified directory.""" if not Path(zip_path).exists(): log.info(f"Error: Zip file '{zip_path}' not found.") return # Unzip files if output_dir is None: output_dir = Path(zip_path).parent else: output_dir = Path(output_dir) with zipfile.ZipFile(zip_path, "r") as z: z.extractall(output_dir) log.info(f"Files unzipped to: {output_dir}") return str(output_dir) def limit_rows_by_key(data: dict, filter_fields: dict = None) -> dict: """Limit rows by key in the data. Args: data (dict): The data to limit. filter_fields (dict): The fields to filter by. Returns: dict: The limited data. """ if not filter_fields: filter_fields = FILTER_FIELDS # If there are no filter fields, return the data as is if not any(value for value in filter_fields.values() if value): return data # Remove items from filter_fields whose keys don't exist in the row keys. filter_fields = { key: value for key, value in filter_fields.items() if key in data[0] } new_data = [ row for row in data if any( row[field] == value for field, value in filter_fields.items() if value) ] if not new_data: log.error('No data found for the given filters.') for field, value in filter_fields.items(): log.error(f'{field}: {value}') raise ValueError('No data found for the given filters.') return new_data def preprocess_cached_data(filename: str): """Preprocess a JSON file for cached data. Args: filename (str): The filename to process. Returns: dict: The preprocessed data. """ # If it's a zipfile, unzip it, and rename it to the param filename, and # retain the param filename's extension if filename.endswith('.zip'): filepath = unzip_file(filename) base_name, _ = os.path.splitext(filename) filename = base_name + '.json' # TODO: accept other types, like xlsx if filepath != '.': filename = os.path.join(filepath, filename) log.info(f'Now Using cached file: {filename}') # Check the cached data file exists if not os.path.exists(filename): log.error( f'Cached data file not found: {filename}. Exiting.') sys.exit(1) # Exit with error # TODO: Check file extension, and laod to dict in appropriate way # Load the cached data with open(filename, 'r') as file: data = json.load(file) # TODO: Pandas load to dict also. if INPUT_FILE_STARTING_ROW_OFFSET: # Truncate the first N rows while preserving order data = { k: data[k] for i, k in enumerate(data) if i >= INPUT_FILE_STARTING_ROW_OFFSET } # If NROWS is set, limit the number of items while preserving order if NROWS > 0: # Only take the first N rows data = { k: data[k] for i, k in enumerate(data) if i < NROWS } return data def find_invalid_cells(df: pd.DataFrame, converters: dict): """Find invalid cells in a DataFrame. Args: df (pd.DataFrame): The DataFrame to check. converters (dict): A dictionary of column converters. Returns: list: A list of invalid cells. """ invalid_cells = [] for col, conv in converters.items(): for idx, value in df[col].items(): try: conv(value) except ValueError: invalid_cells.append((idx, col, value)) return invalid_cells def preprocess_bad_rows(data: list, required_fields: list): """Remove rows with missing data from a list of dictionaries. Args: data (list): A list of dictionaries. required_fields (list): A list of required fields. Returns: list: A list of dictionaries with rows containing missing data removed. """ # Find all the bad rows and report them error_rows = [ (i, row) for i, row in enumerate(data) if not all( str(row.get(field)).strip() for field in required_fields ) ] if error_rows: log.error( f"Found {len(error_rows)} rows with missing data in the sheet.") for row_with_loc in error_rows: # Find the cols with missing data missing_cols = [ field for field in required_fields if not str(row_with_loc[1].get(field)).strip() ] log.error( f"Row {row_with_loc[0] + 1} is missing data in the following " f"fields: {', '.join(missing_cols)}") if error_rows and OMIT_BAD_ROWS: # Remove rows with missing data # Remove all rows where the any required track-level fields is blank # Cast to str to allow trimming of whitespace for checking log.info('OMIT_BAD_ROWS is set to True.') log.info( 'Removing rows with missing data, and proceeding with ' 'processing...') data = [ row for row in data if all( str(row.get(field)).strip() for field in required_fields ) ] elif error_rows: log.error( 'Please check the data in the sheet for invalid values.') sys.exit(1) return data # Example usage if __name__ == "__main__": if len(sys.argv) < 1: log.info("Usage: python json_to_file.py ") sys.exit(1) input_json_path = sys.argv[1] # output_file_path = sys.argv[2] parse_output_data_to_report(input_json_path)