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 '
' + (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 = ''
-
- return result_text
-
-
- result_text = None
-
- if split_vertical:
- if not isinstance(split_vertical, list):
- split_vertical = [split_vertical]
- split_indices = np.cumsum(split_vertical)
- result_text = "".join([format_table_split(df.iloc[(0 if j == 0 else split_indices[j - 1]):index], j == len(split_indices - 1) and index == df.shape[0]) for j, index in enumerate(split_indices)])
- if split_indices[-1] < df.shape[0]:
- result_text += format_table_split(df.iloc[split_indices[-1]:], True)
- else:
- result_text = format_table_split(df, True)
-
-
- return HTML(result_text)
-
-def format_pc_change_table(df, include_plus=True, row_symbols=None, **other_params):
- # Expects pairs of columns in a 2D multi-index where the columns are name "Value" and "% Change"
-
- df_values = pd.concat([df[name].rename(name[0]) for name in df.columns if name[1] == "Value"], axis="columns")
- df_changes = pd.concat([df[name].rename(name[0]) for name in df.columns if name[1] == "% Change"], axis="columns")
-
- change_format = '({:+.2f}%)' if include_plus else '({:.2f}%)'
-
- def format_change_value(val):
- return "" if pd.isna(val) or val == np.inf else ('
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",