From fd3d31bef8b81069a90d76e58b28f5a21cda6d05 Mon Sep 17 00:00:00 2001 From: hunterckx <118154470+hunterckx@users.noreply.github.com> Date: Fri, 14 Aug 2026 15:57:06 -0700 Subject: [PATCH 01/11] feat: remove legacy functionality and dependencies from analytics package (#4912) --- .../analytics_package/analytics/charts.py | 403 +------------- .../analytics_package/analytics/sheets_api.py | 493 ------------------ .../analytics/sheets_elements.py | 46 -- analytics/analytics_package/setup.py | 10 +- 4 files changed, 12 insertions(+), 940 deletions(-) delete mode 100644 analytics/analytics_package/analytics/sheets_api.py diff --git a/analytics/analytics_package/analytics/charts.py b/analytics/analytics_package/analytics/charts.py index 827017e6f..b69a08e19 100644 --- a/analytics/analytics_package/analytics/charts.py +++ b/analytics/analytics_package/analytics/charts.py @@ -1,318 +1,17 @@ from . import api as ga -import matplotlib.pyplot as plt import pandas as pd -import numpy as np -from IPython.core.display import HTML -from html import escape as escape_html - """ -init_tables must be called in the notebook to insert a required stylesheet - - Functions related to tables and data frames will pass any extra parameters they recieve down to any such functions that they call -These parameters include the ones used by get_metrics_by_dimensions in the api module, as well as the table formatting parameters outlined below, and the the `fontsize` parameter for plotting functions - +These parameters include the ones used by get_metrics_by_dimensions in the api module -Table formatting/construction parameters, by table types -These parameters can be used with functions producing tables of the given type -Exceptions to this rule are given in parentheses - -All tables: -rows_limit +Table formatting/construction parameters: df_processor -num_keep_dimensions (determined automatically based on ylabels if omitted for show_difference_table) -index_key_formatter -collapse_index (supplanted in format_change_over_time_table, show_plot_over_time - always True) -hide_index (supplanted in show_difference_table - determined by data shape) -hide_columns (supplanted in show_difference_table - determined by data shape) -split_vertical -pre_render_processor - -Percent change tables: -include_plus -row_symbols (supplanted in format_table_with_change, show_difference_table - automatically inserted if show_symbols is True) - -Change over time tables: -table_subindex -pre_plot_df_processor - -Change between frames tables: -show_symbols (supplanted in show_difference_table - determined based on rows_type) - -Change between periods tables: -rows_type +num_keep_dimensions """ - -authenticate_api = ga.authenticate - -authenticate_ga = ga.authenticate - -authenticate_ga4 = lambda secret_name: ga.authenticate(secret_name, ga.ga4_service_params) - -authenticate_yt = lambda secret_name: ga.authenticate(secret_name, ga.yt_service_params) - -def init_tables(): - display(HTML(""" - - """)) - -def percent_change(valfrom, valto): - return (valto - valfrom)/valfrom * 100 - -def format_table(df, column_defs=["1fr"], index_key_formatter=None, collapse_index=False, hide_index=False, hide_columns=False, split_vertical=None, pre_render_processor=None, **other_params): - if not isinstance(column_defs, dict): - column_defs = {None: column_defs} - - if not pre_render_processor is None: - df, column_defs = pre_render_processor(df, column_defs) - - - def make_item_col_header_code(name): - num_subcols = len(column_defs[name if name in column_defs else None]) - return '
' + escape_html(str(name)) + '
' - - - header_text = 'grid-template-columns: ' - - if not hide_index: - if isinstance(df.index, pd.MultiIndex): - header_text += 'repeat(' + str(df.index.nlevels - 1) + ', auto) ' - header_text += '1fr ' - - header_text += " ".join([cdef[0] if isinstance(cdef, tuple) else cdef for name in df.columns for cdef in column_defs[name if name in column_defs else None]]) + '">' - - if not hide_columns: - if not hide_index: - if isinstance(df.index, pd.MultiIndex): - header_text += "".join(['
' + escape_html(name) + '
' for name in df.index.names]) - else: - header_text += '
' + escape_html(df.index.name) + '
' - header_text += "".join([make_item_col_header_code(name) for name in df.columns]) - - - def apply_formatter(formatter, val, *rest): - if formatter: - result = formatter(val, *rest) - return (str(result[0]) if result[1] else escape_html(str(result[0]))) if isinstance(result, tuple) else escape_html(str(result)) - else: - return escape_html(str(val)) - - def make_index_col_code(index_runs, v, classes, row_n, icol_n): - if index_runs and index_runs[icol_n][row_n] == 0: - return "" - result = '
' - return result - - def make_index_code(index_runs, val, classes, row_n): - if hide_index: - return "" - if not isinstance(val, tuple): - val = (val,) - return "".join([make_index_col_code(index_runs, v, classes, row_n, icol_n) for icol_n, v in enumerate(val)]) - - def make_item_col_code(val, classes, i, c, cdef): - return '
' + (apply_formatter(cdef[1], val, i, c) if isinstance(cdef, tuple) else escape_html(str(val))) + '
' - - def make_item_code(val, classes, i, c): - this_column_defs = column_defs[c if c in column_defs else None] - return "".join([make_item_col_code(val, classes + (" anaSubcolLeft" if subcol_n == 0 else " anaSubcolRight" if subcol_n == len(this_column_defs) - 1 else ""), i, c, cdef) for subcol_n, cdef in enumerate(this_column_defs)]) - - def make_row_code(index_runs, row, n, i): - row_class = "anaOdd" if n%2 else "anaEven" - return make_index_code(index_runs, i, row_class, n) + "".join([make_item_code(item, row_class, i, c) for c, item in row.items()]) - - def format_table_split(df, final): - index_runs = None - - if collapse_index: - index_df = df.index.to_frame(index=False) - index_runs = [index_df[name].groupby((index_df[name] != index_df[name].shift()).cumsum()).transform(lambda g: [g.shape[0]] + [0] * (g.shape[0] - 1)) for name in index_df.columns] - - result_text = '
0 else "anaZero") + '">' + change_format.format(val) + '
', True) - - def format_row_symbol(name): - return ('
', True) if name else "" - - column_defs = [ - ("minmax(var(--value-width), min-content)", lambda v, i, c: str(v) if isinstance(v, int) else "{:.2f}".format(v)), - ("minmax(var(--percentage-width), min-content)", lambda v, i, c: format_change_value(df_changes.loc[i, c])) - ] - - if not row_symbols is None: - column_defs = {None: column_defs, df_values.columns[0]: [("var(--symbol-width)", lambda v, i, c: format_row_symbol(row_symbols.loc[i, (c, "Value")]))] + column_defs} - - return format_table(df_values, column_defs, **other_params) - -def format_change_over_time_table(df, table_subindex="Month", change_dir=1, **other_params): - df2 = df.copy(deep=True) - - data_cols = [c for c in df2.columns] - - df2[table_subindex] = df2.index.quarter if table_subindex == "Quarter" else df2.index.month_name() - - df2['Year'] = df2.index.year - - df2 = df2.groupby(['Year', table_subindex], sort=False).sum() - - df2.columns = pd.MultiIndex.from_tuples([(name, 'Value') for name in data_cols]) - - for name in data_cols: - df2[(name, '% Change')] = df2[name][::change_dir].pct_change()[::change_dir].mul(100) - - df2 = df2[[(a, b) for a in data_cols for b in ['Value', '% Change']]] - - return format_pc_change_table(df2, collapse_index=True, **other_params) - -def format_table_with_change(df, df_prev, show_symbols=True, **other_params): - # The data frames must have the same column names but may have some different rows - - df_joined = df.join(df_prev, rsuffix="_prev") - df_change = pd.concat([v for name in df.columns for v in (df_joined[name], percent_change(df_joined[name + "_prev"], df_joined[name]))], axis=1) - df_change.columns = pd.MultiIndex.from_tuples([(df_change.columns[i - i%2], "Value" if i%2 == 0 else "% Change") for (i, name) in enumerate(df_change.columns)]) - - indices = pd.DataFrame(index=df.index) - indices["index"] = range(indices.shape[0]) - indices_prev = pd.DataFrame(index=df_prev.index) - indices_prev["index"] = range(indices_prev.shape[0]) - indices_combined = indices.join(indices_prev, rsuffix="_prev") - - indices_diff = indices_combined["index"] - indices_combined["index_prev"] - - classes = None - - if show_symbols: - classes_series = indices_diff.map(lambda v: "new" if pd.isna(v) else None if v == 0 else "up" if v < 0 else "down") # lower index = higher in chart - classes = pd.DataFrame(index=df_change.index, columns=df_change.columns) - classes[:] = None - classes.iloc[:, [0]] = classes_series - - return format_pc_change_table(df_change, row_symbols=classes, **other_params) - -def get_top_ga_df(metrics, dimensions, ascending=True, rows_limit=30, **other_params): - df = get_data_df(metrics, dimensions, **other_params) - - if ascending != None: - df = df.sort_values(by=metrics, ascending=ascending) - - if not rows_limit is None: - df = df.tail(rows_limit) if ascending else df.head(rows_limit) - - return df - def get_data_df(metrics, dimensions, percentage_metrics=None, percentage_suffix="_percentage", num_keep_dimensions=None, df_processor=None, **other_params): if metrics is None: df = pd.DataFrame() @@ -321,7 +20,7 @@ def get_data_df(metrics, dimensions, percentage_metrics=None, percentage_suffix= if dimensions: if len(dimensions) > 1 and not num_keep_dimensions is None: - df.drop(columns=dimensions[num_keep_dimensions:], inplace=True); + df.drop(columns=dimensions[num_keep_dimensions:], inplace=True) df.set_index(dimensions[:num_keep_dimensions], inplace=True) for metric in metrics: str_column = df[metric].astype(str) @@ -343,79 +42,6 @@ def get_data_df(metrics, dimensions, percentage_metrics=None, percentage_suffix= def strings_to_lists(*vals): return [[v] if isinstance(v, str) else v for v in vals] -def show_difference_table(xlabels, ylabels, metrics, dimensions, period, prev_period, rows_type="ordered", prev_period_params={}, num_keep_dimensions=None, **other_params): - xlabels, ylabels, metrics, dimensions = strings_to_lists(xlabels, ylabels, metrics, dimensions) - - period = pd.Period(period) - prev_period = prev_period and pd.Period(prev_period) - - shared_params = { - "metrics": metrics, - "dimensions": dimensions, - "ascending": False if rows_type == "ordered" else None, - "num_keep_dimensions": (len(ylabels) if ylabels else 1) if num_keep_dimensions is None else num_keep_dimensions - } - df = get_top_ga_df(**shared_params, start_date=period.start_time.isoformat()[:10], end_date=period.end_time.isoformat()[:10], **other_params) - if prev_period is None: - all_frames = (df,) - else: - all_frames = (df, get_top_ga_df(**shared_params, start_date=prev_period.start_time.isoformat()[:10], end_date=prev_period.end_time.isoformat()[:10], **{**other_params, **prev_period_params})) - - is_single_cell = all([f.shape[0] == 1 and f.shape[1] == 1 for f in all_frames]) - - if is_single_cell and not ylabels: - for f in all_frames: - f.index = pd.Index(xlabels) - else: - xlabels_dict = {col: xlabel for col, xlabel in zip(df.columns, xlabels)} - for f in all_frames: - f.rename(columns=xlabels_dict, inplace=True) - if ylabels: - f.index.rename(ylabels if len(ylabels) > 1 else ylabels[0], inplace=True) - - formatting_params = { - "hide_index": not ylabels and not is_single_cell, - "hide_columns": is_single_cell - } - - if len(all_frames) == 2: - formatted = format_table_with_change(*all_frames, show_symbols=(rows_type == "ordered" or rows_type == "unordered"), **formatting_params, **other_params) - else: - formatted = format_table( - df, - column_defs=[("minmax(calc(var(--value-width) + var(--percentage-width)), min-content)", lambda v, i, c: str(v) if isinstance(v, int) else "{:.2f}".format(v))], - **formatting_params, - **other_params - ) - - display(formatted) - -def make_month_filter(filter_cols): - def filter(df): - non_filter_cols = [name for name in df.columns if not name in filter_cols] - filtered = df[df.index.month != (df.index + pd.Timedelta(days=1)).month] - filtered = filtered.set_axis(filtered.index.to_period("M").to_timestamp(), axis="index") - agg = df[non_filter_cols].groupby(df.index.to_period("M")).sum() - agg = agg.set_axis(agg.index.to_timestamp(), axis="index") - return pd.concat([filtered[name] if name in filter_cols else agg[name] for name in df.columns], axis="columns", join="inner") - - return filter - -def show_plot(df, title, fontsize=16, **other_params): - # Notes: Linking Pandas and Matplotlib - # https://stackoverflow.com/questions/29568110/how-to-use-ax-with-pandas-and-matplotlib - - fig, ax = plt.subplots(figsize=(16, 9)) - df.plot(ax=ax, fontsize=fontsize) #Link the df with the axis - - ax.set_xlabel('Time', fontsize=fontsize) - ax.set_ylabel('Count', fontsize=fontsize) - - ax.legend(fontsize=fontsize) - - fig.suptitle(title, fontsize=fontsize) - plt.show() - def get_df_over_time(xlabels, metrics, dimensions, df_filter=None, **other_params): xlabels, metrics = strings_to_lists(xlabels, metrics) @@ -431,24 +57,3 @@ def get_df_over_time(xlabels, metrics, dimensions, df_filter=None, **other_param df.rename(columns={name: xlabels[i] for i, name in enumerate(df.columns)}, inplace=True) return df - -def show_plot_over_time(titles, xlabels, metrics, dimensions="ga:date", format_table=True, df_filter=None, pre_plot_df_processor=None, **other_params): - df = get_df_over_time(xlabels, metrics, dimensions, df_filter=df_filter, **other_params) - - if (not pre_plot_df_processor is None): - df = pre_plot_df_processor(df) - - if isinstance(titles[0], str): - show_plot(df, titles[0], **other_params) - - if len(titles) > 1 and isinstance(titles[1], str): - # Average per qurter - dfmean = df.rolling(window=30).mean() - show_plot(dfmean, titles[1], **other_params) - - - if format_table: - return format_change_over_time_table(df, **other_params) - else: - return df - diff --git a/analytics/analytics_package/analytics/sheets_api.py b/analytics/analytics_package/analytics/sheets_api.py deleted file mode 100644 index 27e09b235..000000000 --- a/analytics/analytics_package/analytics/sheets_api.py +++ /dev/null @@ -1,493 +0,0 @@ -from dataclasses import dataclass -import typing -import gspread -import gspread_formatting -from enum import Enum -import pandas as pd - -FONT_SIZE_PTS = 10 -PTS_PIXELS_RATIO = 4/3 -DEFAULT_BUFFER_CHARS = 2 -GREEN_COLOR = "#00FF00" -RED_COLOR = "#FF0000" - - -class FILE_OVERRIDE_BEHAVIORS(Enum): - OVERRIDE_IF_IN_SAME_PLACE = 1 - EXIT_IF_IN_SAME_PLACE = 2 - EXIT_ANYWHERE = 3 - - -class WORKSHEET_OVERRIDE_BEHAVIORS(Enum): - OVERRIDE = 1 - EXIT = 2 - - -class COLUMN_FORMAT_OPTIONS(Enum): - DEFAULT = 1 - PERCENT_UNCOLORED = 2 - PERCENT_COLORED = 3 - YEAR_MONTH_DATE = 4 - - -class CHART_TYPES(Enum): - LINE = "LINE" - -DEFAULT_SHEET_FORMATTING_OPTIONS = { - "bold_header": True, - "center_header": True, - "freeze_header": True, - "column_widths": {"justify": True, "buffer_chars": DEFAULT_BUFFER_CHARS}, - "extra_columns": 0, - "extra_columns_width": 50, -} - -DEFAULT_GSPREAD_UPDATE_ARGS = { - "value_input_option": gspread.utils.ValueInputOption.user_entered, -} - -def extract_credentials(authentication_response): - """Extracts the credentials from the tuple from api.authenticate""" - return authentication_response[3] - -def authenticate_gspread(authentication_response): - """Authenticates the gspread client using the credentials in the tuple from api.authenticate""" - gc = gspread.authorize(extract_credentials(authentication_response)) - return gc - -def authenticate_google_api(authentication_response): - """Authenticates the Drive API using the response from api.authenticate""" - return authentication_response[0] - -def check_sheet_exists(gc, sheet_name): - """ - Determine if a sheet named 'sheet_name' exists anywhere in the project. - - :param gc: the gspread client - :param sheet_name: the name of the sheet to check for - :returns: True if the sheet exists, otherwise False - """ - try: - gc.open(sheet_name) - return True - except gspread.exceptions.SpreadsheetNotFound: - return False - -def execute_drive_list(drive_api, search_params): - """ - Execute a files().list() request on the Drive API with the given search parameters. - Returns the 'files' components of the response. - - Positional arguments: - :param drive_api: the Drive API object - :param search_params: the search parameters, see https://developers.google.com/drive/api/v3/search-files - :returns: the 'files' components of the response - """ - files_found = [] - page_token = None - while True: - request = drive_api.files().list(q=search_params, spaces="drive", pageToken=page_token) - response = request.execute() - page_token = response.get("nextPageToken", None) - files_found += response.get("files", []) - if page_token is None: - break - return files_found - -def search_for_folder_id(drive_api, folder_name, allow_trashed = False, allow_duplicates = False): - """ - Search for a folder by name in the Drive API. - Returns a list of folder ids that match the search criteria. - - :param drive_api: the Drive API object - :param folder_name: the name of the folder to search for - :param allow_trashed: whether to include trashed folders in the search, defaults to False - :param allow_duplicates: whether to allow multiple folders with the same name, defaults to False - :returns: a list of folder ids that match the search criteria - """ - search_params = f"name = '{folder_name}' and mimeType = 'application/vnd.google-apps.folder'" - if not allow_trashed: - search_params += " and trashed = false" - - files = execute_drive_list(drive_api, search_params) - files_exact_match = tuple(filter(lambda file: file["name"] == folder_name, files)) - - if len(files_exact_match) > 1: - if not allow_duplicates: - raise RuntimeError("Too many files returned") - if len(files_exact_match) == 0: - raise RuntimeError("No such folder exists") - - return [file["id"] for file in files_exact_match] - - -def create_sheet_in_folder(drive_authentication_response, sheet_name, parent_folder_name=None, override_behavior=FILE_OVERRIDE_BEHAVIORS.EXIT_ANYWHERE): - """ - Create a new sheet in the project with the given name and parent folder. - Returns the new sheet. - - :param drive_authentication_response: the service parameters tuple - :param sheet_name: the name of the new sheet - :param parent_folder_name: the name of the parent folder for the new sheet - :param override_behavior: the behavior to take if the sheet already exists - :returns: the gspread.Spreadsheet object of the new sheet - :rtype: gspread.Spreadsheet - """ - # Build Drive API - gc = authenticate_gspread(drive_authentication_response) - drive_api = authenticate_google_api(drive_authentication_response) - parent_folder_id = None if parent_folder_name is None else search_for_folder_id(drive_api, parent_folder_name)[0] - - # Check if sheet already exists and handle based on input - if check_sheet_exists(gc, sheet_name): - if override_behavior == FILE_OVERRIDE_BEHAVIORS.EXIT_ANYWHERE: - raise RuntimeError("Sheet already exists") - matching_search = f"name = '{sheet_name}' and mimeType = 'application/vnd.google-apps.spreadsheet'" - if parent_folder_id is None: - matching_search += " and 'root' in parents" - else: - matching_search += f" and '{parent_folder_id}' in parents" - matching_files = execute_drive_list(drive_api, matching_search) - - if len(matching_files) > 0: - if override_behavior == FILE_OVERRIDE_BEHAVIORS.EXIT_IF_IN_SAME_PLACE: - raise RuntimeError("File already exists in the same folder") - elif override_behavior == FILE_OVERRIDE_BEHAVIORS.OVERRIDE_IF_IN_SAME_PLACE: - for file in matching_files: - drive_api.files().delete(fileId=file["id"]).execute() - # Create file body - body = { - 'name': sheet_name, - 'mimeType': 'application/vnd.google-apps.spreadsheet', - } - if parent_folder_id is not None: - body["parents"] = [parent_folder_id] - request = drive_api.files().create(body=body) - new_sheet = request.execute() - - # Get id of fresh sheet - spread_id = new_sheet["id"] - - # Open new file - return gc.open_by_key(spread_id) - - -def fill_worksheet_with_df( - sheet, - df, - worksheet_name, - overlapBehavior, - sheet_formatting_options={}, - column_formatting_options={}, - **gspread_update_args - ): - """ - Fill a worksheet with the contents of a DataFrame. - If the worksheet already exists, the behavior is determined by overlapBehavior. - The options dictionary can be used to customize the formatting of the worksheet. - - :param sheet: the gspread.Spreadsheet object - :param df: the DataFrame to fill the worksheet with - :param worksheet_name: the name of the worksheet to fill. Cannot be "Sheet1" - :param overlapBehavior: the behavior to take if the worksheet already exists. - :param sheet_formatting_options: the formatting options for the worksheet. - Should be a dictionary with optional elements "bold_header", "center_header", "freeze_header", and "column_widths", optional - :param column_formatting_options: the column formatting options for the worksheet. - Should be a dictionary with dataframe columns as keys and instances of COLUMN_FORMAT_OPTIONS as values, optional - """ - # Sheet1 is special since it's created by default, so it's not allowed - assert worksheet_name != "Sheet1" - - # Check if worksheet already exists and handle based on overlapBehavior - try: - worksheet = sheet.worksheet(worksheet_name) - if overlapBehavior == WORKSHEET_OVERRIDE_BEHAVIORS.EXIT: - raise RuntimeError("Worksheet already exists") - except gspread.exceptions.WorksheetNotFound: - worksheet = sheet.add_worksheet( - title=worksheet_name, rows=df.shape[0], cols=df.shape[1] - ) - - sheet_formatting_options_filled = {**DEFAULT_SHEET_FORMATTING_OPTIONS, **sheet_formatting_options} - - # Add extra blank columns to the right of the worksheet - df_to_insert = pd.concat( - [df] + [pd.Series(" ", index=df.index, name="")] * sheet_formatting_options_filled["extra_columns"], - axis=1 - ) - # Add data to worksheet - worksheet.update( - [df_to_insert.columns.values.tolist()] + df_to_insert.fillna("NA").values.tolist(), - **{**DEFAULT_GSPREAD_UPDATE_ARGS, **gspread_update_args} - ) - - # Batch formatting updates to increase - with gspread_formatting.batch_updater(worksheet.spreadsheet) as batch: - # Format worksheet - # Justify Column Widths - if "column_widths" not in sheet_formatting_options_filled or sheet_formatting_options_filled["column_widths"]["justify"]: - text_widths = df.columns.map( - lambda column_name: df[column_name].astype(str).str.len().max() - ) - header_widths = df.columns.str.len() - buffer_chars = ( - DEFAULT_BUFFER_CHARS - if ("column_widths" not in sheet_formatting_options_filled or "buffer_chars" not in sheet_formatting_options_filled["column_widths"]) - else sheet_formatting_options_filled["column_widths"]["buffer_chars"] - ) - data_column_widths = [ - round((max(len_tuple) + buffer_chars) * FONT_SIZE_PTS * 1/PTS_PIXELS_RATIO) - for len_tuple in zip(text_widths, header_widths) - ] - extra_column_widths = [sheet_formatting_options_filled["extra_columns_width"]] * sheet_formatting_options_filled["extra_columns"] - combined_column_widths = data_column_widths + extra_column_widths - column_positions = [ - gspread.utils.rowcol_to_a1(1, i + 1)[0] for i, _ in enumerate(combined_column_widths) - ] - batch.set_column_widths(worksheet, zip(column_positions, combined_column_widths)) - # Freeze Header - if "freeze_header" not in sheet_formatting_options_filled or sheet_formatting_options_filled["freeze_header"]: - batch.set_frozen(worksheet, rows=1) - base_format_options = gspread_formatting.CellFormat() - # Bold Header - if "bold_header" not in sheet_formatting_options_filled or sheet_formatting_options_filled["bold_header"]: - base_format_options += gspread_formatting.CellFormat(textFormat=gspread_formatting.TextFormat(bold=True)) - # Center Header - if "center_header" not in sheet_formatting_options_filled or sheet_formatting_options_filled["center_header"]: - base_format_options += gspread_formatting.CellFormat(horizontalAlignment="CENTER") - # Handle column specific formatting - for column in column_formatting_options: - if column not in df.columns: - raise KeyError("Formatting column is not in the dataframe") - # Skip if the column is set to default - if column_formatting_options[column] == COLUMN_FORMAT_OPTIONS.DEFAULT: - continue - # Get the column position - column_position_numeric = df.columns.get_loc(column) + 1 - column_range_top = gspread.utils.rowcol_to_a1(1, column_position_numeric) - column_range_bottom = gspread.utils.rowcol_to_a1(df.index.size + 1, column_position_numeric) - column_range = f"{column_range_top}:{column_range_bottom}" - column_worksheet_range = gspread_formatting.GridRange.from_a1_range(column_range, worksheet) - # Get conditional formatting rules - if column_formatting_options[column] == COLUMN_FORMAT_OPTIONS.PERCENT_COLORED: - green_rule = gspread_formatting.ConditionalFormatRule( - ranges=[column_worksheet_range], - booleanRule=gspread_formatting.BooleanRule( - condition=gspread_formatting.BooleanCondition('NUMBER_GREATER_THAN_EQ', ['0']), - format=gspread_formatting.CellFormat( - textFormat=gspread_formatting.TextFormat(foregroundColor=gspread_formatting.Color(0,1,0))) - ) - ) - red_rule = gspread_formatting.ConditionalFormatRule( - ranges=[column_worksheet_range], - booleanRule=gspread_formatting.BooleanRule( - condition=gspread_formatting.BooleanCondition('NUMBER_LESS_THAN_EQ', ['0']), - format=gspread_formatting.CellFormat( - textFormat=gspread_formatting.TextFormat(foregroundColor=gspread_formatting.Color(1,0,0))) - ) - ) - # Apply conditional formatting rules - conditional_formatting_rules = gspread_formatting.get_conditional_format_rules(worksheet) - conditional_formatting_rules.append(green_rule) - conditional_formatting_rules.append(red_rule) - conditional_formatting_rules.save() - if column_formatting_options[column] in (COLUMN_FORMAT_OPTIONS.PERCENT_COLORED, COLUMN_FORMAT_OPTIONS.PERCENT_UNCOLORED): - # Apply percent format rule - gspread_formatting.format_cell_range( - worksheet, - column_range, - gspread_formatting.CellFormat(numberFormat=gspread_formatting.NumberFormat(type='PERCENT', pattern='0.0%')) - ) - if column_formatting_options[column] == COLUMN_FORMAT_OPTIONS.YEAR_MONTH_DATE: - # Apply date format rule - gspread_formatting.format_cell_range( - worksheet, - column_range, - gspread_formatting.CellFormat(numberFormat=gspread_formatting.NumberFormat(type='DATE', pattern='yyyy-mm')) - ) - - # Apply base formatting options - batch.format_cell_range( - worksheet, - f"A1:{gspread.utils.rowcol_to_a1(1, len(df.columns))}", - base_format_options - ) - - # Delete Sheet1 if it has been created by default - if "Sheet1" in [i.title for i in sheet.worksheets()]: - sheet.del_worksheet(sheet.worksheet("Sheet1")) - -def fill_spreadsheet_with_df_dict(sheet, df_dict, overlapBehavior, sheet_formatting_options={}, column_formatting_options={}, gspread_update_args={}): - """ - Fill a sheet with the contents of a dictionary of DataFrames. - The keys of the dictionary are the names of the worksheets, and the values contain the data to be placed in the sheet. - If any worksheets would be overidden, the behavior is determined by overlapBehavior. - - :param sheet: the gspread.Spreadsheet object - :param df_dict: the dictionary of DataFrames to fill the worksheets with - :param overlapBehavior: the behavior to take if any of the worksheets already exist - :param sheet_formatting_options: the formatting options for the worksheets. - Should be a 2 level dictionary with outer keys being names of worksheets and inner keys being some of - "bold_header", "center_header", "freeze_header", and "column_widths", optional - :param column_formatting_options: the column formatting options for the worksheets. - Should be a 2 level dictionary with outer keys being names of worksheets and inner keys being column names. - The inner keys should be an instance of COLUMN_FORMATTING_OPTIONS, optional - """ - if overlapBehavior == WORKSHEET_OVERRIDE_BEHAVIORS.EXIT: - for worksheet_name in df_dict.keys(): - try: - sheet.worksheet(worksheet_name) - raise RuntimeError("Worksheet already exists") - except gspread.exceptions.WorksheetNotFound: - pass - for worksheet_name, df in df_dict.items(): - fill_worksheet_with_df( - sheet, df, worksheet_name, overlapBehavior, - sheet_formatting_options=sheet_formatting_options.get(worksheet_name, {}), - column_formatting_options=column_formatting_options.get(worksheet_name, {}), - **gspread_update_args.get(worksheet_name, {}) - ) - -def update_sheet_raw(sheets_authentication_response, sheet, *updates): - """ - Directly call the Google Sheets api to update the specified sheet with the optional arguments. - """ - # TODO: gspread.Spreadsheet.batch_update can also do this - assert len(updates) > 0 - sheets_api = authenticate_google_api(sheets_authentication_response) - sheet_id = sheet.id - body = {"requests": list(updates)} - response = ( - sheets_api.spreadsheets() - .batchUpdate(spreadsheetId=sheet_id, body=body) - .execute() - ) - return response - -REQUIRED_CHART_ARGS = [] - -DEFAULT_CHART_ARGS = { - "title": "", - "x_axis_title": "", - "y_axis_title": "", - "invert_x_axis": False, - "chart_position": None, # None means it will be created in a new sheet - "chart_position_offset_x": 0, - "chart_position_offset_y": 0, - "chart_width": 600, - "chart_height": 371, -} - -@dataclass -class WorksheetRange: - """ - A dataclass to represent a range of cells in a worksheet in the one-sided interval [top_left, bottom_right). - :param worksheet: the gspread.worksheet.Worksheet object - :param top_left: the top left cell of the range. This cell will be included in the range - :param bottom_right: the bottom right cell of the range. This cell will not be included in the range - """ - worksheet: gspread.worksheet.Worksheet - top_left: gspread.cell.Cell - bottom_right: gspread.cell.Cell - - @property - def range_dict(self): - """The range as a dictionary for the sources field in the Google Sheets api""" - return { - "sheetId": self.worksheet.id, - "startRowIndex": self.top_left.row - 1, - "endRowIndex": self.bottom_right.row - 1, - "startColumnIndex": self.top_left.col - 1, - "endColumnIndex": self.bottom_right.col - 1, - } - -def _cell_to_grid_coordinate(cell, worksheet): - return { - "sheetId": worksheet.id, - "rowIndex": cell.row - 1, - "columnIndex": cell.col - 1, - } - -def add_chart_to_sheet(sheets_authentication_response, sheet, worksheet, chart_type, domain, series, **chart_args): - """ - Add a chart to a specified workshet - :param sheets_authentication_response: the response from ga.authenticate. Must be for the sheets api v4 - :param sheet: the gspread.Spreadsheet object - :param worksheet: the gspread.Worksheet object - :param chart_type: the type of chart to add - :param domain: the domain of the chart as a WorksheetRange. Must contain either one row or one column - :param series: the series of the chart as a WorksheetRange. Must contain either one row or one column - :param chart_args: other arguments to create the chart. See DEFAULT_CHART_ARGS - """ - complete_chart_args = {**DEFAULT_CHART_ARGS, **chart_args} - if complete_chart_args["chart_position"] is not None: - position_dict = { - "overlayPosition": { - "anchorCell": _cell_to_grid_coordinate(complete_chart_args["chart_position"], worksheet), - "offsetXPixels": complete_chart_args["chart_position_offset_x"], - "offsetYPixels": complete_chart_args["chart_position_offset_y"], - "widthPixels": complete_chart_args["chart_width"], - "heightPixels": complete_chart_args["chart_height"], - } - } - else: - position_dict = {"newSheet": True} - formatted_domains = [ - { - "domain": { - "sourceRange": { - "sources": [ - domain.range_dict - ], - }, - }, - "reversed": complete_chart_args["invert_x_axis"], - }, - ] - - formatted_series = [ - { - "series": { - "sourceRange": { - "sources": [ - series_source.range_dict - ], - }, - }, - "targetAxis": "LEFT_AXIS", - } - for series_source in series - ] - formatted_axis = [] - if complete_chart_args["x_axis_title"]: - formatted_axis.append({ - "title": complete_chart_args["x_axis_title"], - "position": "BOTTOM_AXIS", - }) - if complete_chart_args["y_axis_title"]: - formatted_axis.append({ - "title": complete_chart_args["y_axis_title"], - "position": "LEFT_AXIS", - }) - request = { - "addChart": { - "chart": { - "spec": { - "title": complete_chart_args["title"], - #TODO: insert legend position - #TODO: insert axis positions - "basicChart": { - "axis": formatted_axis, - "chartType": chart_type.value, - "domains": formatted_domains, - "headerCount": 1, #TODO: not sure what this means - "series": formatted_series, - }, - }, - "position": position_dict - }, - }, - } - - response = update_sheet_raw(sheets_authentication_response, sheet, request) - return response diff --git a/analytics/analytics_package/analytics/sheets_elements.py b/analytics/analytics_package/analytics/sheets_elements.py index d54576c60..bc628eca3 100644 --- a/analytics/analytics_package/analytics/sheets_elements.py +++ b/analytics/analytics_package/analytics/sheets_elements.py @@ -2,54 +2,8 @@ from ._sheets_utils import * from .entities import * -from .sheets_api import COLUMN_FORMAT_OPTIONS from urllib.parse import urlparse -OUTBOUND_LINKS_CHANGE_FORMATTING = { - SYNTHETIC_METRIC_CLICKS["change_alias"]: COLUMN_FORMAT_OPTIONS.PERCENT_COLORED, - METRIC_TOTAL_USERS["change_alias"]: COLUMN_FORMAT_OPTIONS.PERCENT_COLORED, -} - -PAGE_VIEWS_CHANGE_FORMATTING = { - METRIC_PAGE_VIEWS["change_alias"]: COLUMN_FORMAT_OPTIONS.PERCENT_COLORED, - METRIC_TOTAL_USERS["change_alias"]: COLUMN_FORMAT_OPTIONS.PERCENT_COLORED, -} - -PAGE_VIEWS_OVER_TIME_FORMATTING = { - DIMENSION_YEAR_MONTH["alias"]: COLUMN_FORMAT_OPTIONS.YEAR_MONTH_DATE, - METRIC_ACTIVE_USERS["change_alias"]: COLUMN_FORMAT_OPTIONS.PERCENT_COLORED, - METRIC_PAGE_VIEWS["change_alias"]: COLUMN_FORMAT_OPTIONS.PERCENT_COLORED, -} - -LANDING_PAGE_CHANGE_FORMATTING = { - METRIC_SESSIONS["change_alias"]: COLUMN_FORMAT_OPTIONS.PERCENT_COLORED, -} - -INDEX_TABLE_DOWNLOAD_CHANGE_FORMATTING = { - METRIC_EVENT_COUNT["change_alias"]: COLUMN_FORMAT_OPTIONS.PERCENT_COLORED, - METRIC_TOTAL_USERS["change_alias"]: COLUMN_FORMAT_OPTIONS.PERCENT_COLORED, -} - -INDEX_ENTITY_SELECTED_CHANGE_FORMATTING = { - METRIC_EVENT_COUNT["change_alias"]: COLUMN_FORMAT_OPTIONS.PERCENT_COLORED, - METRIC_TOTAL_USERS["change_alias"]: COLUMN_FORMAT_OPTIONS.PERCENT_COLORED, -} - -INDEX_ENTITY_TABLE_SORTED_CHANGE_FORMATTING = { - METRIC_EVENT_COUNT["change_alias"]: COLUMN_FORMAT_OPTIONS.PERCENT_COLORED, - METRIC_TOTAL_USERS["change_alias"]: COLUMN_FORMAT_OPTIONS.PERCENT_COLORED, -} - -INDEX_ENTITY_TABLE_PAGINATED_CHANGE_FORMATTING = { - METRIC_EVENT_COUNT["change_alias"]: COLUMN_FORMAT_OPTIONS.PERCENT_COLORED, - METRIC_TOTAL_USERS["change_alias"]: COLUMN_FORMAT_OPTIONS.PERCENT_COLORED, -} - -INDEX_FILTER_SELECTED_CHANGE_FORMATTING = { - METRIC_EVENT_COUNT["change_alias"]: COLUMN_FORMAT_OPTIONS.PERCENT_COLORED, - METRIC_TOTAL_USERS["change_alias"]: COLUMN_FORMAT_OPTIONS.PERCENT_COLORED, -} - def get_bounds_for_month_and_prev(month): """ Get dates of the first and last day of the given month and the month preceding it. diff --git a/analytics/analytics_package/setup.py b/analytics/analytics_package/setup.py index e28f72314..e84f5fd9a 100644 --- a/analytics/analytics_package/setup.py +++ b/analytics/analytics_package/setup.py @@ -5,5 +5,11 @@ version="4.3.0", packages=["analytics", "analytics.static_site"], package_data={"analytics.static_site": ["template/*.html"]}, - install_requires=["matplotlib", "pandas", "numpy", "google-auth-oauthlib", "google-api-python-client", "gspread", "gspread-formatting", "requests"], -) \ No newline at end of file + install_requires=[ + "pandas", + "numpy", + "google-auth-oauthlib", + "google-api-python-client", + "requests", + ], +) From a987c471b18a5efbaaadda80236487d52903d86c Mon Sep 17 00:00:00 2001 From: hunterckx <118154470+hunterckx@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:04:05 -0700 Subject: [PATCH 02/11] refactor: rename and consolidate modules related to legacy analytics reports (#4912) --- .../{_sheets_utils.py => _report_utils.py} | 50 +++++++++++++++- .../analytics_package/analytics/charts.py | 59 ------------------- ...{sheets_elements.py => report_elements.py} | 2 +- .../analytics/static_site/fetch.py | 4 +- 4 files changed, 52 insertions(+), 63 deletions(-) rename analytics/analytics_package/analytics/{_sheets_utils.py => _report_utils.py} (81%) delete mode 100644 analytics/analytics_package/analytics/charts.py rename analytics/analytics_package/analytics/{sheets_elements.py => report_elements.py} (99%) diff --git a/analytics/analytics_package/analytics/_sheets_utils.py b/analytics/analytics_package/analytics/_report_utils.py similarity index 81% rename from analytics/analytics_package/analytics/_sheets_utils.py rename to analytics/analytics_package/analytics/_report_utils.py index 5e82062e7..33ce20335 100644 --- a/analytics/analytics_package/analytics/_sheets_utils.py +++ b/analytics/analytics_package/analytics/_report_utils.py @@ -1,9 +1,57 @@ import datetime as dt -from .charts import get_data_df, get_df_over_time +from . import api as ga from .entities import ADDITIONAL_DATA_BEHAVIOR import numpy as np import pandas as pd + +def get_data_df(metrics, dimensions, percentage_metrics=None, percentage_suffix="_percentage", num_keep_dimensions=None, df_processor=None, **other_params): + if metrics is None: + df = pd.DataFrame() + else: + df = ga.get_metrics_by_dimensions(metrics, dimensions, **other_params) + + if dimensions: + if len(dimensions) > 1 and not num_keep_dimensions is None: + df.drop(columns=dimensions[num_keep_dimensions:], inplace=True) + df.set_index(dimensions[:num_keep_dimensions], inplace=True) + for metric in metrics: + str_column = df[metric].astype(str) + try: + num_column = str_column.astype(int) + except ValueError: + num_column = str_column.astype(float) + df[metric] = num_column + + if percentage_metrics: + for metric in percentage_metrics: + df.insert(list(df.columns).index(metric) + 1, metric + percentage_suffix, df[metric] / df[metric].sum() * 100) + + if df_processor: + df = df_processor(df) + + return df + +def strings_to_lists(*vals): + return [[v] if isinstance(v, str) else v for v in vals] + +def get_df_over_time(xlabels, metrics, dimensions, df_filter=None, **other_params): + xlabels, metrics = strings_to_lists(xlabels, metrics) + + df = get_data_df(metrics, dimensions, **other_params) + + # Convert date to datetime object + df.index = pd.to_datetime(df.index) + + if (not df_filter is None): + df = df_filter(df) + + # Rename for display + df.rename(columns={name: xlabels[i] for i, name in enumerate(df.columns)}, inplace=True) + + return df + + def get_data_df_from_fields(metrics, dimensions, **other_params): """ Get a df from the Analytics API with metrics and dimensions as specified in fields.py diff --git a/analytics/analytics_package/analytics/charts.py b/analytics/analytics_package/analytics/charts.py deleted file mode 100644 index b69a08e19..000000000 --- a/analytics/analytics_package/analytics/charts.py +++ /dev/null @@ -1,59 +0,0 @@ -from . import api as ga -import pandas as pd - -""" - -Functions related to tables and data frames will pass any extra parameters they recieve down to any such functions that they call -These parameters include the ones used by get_metrics_by_dimensions in the api module - -Table formatting/construction parameters: -df_processor -num_keep_dimensions - -""" - -def get_data_df(metrics, dimensions, percentage_metrics=None, percentage_suffix="_percentage", num_keep_dimensions=None, df_processor=None, **other_params): - if metrics is None: - df = pd.DataFrame() - else: - df = ga.get_metrics_by_dimensions(metrics, dimensions, **other_params) - - if dimensions: - if len(dimensions) > 1 and not num_keep_dimensions is None: - df.drop(columns=dimensions[num_keep_dimensions:], inplace=True) - df.set_index(dimensions[:num_keep_dimensions], inplace=True) - for metric in metrics: - str_column = df[metric].astype(str) - try: - num_column = str_column.astype(int) - except ValueError: - num_column = str_column.astype(float) - df[metric] = num_column - - if percentage_metrics: - for metric in percentage_metrics: - df.insert(list(df.columns).index(metric) + 1, metric + percentage_suffix, df[metric] / df[metric].sum() * 100) - - if df_processor: - df = df_processor(df) - - return df - -def strings_to_lists(*vals): - return [[v] if isinstance(v, str) else v for v in vals] - -def get_df_over_time(xlabels, metrics, dimensions, df_filter=None, **other_params): - xlabels, metrics = strings_to_lists(xlabels, metrics) - - df = get_data_df(metrics, dimensions, **other_params) - - # Convert date to datetime object - df.index = pd.to_datetime(df.index) - - if (not df_filter is None): - df = df_filter(df) - - # Rename for display - df.rename(columns={name: xlabels[i] for i, name in enumerate(df.columns)}, inplace=True) - - return df diff --git a/analytics/analytics_package/analytics/sheets_elements.py b/analytics/analytics_package/analytics/report_elements.py similarity index 99% rename from analytics/analytics_package/analytics/sheets_elements.py rename to analytics/analytics_package/analytics/report_elements.py index bc628eca3..a77f5a030 100644 --- a/analytics/analytics_package/analytics/sheets_elements.py +++ b/analytics/analytics_package/analytics/report_elements.py @@ -1,6 +1,6 @@ import pandas as pd -from ._sheets_utils import * +from ._report_utils import * from .entities import * from urllib.parse import urlparse diff --git a/analytics/analytics_package/analytics/static_site/fetch.py b/analytics/analytics_package/analytics/static_site/fetch.py index 57175e22b..d6f4bb6cb 100644 --- a/analytics/analytics_package/analytics/static_site/fetch.py +++ b/analytics/analytics_package/analytics/static_site/fetch.py @@ -4,9 +4,9 @@ from datetime import date from urllib.parse import urlparse, parse_qs -from .. import sheets_elements as elements +from .. import report_elements as elements from ..api import parse_filter_expressions -from .._sheets_utils import get_data_df_from_fields +from .._report_utils import get_data_df_from_fields from ..entities import ( DIMENSION_YEAR_MONTH, METRIC_EVENT_COUNT, From 2d5db05a52d367f922ed18cc5c6b874358835ecb Mon Sep 17 00:00:00 2001 From: hunterckx <118154470+hunterckx@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:04:56 -0700 Subject: [PATCH 03/11] chore: update analytics package version (#4912) --- analytics/analytics_package/setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/analytics/analytics_package/setup.py b/analytics/analytics_package/setup.py index e84f5fd9a..787d98c1c 100644 --- a/analytics/analytics_package/setup.py +++ b/analytics/analytics_package/setup.py @@ -2,7 +2,7 @@ setup( name="analytics", - version="4.3.0", + version="5.0.0", packages=["analytics", "analytics.static_site"], package_data={"analytics.static_site": ["template/*.html"]}, install_requires=[ From d6a4201b3bd84848705a80cc03fc79eb8a00987e Mon Sep 17 00:00:00 2001 From: hunterckx <118154470+hunterckx@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:10:51 -0700 Subject: [PATCH 04/11] chore: update analytics requirements (#4912) --- analytics/requirements.txt | 195 +++++-------------------------------- 1 file changed, 25 insertions(+), 170 deletions(-) diff --git a/analytics/requirements.txt b/analytics/requirements.txt index 2aac476dc..01f61dc35 100644 --- a/analytics/requirements.txt +++ b/analytics/requirements.txt @@ -1,173 +1,28 @@ -accessible-pygments==0.0.5 -alabaster==0.7.16 -e ./analytics_package -anyio==4.7.0 -appdirs==1.4.4 -appnope==0.1.4 -argon2-cffi==23.1.0 -argon2-cffi-bindings==21.2.0 -arrow==1.3.0 -asttokens==2.4.1 -async-lru==2.0.4 -attrs==24.2.0 -babel==2.16.0 -beautifulsoup4==4.12.3 -bleach==6.2.0 -cachetools==5.5.0 -certifi==2024.8.30 -cffi==1.17.1 -charset-normalizer==3.3.2 -click==8.1.7 -comm==0.2.2 -contourpy==1.3.0 -cycler==0.12.1 -debugpy==1.8.5 -decorator==5.1.1 -defusedxml==0.7.1 -docutils==0.20.1 -executing==2.1.0 -fastjsonschema==2.20.0 -fonttools==4.53.1 -fqdn==1.5.1 -google-api-core==2.19.2 -google-api-python-client==2.146.0 -google-auth==2.34.0 -google-auth-httplib2==0.2.0 -google-auth-oauthlib==1.2.1 -googleapis-common-protos==1.65.0 -gspread==6.1.4 -gspread-formatting==1.2.0 -h11==0.14.0 -httpcore==1.0.7 -httplib2==0.22.0 -httpx==0.28.1 -idna==3.10 -imagesize==1.4.1 -importlib_metadata==8.5.0 -ipykernel==6.29.5 -ipython==8.27.0 -ipywidgets==8.1.5 -isoduration==20.11.0 -jedi==0.19.1 -Jinja2==3.1.4 -json5==0.10.0 -jsonpointer==3.0.0 -jsonschema==4.23.0 -jsonschema-specifications==2023.12.1 -jupyter==1.1.1 -jupyter-book==1.0.2 -jupyter-cache==1.0.0 -jupyter-console==6.6.3 -jupyter-events==0.10.0 -jupyter-lsp==2.2.5 -jupyter_client==8.6.3 -jupyter_core==5.7.2 -jupyter_server==2.14.2 -jupyter_server_terminals==0.5.3 -jupyterlab==4.3.3 -jupyterlab_pygments==0.3.0 -jupyterlab_server==2.27.3 -jupyterlab_widgets==3.0.13 -kiwisolver==1.4.7 -latexcodec==3.0.0 -linkify-it-py==2.0.3 -markdown-it-py==3.0.0 -MarkupSafe==2.1.5 -matplotlib==3.9.2 -matplotlib-inline==0.1.7 -mdit-py-plugins==0.4.2 -mdurl==0.1.2 -mistune==3.0.2 -myst-nb==1.1.1 -myst-parser==2.0.0 -nbclient==0.10.0 -nbconvert==7.16.4 -nbformat==5.10.4 -nest-asyncio==1.6.0 -notebook==7.3.1 -notebook_shim==0.2.4 -numpy==2.1.1 -oauthlib==3.2.2 -overrides==7.7.0 -packaging==24.1 -pandas==2.2.2 -pandocfilters==1.5.1 -parso==0.8.4 -pexpect==4.9.0 -pillow==10.4.0 -platformdirs==4.3.6 -prometheus_client==0.21.1 -prompt_toolkit==3.0.47 -proto-plus==1.24.0 -protobuf==5.28.1 -psutil==6.0.0 -ptyprocess==0.7.0 -pure_eval==0.2.3 -pyasn1==0.6.1 -pyasn1_modules==0.4.1 -pybtex==0.24.0 -pybtex-docutils==1.0.3 -pycparser==2.22 -pydata-sphinx-theme==0.15.4 -pyee==11.1.1 -Pygments==2.18.0 -pyparsing==3.1.4 -pyppeteer==2.0.0 +certifi==2026.7.22 +cffi==2.1.1 +charset-normalizer==3.5.0 +cryptography==50.0.0 +google-api-core==2.34.0 +google-api-python-client==2.198.0 +google-auth==2.56.3 +google-auth-httplib2==0.4.1 +google-auth-oauthlib==1.4.0 +googleapis-common-protos==1.75.1 +httplib2==0.32.0 +idna==3.18 +numpy==2.5.2 +oauthlib==3.3.1 +pandas==3.0.5 +proto-plus==1.28.3 +protobuf==7.35.1 +pyasn1==0.6.4 +pyasn1_modules==0.4.2 +pycparser==3.0 +pyparsing==3.3.2 python-dateutil==2.9.0.post0 -python-json-logger==3.2.1 -pytz==2024.2 -PyYAML==6.0.2 -pyzmq==26.2.0 -referencing==0.35.1 -requests==2.32.3 +requests==2.34.2 requests-oauthlib==2.0.0 -rfc3339-validator==0.1.4 -rfc3986-validator==0.1.1 -rpds-py==0.20.0 -rsa==4.9 -Send2Trash==1.8.3 -setuptools==75.1.0 -six==1.16.0 -sniffio==1.3.1 -snowballstemmer==2.2.0 -soupsieve==2.6 -Sphinx==7.4.7 -sphinx-book-theme==1.1.3 -sphinx-comments==0.0.3 -sphinx-copybutton==0.5.2 -sphinx-jupyterbook-latex==1.0.0 -sphinx-multitoc-numbering==0.1.3 -sphinx-thebe==0.3.1 -sphinx-togglebutton==0.3.2 -sphinx_design==0.6.1 -sphinx_external_toc==1.0.1 -sphinxcontrib-applehelp==2.0.0 -sphinxcontrib-bibtex==2.6.3 -sphinxcontrib-devhelp==2.0.0 -sphinxcontrib-htmlhelp==2.1.0 -sphinxcontrib-jsmath==1.0.1 -sphinxcontrib-qthelp==2.0.0 -sphinxcontrib-serializinghtml==2.0.0 -SQLAlchemy==2.0.35 -stack-data==0.6.3 -tabulate==0.9.0 -terminado==0.18.1 -tinycss2==1.4.0 -tornado==6.4.1 -tqdm==4.66.5 -traitlets==5.14.3 -types-python-dateutil==2.9.0.20241206 -typing_extensions==4.12.2 -tzdata==2024.1 -uc-micro-py==1.0.3 -uri-template==1.3.0 -uritemplate==4.1.1 -urllib3==1.26.20 -wcwidth==0.2.13 -webcolors==24.11.1 -webencodings==0.5.1 -websocket-client==1.8.0 -websockets==10.4 -wheel==0.44.0 -widgetsnbextension==4.0.13 -zipp==3.20.2 +six==1.17.0 +uritemplate==4.2.0 +urllib3==2.7.0 From 5a6bb0b5cbb52fa16212354d72888f4842c6bd1b Mon Sep 17 00:00:00 2001 From: hunterckx <118154470+hunterckx@users.noreply.github.com> Date: Fri, 14 Aug 2026 16:33:41 -0700 Subject: [PATCH 05/11] fix: update dtype check to account for pandas 3 string handling (#4912) --- analytics/analytics_package/analytics/static_site/export.py | 2 +- analytics/analytics_package/setup.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/analytics/analytics_package/analytics/static_site/export.py b/analytics/analytics_package/analytics/static_site/export.py index 78f8a605e..f81e2403d 100644 --- a/analytics/analytics_package/analytics/static_site/export.py +++ b/analytics/analytics_package/analytics/static_site/export.py @@ -42,7 +42,7 @@ def export_df_as_json(df, col_map, change_col, filename, output_dir): export.columns = output_names for col in output_names: - if col != "change" and export[col].dtype != object: + if col != "change" and export[col].dtype not in (object, "str"): export[col] = export[col].fillna(0).astype(int) records = export.to_dict(orient="records") diff --git a/analytics/analytics_package/setup.py b/analytics/analytics_package/setup.py index 787d98c1c..b12bff8da 100644 --- a/analytics/analytics_package/setup.py +++ b/analytics/analytics_package/setup.py @@ -2,7 +2,7 @@ setup( name="analytics", - version="5.0.0", + version="5.0.1", packages=["analytics", "analytics.static_site"], package_data={"analytics.static_site": ["template/*.html"]}, install_requires=[ From 7258161da65a556517666819892e5d0b164580f5 Mon Sep 17 00:00:00 2001 From: hunterckx <118154470+hunterckx@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:11:39 -0700 Subject: [PATCH 06/11] fix: use functions provided by pandas to more reliable check dtype (#4912) --- analytics/analytics_package/analytics/static_site/export.py | 4 +++- analytics/analytics_package/setup.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/analytics/analytics_package/analytics/static_site/export.py b/analytics/analytics_package/analytics/static_site/export.py index f81e2403d..3920743fa 100644 --- a/analytics/analytics_package/analytics/static_site/export.py +++ b/analytics/analytics_package/analytics/static_site/export.py @@ -6,6 +6,7 @@ from datetime import datetime import pandas as pd +from pandas.api.types import is_object_dtype, is_string_dtype from ..entities import ( DIMENSION_PAGE_PATH, @@ -42,7 +43,8 @@ def export_df_as_json(df, col_map, change_col, filename, output_dir): export.columns = output_names for col in output_names: - if col != "change" and export[col].dtype not in (object, "str"): + col_dtype = export[col].dtype + if not (col == "change" or is_object_dtype(col_dtype) or is_string_dtype(col_dtype)): export[col] = export[col].fillna(0).astype(int) records = export.to_dict(orient="records") diff --git a/analytics/analytics_package/setup.py b/analytics/analytics_package/setup.py index b12bff8da..7c8bb51c6 100644 --- a/analytics/analytics_package/setup.py +++ b/analytics/analytics_package/setup.py @@ -2,7 +2,7 @@ setup( name="analytics", - version="5.0.1", + version="5.0.2", packages=["analytics", "analytics.static_site"], package_data={"analytics.static_site": ["template/*.html"]}, install_requires=[ From b02ba98f0ed52df2a287e9199afd26351cdeefa8 Mon Sep 17 00:00:00 2001 From: hunterckx <118154470+hunterckx@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:15:13 -0700 Subject: [PATCH 07/11] fix: normalize `setup.py` indentation to four spaces (#4912) --- analytics/analytics_package/setup.py | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/analytics/analytics_package/setup.py b/analytics/analytics_package/setup.py index 7c8bb51c6..747953f74 100644 --- a/analytics/analytics_package/setup.py +++ b/analytics/analytics_package/setup.py @@ -1,15 +1,15 @@ from setuptools import setup setup( - name="analytics", - version="5.0.2", - packages=["analytics", "analytics.static_site"], - package_data={"analytics.static_site": ["template/*.html"]}, - install_requires=[ - "pandas", - "numpy", - "google-auth-oauthlib", - "google-api-python-client", - "requests", - ], + name="analytics", + version="5.0.2", + packages=["analytics", "analytics.static_site"], + package_data={"analytics.static_site": ["template/*.html"]}, + install_requires=[ + "pandas", + "numpy", + "google-auth-oauthlib", + "google-api-python-client", + "requests", + ], ) From 125fae8754e51194a15762245b68b3b2af8e0e77 Mon Sep 17 00:00:00 2001 From: hunterckx <118154470+hunterckx@users.noreply.github.com> Date: Fri, 14 Aug 2026 18:31:17 -0700 Subject: [PATCH 08/11] fix: use consistent indentation character in `_report_utils.py` (#4912) --- .../analytics/_report_utils.py | 74 +++++++++---------- 1 file changed, 37 insertions(+), 37 deletions(-) diff --git a/analytics/analytics_package/analytics/_report_utils.py b/analytics/analytics_package/analytics/_report_utils.py index 33ce20335..990ba0143 100644 --- a/analytics/analytics_package/analytics/_report_utils.py +++ b/analytics/analytics_package/analytics/_report_utils.py @@ -6,50 +6,50 @@ def get_data_df(metrics, dimensions, percentage_metrics=None, percentage_suffix="_percentage", num_keep_dimensions=None, df_processor=None, **other_params): - if metrics is None: - df = pd.DataFrame() - else: - df = ga.get_metrics_by_dimensions(metrics, dimensions, **other_params) - - if dimensions: - if len(dimensions) > 1 and not num_keep_dimensions is None: - df.drop(columns=dimensions[num_keep_dimensions:], inplace=True) - df.set_index(dimensions[:num_keep_dimensions], inplace=True) - for metric in metrics: - str_column = df[metric].astype(str) - try: - num_column = str_column.astype(int) - except ValueError: - num_column = str_column.astype(float) - df[metric] = num_column - - if percentage_metrics: - for metric in percentage_metrics: - df.insert(list(df.columns).index(metric) + 1, metric + percentage_suffix, df[metric] / df[metric].sum() * 100) - - if df_processor: - df = df_processor(df) - - return df + if metrics is None: + df = pd.DataFrame() + else: + df = ga.get_metrics_by_dimensions(metrics, dimensions, **other_params) + + if dimensions: + if len(dimensions) > 1 and not num_keep_dimensions is None: + df.drop(columns=dimensions[num_keep_dimensions:], inplace=True) + df.set_index(dimensions[:num_keep_dimensions], inplace=True) + for metric in metrics: + str_column = df[metric].astype(str) + try: + num_column = str_column.astype(int) + except ValueError: + num_column = str_column.astype(float) + df[metric] = num_column + + if percentage_metrics: + for metric in percentage_metrics: + df.insert(list(df.columns).index(metric) + 1, metric + percentage_suffix, df[metric] / df[metric].sum() * 100) + + if df_processor: + df = df_processor(df) + + return df def strings_to_lists(*vals): - return [[v] if isinstance(v, str) else v for v in vals] + return [[v] if isinstance(v, str) else v for v in vals] def get_df_over_time(xlabels, metrics, dimensions, df_filter=None, **other_params): - xlabels, metrics = strings_to_lists(xlabels, metrics) - - df = get_data_df(metrics, dimensions, **other_params) - - # Convert date to datetime object - df.index = pd.to_datetime(df.index) + xlabels, metrics = strings_to_lists(xlabels, metrics) + + df = get_data_df(metrics, dimensions, **other_params) + + # Convert date to datetime object + df.index = pd.to_datetime(df.index) - if (not df_filter is None): - df = df_filter(df) + if (not df_filter is None): + df = df_filter(df) - # Rename for display - df.rename(columns={name: xlabels[i] for i, name in enumerate(df.columns)}, inplace=True) + # Rename for display + df.rename(columns={name: xlabels[i] for i, name in enumerate(df.columns)}, inplace=True) - return df + return df def get_data_df_from_fields(metrics, dimensions, **other_params): From 24362015fdf9194318176bb26ee525361c1efe8e Mon Sep 17 00:00:00 2001 From: hunterckx <118154470+hunterckx@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:26:38 -0700 Subject: [PATCH 09/11] fix: remove unused `format_table` argument (#4912) --- analytics/analytics_package/analytics/_report_utils.py | 1 - 1 file changed, 1 deletion(-) diff --git a/analytics/analytics_package/analytics/_report_utils.py b/analytics/analytics_package/analytics/_report_utils.py index 990ba0143..edb31987e 100644 --- a/analytics/analytics_package/analytics/_report_utils.py +++ b/analytics/analytics_package/analytics/_report_utils.py @@ -125,7 +125,6 @@ def get_change_over_time_df( time_dimension["id"], sort_results=[time_dimension["id"]], df_processor=(lambda df: df.set_index(df.index + "01").sort_index(ascending=False)), - format_table=False, **other_params ).rename({time_dimension["id"]: time_dimension["alias"]}) From 2940ad325e2a749a238d6ef0b840c34070c52e87 Mon Sep 17 00:00:00 2001 From: hunterckx <118154470+hunterckx@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:31:19 -0700 Subject: [PATCH 10/11] fix: remove deprecated pandas rule (#4912) --- analytics/analytics_package/analytics/report_elements.py | 1 - 1 file changed, 1 deletion(-) diff --git a/analytics/analytics_package/analytics/report_elements.py b/analytics/analytics_package/analytics/report_elements.py index a77f5a030..554d5874d 100644 --- a/analytics/analytics_package/analytics/report_elements.py +++ b/analytics/analytics_package/analytics/report_elements.py @@ -33,7 +33,6 @@ def get_outbound_links_df(analytics_params, ignore_index=True): Dimensions: DIMENSION_PAGE_PATH, SYNTHETIC_DIMENSION_CLICKED_HOSTNAME, SYNTHETIC_DIMENSION_CLICKED_LINK Metrics: SYNTHETIC_METRIC_CLICKS, METRIC_TOTAL_USERS """ - pd.set_option('future.no_silent_downcasting', True) assert "dimension_filter" not in analytics_params # Get the builtin "Click" event df_builtin_links =get_data_df_from_fields( From b6c27004780da98d5b18854d32f65506aa7da786 Mon Sep 17 00:00:00 2001 From: hunterckx <118154470+hunterckx@users.noreply.github.com> Date: Fri, 14 Aug 2026 19:35:09 -0700 Subject: [PATCH 11/11] fix: limit pandas version for analytics package (#4912) --- analytics/analytics_package/setup.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/analytics/analytics_package/setup.py b/analytics/analytics_package/setup.py index 747953f74..ad9c6b52f 100644 --- a/analytics/analytics_package/setup.py +++ b/analytics/analytics_package/setup.py @@ -2,11 +2,11 @@ setup( name="analytics", - version="5.0.2", + version="5.0.3", packages=["analytics", "analytics.static_site"], package_data={"analytics.static_site": ["template/*.html"]}, install_requires=[ - "pandas", + "pandas>=3,<4", "numpy", "google-auth-oauthlib", "google-api-python-client",