Repository: NooraHealth/sync-turn-whatsapp-attachments
Pull request #1: Sync WhatsApp Attachments to GCP Buckets
Author: login: AmmarAhmedAlvi
Created: 2025-05-16T02:19:38Z | Merged: 2025-05-27T23:30:20Z



Diff:
diff --git a/.github/workflows/sync_andhra_pradesh_ccp.yaml b/.github/workflows/sync_andhra_pradesh_ccp.yaml
deleted file mode 100644
index 2ed1171..0000000
--- a/.github/workflows/sync_andhra_pradesh_ccp.yaml
+++ /dev/null
@@ -1,34 +0,0 @@
-name: Sync Andhra Pradesh CCP
-
-on:
-  schedule:
-    - cron: '30 16 * * 0'
-  workflow_dispatch:
-
-jobs:
-  sync-to-warehouse:
-    runs-on: ubuntu-latest
-    env:
-      SOURCE_PARAMS: ${{ secrets.ANDHRA_PRADESH_CCP }}
-      SERVICE_ACCOUNT_KEY: ${{ secrets.SERVICE_ACCOUNT_KEY }}
-      SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }}
-      RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
-
-    steps:
-      - name: Checkout code
-        uses: actions/checkout@v4
-
-      - name: Install uv
-        uses: astral-sh/setup-uv@v3
-        with:
-          version: '0.5.4'
-          enable-cache: true
-
-      - name: Set up Python
-        run: uv python install
-
-      - name: Install the project
-        run: uv sync
-
-      - name: Run script
-        run: uv run -m src.andhra_pradesh_ccp
diff --git a/.github/workflows/sync_andhra_pradesh_mlhp.yaml b/.github/workflows/sync_andhra_pradesh_mlhp.yaml
deleted file mode 100644
index 39b2253..0000000
--- a/.github/workflows/sync_andhra_pradesh_mlhp.yaml
+++ /dev/null
@@ -1,66 +0,0 @@
-name: Sync Andhra Pradesh MLHP
-
-on:
-  schedule:
-    - cron: '30 16 * * 3'
-  workflow_dispatch:
-    inputs:
-      timeout_mins:
-        required: false
-        type: choice
-        options:
-          - 5
-          - 20
-          - 60
-          - 240
-      trigger_mode:
-        required: false
-        type: choice
-        options:
-          - oneanddone
-          - oneormore
-          - continuing
-      max_workers:
-        required: false
-        type: choice
-        options:
-          - 1
-          - 2
-          - 4
-
-jobs:
-  sync-to-warehouse:
-    runs-on: ubuntu-latest
-    env:
-      GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-      GH_PAT: ${{ secrets.GH_PAT }}
-      SOURCE_PARAMS: ${{ secrets.ANDHRA_PRADESH_MLHP }}
-      SERVICE_ACCOUNT_KEY: ${{ secrets.SERVICE_ACCOUNT_KEY }}
-      SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }}
-      RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
-      TIMEOUT_MINS: ${{ inputs.timeout_mins || '240' }}
-      TRIGGER_MODE: ${{ inputs.trigger_mode || 'oneormore' }}
-      MAX_WORKERS: ${{ inputs.max_workers || '4' }}
-
-    steps:
-      - name: Checkout code
-        uses: actions/checkout@v4
-
-      - name: Install uv
-        uses: astral-sh/setup-uv@v3
-        with:
-          version: '0.5.4'
-          enable-cache: true
-
-      - name: Set up Python
-        run: uv python install
-
-      - name: Install the project
-        run: uv sync
-
-      - name: Run script
-        run: >
-          uv run -m src.andhra_pradesh_mlhp
-          --timeout-mins $TIMEOUT_MINS
-          --trigger-mode $TRIGGER_MODE
-          --max-workers $MAX_WORKERS
diff --git a/.github/workflows/sync_whatsapp_attachments.yaml b/.github/workflows/sync_whatsapp_attachments.yaml
new file mode 100644
index 0000000..d88483c
--- /dev/null
+++ b/.github/workflows/sync_whatsapp_attachments.yaml
@@ -0,0 +1,34 @@
+name: Sync Whatsapp Attachments
+
+on:
+  schedule:
+    - cron: '0 0 * * *'
+  workflow_dispatch:
+
+jobs:
+  sync:
+    runs-on: ubuntu-latest
+    env:
+      SOURCE_PARAMS: ${{ secrets.TURN_ATTACHMENTS }}
+      SERVICE_ACCOUNT_KEY_RAW: ${{ secrets.SERVICE_ACCOUNT_KEY_RAW }}
+      SERVICE_ACCOUNT_KEY_ANALYTICS: ${{ secrets.SERVICE_ACCOUNT_KEY_ANALYTICS }}
+      TURN_HEADERS: ${{ secrets.TURN_AUTH }}
+      SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }}
+      RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+
+      - name: Install uv
+        uses: astral-sh/setup-uv@v6
+
+      - name: Set up Python
+        run: uv python install
+
+      - name: Install the Project
+        run: uv sync --locked
+
+      - name: Run script
+        run: uv run -m src.turn_attachments
+        #run: uv run python src/turn_attachments.py
diff --git a/README.md b/README.md
index 7311c6f..6c0fdc3 100644
--- a/README.md
+++ b/README.md
@@ -1,12 +1,10 @@
-[![sync_andhra_pradesh_ccp](https://github.com/NooraHealth/ap-ccp-cron/actions/workflows/sync_andhra_pradesh_ccp.yaml/badge.svg)](https://github.com/NooraHealth/ap-ccp-cron/actions/workflows/sync_andhra_pradesh_ccp.yaml)
-
-[![sync_andhra_pradesh_mlhp](https://github.com/NooraHealth/ap-ccp-cron/actions/workflows/sync_andhra_pradesh_mlhp.yaml/badge.svg)](https://github.com/NooraHealth/ap-ccp-cron/actions/workflows/sync_andhra_pradesh_mlhp.yaml)
+[![sync_whatsapp_attachments](https://github.com/NooraHealth/ap-ccp-cron/actions/workflows/sync_whatsapp_attachments.yaml/badge.svg)](https://github.com/NooraHealth/ap-ccp-cron/actions/workflows/sync_whatsapp_attachments.yaml)
 
 # Overview
 
-This repository contains GitHub Actions workflows that fetch data from the Andhra Pradesh CCP API and the Andhra Pradesh MLHP API and sync the data to the BigQuery data warehouse.
+This repository contains GitHub Actions workflows that fetch inbound message attachmnets data from Turn API and sync the data to GCP storage bucket.
 
-When syncing to BigQuery, the code fetches data starting with 30 days prior to the latest data existing in BigQuery. This redundancy accounts for the possibility that historical data behind the APIs can change. Per Hassan, editing or deleting submissions is not allowed, but new submissions can be back-dated up to 15 days. The data are deduplicated in dbt.
+The sync runs daily and the code fetches data for the past 2 days.
 
 ## Setup
 
@@ -18,30 +16,14 @@ When syncing to BigQuery, the code fetches data starting with 30 days prior to t
    5. Run python scripts using `uv run path_to_script.py`.
 
 2. Configure the repository secrets locally.
-   1. Create a file secrets/andhra_pradesh_ccp.yml as follows, replacing `xxx` as appropriate:
-
-      ```yaml
-      url: xxx
-      username: xxx
-      password: xxx
-      ```
-      The `url` is currently a proxy URL, because the actual API is inaccessible within GitHub Actions due to an unresolvable misconfiguration on the API server.
-   2. Create a file secrets/andhra_pradesh_mlhp.yml as follows, replacing `xxx` as appropriate:
-
-      ```yaml
-      url: xxx
-      key: xxx
-      ```
-   3. Create a file secrets/slack_token.txt that contains the Slack bot token.
-   4. Create a file secrets/service_account_key.json that contains the JSON key for the service account that will connect to BigQuery (airbyte-user).
-   5. Create a file secrets/gh_pat.txt that contains a GitHub fine-grained personal access token that has
-      - access on the NooraHealth organization
-      - repository access to this repo
-      - read and write permissions for Actions
+   1. Create a file secrets/slack_token.txt that contains the Slack bot token.
+   2. Create a file secrets/service_account_key_raw.json that contains the JSON key for the service account that will dump the data to the storage buckets (airbyte-user). Ensure that this has 'Storage Object User' permission on the dev and prod buckets.
+   3. Create a file secrets/service_account_key_analytics.json that contains the JSON key for the service account that will connect to BigQuery (metabase-user).
+   4. Create a file secrets/turn_auth.json that contains the JSON key for the API headers used by turn for each line (available on passbolt).
 
 3. Configure the repository secrets on GitHub.
-   1. Copy and paste the contents of secrets/andhra_pradesh_ccp.yml into a secret named "ANDHRA_PRADESH_CCP".
-   2. Copy and paste the contents of secrets/andhra_pradesh_mlhp.yml into a secret named "ANDHRA_PRADESH_MLHP".
-   3. Copy and paste the contents of secrets/slack_token.txt into a secret named "SLACK_TOKEN".
-   4. Copy and paste the contents of secrets/bigquery_service_account_key.json into a secret named "SERVICE_ACCOUNT_KEY".
-   5. Copy and paste the contents of secrets/gh_pat.txt into a secret named "GH_PAT".
+   1. Copy and paste the contents of secrets/slack_token.txt into a secret named "SLACK_TOKEN".
+   2. Copy and paste the contents of secrets/service_account_key_raw.json into a secret named "SERVICE_ACCOUNT_KEY_RAW".
+   3. Copy and paste the contents of secrets/service_account_key_analytics.json into a secret named "SERVICE_ACCOUNT_KEY_ANALYTICS".
+   4. Copy and paste the contents of secrets/turn_auth.json into a secret named "TURN_HEADERS".
+   5. Create a GH Token with read and write access for Actions and paste it into a secret named "GH_PAT".
diff --git a/params.yaml b/params.yaml
index 173ac87..db66178 100644
--- a/params.yaml
+++ b/params.yaml
@@ -1,16 +1,12 @@
 slack_channel_id: C080138T9S6
-service_account_key: service_account_key.json
-project: noorahealth-raw
-sources:
-  - name: andhra_pradesh_ccp
-    environments:
-      - name: prod
-        dataset: andhra_pradesh_ccp
-      - name: dev
-        dataset: andhra_pradesh_ccp_dev
-  - name: andhra_pradesh_mlhp
-    environments:
-      - name: prod
-        dataset: andhra_pradesh_mlhp
-      - name: dev
-        dataset: andhra_pradesh_mlhp_dev
+service_account_key_raw: service_account_key_raw.json
+service_account_key_analytics: service_account_key_analytics.json
+turn_headers: turn_auth.json
+project_raw: noorahealth-raw
+project_analytics: noorahealth-analytics
+
+environments:
+  dev:
+    bucket_name: noora_test_bucket
+  prod:
+    bucket_name: noora_whatsapp_attachments_inbound
diff --git a/pyproject.toml b/pyproject.toml
index d4787b5..e997579 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,17 +1,19 @@
 [project]
-name = "sync-andhra-pradesh-ccp"
+name = "sync-turn-attachments"
 version = "0.1.0"
 description = "Add your description here"
 readme = "README.md"
 requires-python = ">=3.13"
 dependencies = [
     "google-cloud-bigquery>=3.27.0",
+    "google-cloud-storage>=2.0.0",
+    "google-cloud-bigquery-storage>=2.0.0",
+    "google-crc32c>=1.5.0",
+    "db-dtypes>=1.0.4",
     "oyaml>=1.0",
-    "polars==1.17.0",
     "pyarrow>=18.1.0",
     "pygithub>=2.5.0",
     "requests>=2.32.3",
-    "slack-sdk>=3.34.0",
-    "tqdm>=4.67.1",
-    "xlsxwriter>=3.2.0",
+    "pandas>=1.3.0",
+    "slack-sdk>=3.34.0"
 ]
diff --git a/src/andhra_pradesh_ccp.py b/src/andhra_pradesh_ccp.py
deleted file mode 100644
index 76e0f7e..0000000
--- a/src/andhra_pradesh_ccp.py
+++ /dev/null
@@ -1,308 +0,0 @@
-import argparse
-import concurrent.futures
-import datetime as dt
-import hashlib
-import json
-import polars as pl
-import polars.selectors as cs
-import requests
-import xlsxwriter
-from datetime import datetime, timedelta
-from . import utils
-
-
-def dict_hash(dictionary):
-  dictionary_json = json.dumps(dictionary, sort_keys = True)
-  # Create an MD5 hash object
-  hasher = hashlib.md5()
-  # Encode the JSON string and update the hasher
-  hasher.update(dictionary_json.encode('utf-8'))
-  # Return the hexadecimal representation of the hash
-  return hasher.hexdigest()
-
-
-def dt_iterate(start, end, step):
-  assert start <= end, 'start should be less than end'
-  while start <= end:
-    yield start
-    start += step
-
-
-class Report:
-  def __init__(self, base_url: str, username: str, password: str):
-    self.url = base_url
-    self.username = username
-    self.password = password
-    self.key = None
-
-  def login(self):
-    response = requests.get(
-      self.url,
-      json = {'login': True, 'username': self.username, 'password': self.password})
-    response.raise_for_status()
-    data = response.json()
-
-    assert data['result'] == 'success', f'Login unsuccessful: {data}'
-
-    print('Login successful. Auth-Key:', data['Auth-Key'])
-    self.key = data['Auth-Key']
-
-  @staticmethod
-  def has_token_expired(data):
-    error_vals = ('Invalid or token expired', 'Expired token')
-    return data['result'] == 'failed' and data['error'] in error_vals
-
-  @property
-  def headers(self):
-    return {'Auth-Key': self.key, 'Username': self.username}
-
-  def get_patient_training(self, date: datetime):
-    response = requests.get(
-      self.url,
-      headers = self.headers,
-      json = {
-        'get_total_ccp_class_attendancedata': True,
-        'date': date.strftime('%d-%m-%Y'),
-      },
-    )
-    response.raise_for_status()
-    data = response.json()
-
-    if Report.has_token_expired(data):
-      self.login()
-      return self.get_patient_training(date)
-    else:
-      print(f'Fetched patient training sessions for date {date}')
-      return data['data'] if data['result'] == 'success' else []
-
-  def get_nurse_training(self, date: datetime):
-    response = requests.get(
-      self.url,
-      headers = self.headers,
-      json = {
-        'get_total_nurse_training_sessiondata': True, 'date': date.strftime('%d-%m-%Y')}
-    )
-    response.raise_for_status()
-    data = response.json()
-
-    if Report.has_token_expired(data):
-      self.login()
-      return self.get_nurse_training(date)
-    else:
-      print(f'Fetched nurse training sessions for date {date}')
-      return data['data'] if data['result'] == 'success' else []
-
-  def get_nurse_details(self, phone_number: str):
-    response = requests.get(
-      self.url,
-      headers = self.headers,
-      json = {'get_nurses_detailes_data': True, 'username': phone_number}
-    )
-    response.raise_for_status()
-    data = response.json()
-
-    # check for expired token, and if expired, regenerate token
-    if Report.has_token_expired(data):
-      self.login()
-      return self.get_nurse_details(phone_number)
-    else:
-      print(f'Fetched nurse details for phone ending in {phone_number[-4:]}')
-      return data['data'] if data['result'] == 'success' else []
-
-
-def read_data_from_api(params, dates):
-  report = Report(params['url'], params['username'], params['password'])
-  report.login()
-  iter_dates = [*dt_iterate(dates['start'], dates['end'], timedelta(days = 1))]
-
-  with concurrent.futures.ThreadPoolExecutor() as executor:
-    patient_trainings_nested = executor.map(report.get_patient_training, iter_dates)
-  patient_trainings = [x2 for x1 in patient_trainings_nested for x2 in x1]
-
-  with concurrent.futures.ThreadPoolExecutor() as executor:
-    nurse_trainings_nested = executor.map(report.get_nurse_training, iter_dates)
-
-  nurse_trainings = [x2 for x1 in nurse_trainings_nested for x2 in x1]
-
-  if not patient_trainings:
-    print('No patient training sessions found')
-    return
-
-  for x in patient_trainings:
-    x['md5'] = dict_hash(x)
-
-  for x in nurse_trainings:
-    x['md5'] = dict_hash(x)
-
-  phones_in_patient_trainings = [
-    x2 for x1 in patient_trainings for x2 in x1['session_conducted_by'].split(',')
-  ]
-
-  phones_in_nurse_trainings = []
-  for i in nurse_trainings:
-    if i.get('trainerdata1'):
-      for trainer in i['trainerdata1']:
-        phones_in_nurse_trainings.append(trainer['phone_no'])
-
-    if i.get('traineesdata1'):
-      for trainee in i['traineesdata1']:
-        phones_in_nurse_trainings.append(trainee['phone_no'])
-
-  all_phones = list(set(phones_in_patient_trainings + phones_in_nurse_trainings))
-
-  with concurrent.futures.ThreadPoolExecutor() as executor:
-    nurse_details_nested = executor.map(report.get_nurse_details, all_phones)
-  nurses = [x2 for x1 in nurse_details_nested for x2 in x1]
-
-  nurses_df = pl.from_dicts(nurses).with_columns(
-    cs.by_name('user_created_dateandtime', require_all = False)
-    .str.to_datetime('%Y-%m-%d %H:%M:%S')
-  )
-
-  int_cols = ['mothers_trained', 'family_members_trained', 'total_trained']
-  patient_trainings_df = (
-    pl.from_dicts(patient_trainings)
-    .with_columns(
-      pl.col('date_of_session').str.to_date('%d-%m-%Y'),
-      cs.by_name(int_cols, require_all = False).cast(pl.Int64),
-      cs.by_name('data1', require_all = False)
-      .map_elements(utils.json_dumps_list, return_dtype = pl.String)
-    )
-  )
-
-  int_cols = ['totalmaster_trainer', 'total_trainees']
-  struct_cols = ['trainerdata1', 'traineesdata1']
-  nurse_trainings_df = (
-    pl.from_dicts(nurse_trainings)
-    .with_columns(
-      cs.by_name('sessiondateandtime', require_all = False).str.to_date('%d-%m-%Y'),
-      cs.by_name(int_cols, require_all = False).cast(pl.Int64),
-      cs.by_name(struct_cols, require_all = False)
-      .map_elements(utils.json_dumps_list, return_dtype = pl.String)
-    )
-  )
-
-  data_frames = {
-    'nurses': nurses_df,
-    'patient_training_sessions': patient_trainings_df,
-    'nurse_training_sessions': nurse_trainings_df,
-  }
-  return data_frames
-
-
-def write_data_to_excel(data_frames, filepath = 'data.xlsx'):
-  with xlsxwriter.Workbook(filepath) as wb:
-    for key, df in sorted(data_frames.items()):
-      df.write_excel(workbook = wb, worksheet = key)
-
-
-def write_data_to_bigquery(params, data_frames):
-  extracted_at = datetime.now(dt.timezone.utc).replace(microsecond = 0)
-  for key in data_frames:
-    data_frames[key] = utils.add_extracted_columns(data_frames[key], extracted_at)
-
-  if utils.read_bigquery_exists('nurses', params):
-    # keep the latest data for each username
-    nurses_old = utils.read_bigquery(
-      f"select * from `{params['dataset']}.nurses`", params['credentials'])
-    nurses_concat = pl.concat(
-      [nurses_old, data_frames['nurses']], how = 'diagonal_relaxed')
-    data_frames['nurses'] = nurses_concat.group_by('username').tail(1)
-
-  write_dispositions = {
-    'nurses': 'WRITE_TRUNCATE',
-    'patient_training_sessions': 'WRITE_APPEND',
-    'nurse_training_sessions': 'WRITE_APPEND',
-  }
-
-  for table_name in sorted(data_frames.keys()):
-    utils.write_bigquery(
-      data_frames[table_name], table_name, params, write_dispositions[table_name])
-
-
-def get_dates_from_bigquery(
-    params, default_start_date = dt.date(2023, 6, 1), overlap = 30):
-  # earliest nurse training 2023-06-06, earliest patient training 2023-08-16
-  table_name = 'patient_training_sessions'
-  col_name = 'date_of_session'
-  dates = dict(
-    start = default_start_date, end = datetime.now().date() - timedelta(days = 1))
-
-  if utils.read_bigquery_exists(table_name, params):
-    q = f"select max({col_name}) as max_date from `{params['dataset']}.{table_name}`"
-    df = utils.read_bigquery(q, params['credentials'])
-    if df.item() is not None:
-      dates['start'] = df.item() - timedelta(days = overlap - 1)
-
-  return dates
-
-
-def get_dates(args, params):
-  today = datetime.now().date()
-  if args.dest == 'bigquery':
-    dates = get_dates_from_bigquery(params)
-  else:
-    dates = dict(start = today - timedelta(days = 7), end = today - timedelta(days = 1))
-
-    if args.start_date is not None:
-      dates['start'] = args.start_date
-    if args.end_date is not None:
-      dates['end'] = args.end_date
-
-  if dates['start'] > dates['end']:
-    raise Exception('Start date cannot be later than end date.')
-  if dates['end'] > today:
-    raise Exception('End date cannot be later than today.')
-
-  print(
-    f"Attempting to fetch data between {dates['start']} and {dates['end']}, inclusive.")
-  return dates
-
-
-def parse_args():
-  parser = argparse.ArgumentParser(
-    description = 'Extract and load for the Andhra Pradesh CCP API.',
-    formatter_class = argparse.RawTextHelpFormatter)
-  parser.add_argument(
-    '--dest',
-    choices = ['bigquery', 'local'],
-    default = 'bigquery',
-    help = 'Destination to write the data (bigquery or local).')
-  parser.add_argument(
-    '--start-date',
-    type = lambda x: datetime.strptime(x, '%Y-%m-%d').date(),
-    help = '(Optional) Start date in YYYY-MM-DD format. Only used if --dest=local.')
-  parser.add_argument(
-    '--end-date',
-    type = lambda x: datetime.strptime(x, '%Y-%m-%d').date(),
-    help = '(Optional) End date in YYYY-MM-DD format. Only used if --dest=local.')
-
-  args = parser.parse_args()
-  return args
-
-
-def main():
-  try:
-    source_name = 'andhra_pradesh_ccp'
-    args = parse_args()
-    params = utils.get_params(source_name)
-    dates = get_dates(args, params)
-
-    data = read_data_from_api(params['source_params'], dates)
-    if data is None:
-      return None
-
-    if args.dest == 'bigquery':
-      write_data_to_bigquery(params, data)
-    else:
-      write_data_to_excel(data)
-
-  except Exception as e:
-    if params['environment'] == 'prod':
-      text = utils.get_slack_message_text(e, source_name)
-      utils.send_message_to_slack(text, params['slack_channel_id'], params['slack_token'])
-    raise e
-
-
-if __name__ == '__main__':
-  main()
diff --git a/src/andhra_pradesh_mlhp.py b/src/andhra_pradesh_mlhp.py
deleted file mode 100644
index d7f33bc..0000000
--- a/src/andhra_pradesh_mlhp.py
+++ /dev/null
@@ -1,255 +0,0 @@
-import argparse
-import concurrent.futures
-import datetime as dt
-import functools
-import github
-import os
-import polars as pl
-import polars.selectors as cs
-import re
-import requests
-import time
-from pathlib import Path
-from tqdm import tqdm
-from . import utils
-
-
-USERS_TABLE_NAME = 'users'
-DEFAULT_EXTRACTED_AT = dt.datetime(2023, 4, 1).replace(tzinfo = dt.timezone.utc)
-
-
-def get_try_chunk_days(range_days, try_chunk_days = (90, 45, 21, 10, 3)):
-  idx = next(
-    (i for i in range(len(try_chunk_days)) if try_chunk_days[i] < range_days),
-    len(try_chunk_days))
-  idx = max(0, idx - 1)
-  return try_chunk_days[idx:]
-
-
-def get_chunk_dates(fromdate, todate, chunk_days):
-  chunk_dates = pl.date_range(
-    fromdate, todate, f'{chunk_days}d', closed = 'left', eager = True).to_list()
-  chunk_dates.append(todate + dt.timedelta(days = 1))
-
-  fromdates = [x.strftime('%Y-%m-%d') for x in chunk_dates[:-1]]
-  todates = [(x - dt.timedelta(days = 1)).strftime('%Y-%m-%d') for x in chunk_dates[1:]]
-  return zip(fromdates, todates)
-
-
-def get_sessions_from_api(fromdate, todate, username, api_key, api_url):
-  try_chunk_days = get_try_chunk_days((todate - fromdate).days)
-  base_headers = {'username': username, 'ApiKey': api_key}
-  no_data = {'status': 'Failed', 'msg': 'No Data Found'}
-
-  for chunk_days in try_chunk_days:
-    try:
-      if chunk_days != try_chunk_days[0]:
-        print(f'Downshifting username {username} to chunk_days={chunk_days}')
-        time.sleep(3)
-      chunk_dates = get_chunk_dates(fromdate, todate, chunk_days)
-      data = []
-
-      for chunk_fromdate, chunk_todate in chunk_dates:
-        chunk_headers = {'fromdate': chunk_fromdate, 'todate': chunk_todate}
-        response = requests.get(api_url, headers = chunk_headers | base_headers)
-        response.raise_for_status()
-        if response.json() != no_data and 'data' in response.json().keys():
-          data.extend(response.json()['data'])
-      break  # if we've made it this far, we're good to go
-
-    except Exception as e:
-      if chunk_days == try_chunk_days[-1]:
-        raise e
-
-  if len(data) == 0:
-    return pl.DataFrame()
-
-  # int_cols = ['id', 'patients_trained', 'member_trained']
-  df = (
-    pl.from_dicts(data)
-    # .with_columns(cs.by_name(int_cols, require_all = False).cast(pl.Int64))
-    .with_columns(
-      pl.col('id').cast(pl.Int64),
-      pl.col('session_date').str.to_date('%d-%m-%Y'),
-      cs.by_name('subdata', require_all = False)
-      .map_elements(utils.json_dumps_list, return_dtype = pl.String))
-  )
-  return df
-
-
-def sync_sessions_by_users(users_slice, params, extracted_at, overlap_days = 30):
-  # TODO: how far back can sessions be created? affects overlap_days
-  def get_where(x, colname = 'username'):
-    return f"where {colname} in ('{"', '".join(x)}')"
-
-  q_pre = f"update `{params['dataset']}.{USERS_TABLE_NAME}` set"
-  query = q_pre + ' is_extracting = true ' + get_where(users_slice['username'].to_list())
-  utils.run_bigquery(query, params['credentials'])
-
-  sessions_list = []
-  ok_usernames = []
-  err_usernames = []
-  todate = extracted_at.date() - dt.timedelta(days = 1)
-  (api_key, api_url) = [params['source_params'][x] for x in ('key', 'url')]
-
-  for user in users_slice.iter_rows(named = True):
-    try:
-      fromdate = max(DEFAULT_EXTRACTED_AT.date(), user['max_todate'])
-      sessions_now = get_sessions_from_api(
-        fromdate, todate, user['username'], api_key, api_url)
-      sessions_list.append(sessions_now)
-      ok_usernames.append(user['username'])
-
-    except Exception as e:
-      print(f"Error for username {user['username']}: {e}")
-      err_usernames.append(user['username'])
-
-  if len(sessions_list) > 0:
-    sessions = pl.concat(sessions_list, how = 'diagonal_relaxed')
-    if sessions.shape[0] > 0:
-      sessions = utils.add_extracted_columns(sessions, extracted_at)
-      utils.write_bigquery(sessions, 'sessions', params, 'WRITE_APPEND')
-
-  if len(ok_usernames) > 0:
-    query = q_pre + (
-      f" max_todate = date('{todate}'), "
-      f"_extracted_at = timestamp('{extracted_at}'), "
-      f"is_extracting = false "
-    ) + get_where(ok_usernames)
-    utils.run_bigquery(query, params['credentials'])
-
-  if len(err_usernames) > 0:
-    query = q_pre + ' is_extracting = false ' + get_where(err_usernames)
-    utils.run_bigquery(query, params['credentials'])
-
-  return users_slice.shape[0]
-
-
-def sync_data_to_warehouse(params, timeout_mins, trigger_mode, max_workers = 4):
-  col_name = '_extracted_at'
-  timeout = timeout_mins * 60 if timeout_mins > 0 else None
-  num_users_per_slice = 10  # bigquery quota of 1500 table appends per 24 hours
-
-  if trigger_mode == 'continuing':
-    query = f"select max({col_name}) from `{params['dataset']}.{USERS_TABLE_NAME}`"
-    extracted_at = utils.read_bigquery(query, params['credentials']).item()
-
-  if trigger_mode != 'continuing' or extracted_at is None:
-    extracted_at = dt.datetime.now(dt.timezone.utc).replace(microsecond = 0)
-
-  query = (
-    f"select username, max_todate, _extracted_at "
-    f"from `{params['dataset']}.{USERS_TABLE_NAME}`"
-  )
-  if trigger_mode == 'continuing':
-    query += (
-      f" where {col_name} is null or {col_name} != "
-      f"(select max({col_name}) from `{params['dataset']}.{USERS_TABLE_NAME}`)"
-    )
-  users = utils.read_bigquery(query, params['credentials']).sort([col_name, 'username'])
-
-  sync_sessions_by_users_p = functools.partial(
-    sync_sessions_by_users, params = params, extracted_at = extracted_at)
-
-  try:  # api is easily overwhelmed
-    # this version wasn't timing out properly and wasn't updating the progress bar
-    # with tqdm(total = users.shape[0]) as pbar:
-    #   with concurrent.futures.ThreadPoolExecutor(max_workers) as executor:
-    #     futures = [
-    #       executor.submit(sync_sessions_by_users_p, users_slice)
-    #       for users_slice in users.iter_slices(num_users_per_slice)]
-    #     for future in concurrent.futures.as_completed(futures, timeout = timeout):
-    #       num_users_this_slice = future.result()
-    #       pbar.update(num_users_this_slice)
-
-    with concurrent.futures.ThreadPoolExecutor(max_workers) as executor:
-      list(tqdm(
-        executor.map(
-          sync_sessions_by_users_p,
-          users.iter_slices(num_users_per_slice),
-          timeout = timeout),
-        total = (users.shape[0] // num_users_per_slice) + 1))
-
-  except TimeoutError:
-    if (trigger_mode in ('oneormore', 'continuing')
-        and params['github_ref_name'] is not None):  # noqa: W503
-      trigger_workflow(timeout_mins)
-
-  return True
-
-
-def trigger_workflow(timeout_mins, trigger_mode = 'continuing'):
-  g = github.Github(login_or_token = os.getenv('GH_PAT'))
-  repo = g.get_repo(os.getenv('GITHUB_REPOSITORY'))
-  pattern = '(?<=\\.github/workflows/).+\\.ya?ml'
-  workflow_name = re.search(pattern, os.getenv('GITHUB_WORKFLOW_REF')).group(0)
-  workflow = repo.get_workflow(workflow_name)
-  inputs = {'timeout_mins': str(timeout_mins), 'trigger_mode': trigger_mode}
-  response = workflow.create_dispatch(os.getenv('GITHUB_REF_NAME'), inputs = inputs)
-  return response
-
-
-def upload_users(params, data_dir = 'data'):
-  file_name = 'MLHP CHO Second Batch - mlhp_data_10032_new.csv'
-  schema_overrides = dict(nin_number = pl.String, mobile_no = pl.String)
-  colname_mapping = dict(
-    old_district = 'old_district_name',
-    new_district = 'district_name',
-    mandal = 'mandal_name',
-    ysr_clinicname = 'ysr_clinic_name',
-    nin_number = 'username',
-    sec_code = 'secretariat_code',
-    mobile_no = 'mobile_number'
-  )
-  created_at = dt.datetime.now(dt.timezone.utc).replace(microsecond = 0)
-
-  df_raw = pl.read_csv(Path(data_dir, file_name), schema_overrides = schema_overrides)
-  # df_raw.group_by('nin_number').len().filter(pl.col('len') > 1)  # hopefully empty
-  df = (
-    df_raw.rename(colname_mapping)
-    .with_columns(
-      created_at = created_at,
-      max_todate = DEFAULT_EXTRACTED_AT.date(),
-      _extracted_at = DEFAULT_EXTRACTED_AT,
-      is_extracting = False
-    )
-    .unique('username', keep = 'first')
-  )
-  # with pl.Config(tbl_cols = -1):
-  #   print(df)
-  # TODO: combine and reconcile first and second batches, if necessary
-  # TODO: deal with the next batch when it comes, without refetching all data
-
-  utils.write_bigquery(df_raw, f'{USERS_TABLE_NAME}_raw', params, 'WRITE_EMPTY')
-  utils.write_bigquery(df, USERS_TABLE_NAME, params, 'WRITE_EMPTY')
-
-
-def parse_args():
-  parser = argparse.ArgumentParser()
-  parser.add_argument('--params-path', default = 'params.yaml', type = Path)
-  parser.add_argument('--timeout-mins', default = 5, type = int)
-  parser.add_argument(
-    '--trigger-mode', default = 'oneanddone',
-    choices = ['oneanddone', 'oneormore', 'continuing'])
-  parser.add_argument('--max-workers', default = 4, type = int)
-  args = parser.parse_args()
-  return args
-
-
-def main():
-  try:
-    source_name = 'andhra_pradesh_mlhp'
-    args = parse_args()
-    params = utils.get_params(source_name, args.params_path)
-    sync_data_to_warehouse(params, args.timeout_mins, args.trigger_mode, args.max_workers)
-
-  except Exception as e:
-    if params['environment'] == 'prod':
-      text = utils.get_slack_message_text(e, source_name)
-      utils.send_message_to_slack(
-        text, params['slack_channel_id'], params['slack_token'])
-    raise e
-
-
-if __name__ == '__main__':
-  main()
diff --git a/src/turn_attachments.py b/src/turn_attachments.py
new file mode 100644
index 0000000..a08fd70
--- /dev/null
+++ b/src/turn_attachments.py
@@ -0,0 +1,54 @@
+import requests
+import mimetypes
+import argparse
+import utils
+from pathlib import Path
+
+def parse_args():
+    parser = argparse.ArgumentParser(description="Sync WhatsApp attachments to GCS and notify on errors.")
+    parser.add_argument(
+        '--params-path',
+        type=Path,
+        default=Path('params.yaml'),
+        help='Path to the params.yaml file'
+    )
+    return parser.parse_args()
+
+
+def main():
+    args = parse_args()
+    params = utils.get_params(args.params_path)
+
+    try:
+        # Fetch recent attachments from BigQuery
+        project_analytics = params['project_analytics']
+        creds_analytics = params['credentials_analytics']
+        query = f"""
+          SELECT *
+          FROM `{project_analytics}.prod.res_message_attachments`
+          WHERE inserted_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 28 HOUR)
+            AND media_type NOT IN ('location', 'sticker')
+          ORDER BY channel_phone
+          """
+        df = utils.run_read_bigquery(query, creds_analytics)
+
+        # Prepare GCS bucket
+        storage_client = utils.get_storage_client(params['project_raw'], params['credentials_raw'])
+        bucket = storage_client.bucket(params['bucket_name'])
+
+        # Download each attachment and upload to GCS
+        df.apply(lambda row: utils.transfer_file(row, bucket, params['turn_headers']), axis=1)
+
+    except Exception as e:
+        # Notify on Slack if running in prod
+        text = utils.get_slack_message_text(e)
+        utils.send_message_to_slack(
+            text,
+            params['slack_channel_id'],
+            params['slack_token']
+        )
+        raise e
+
+
+if __name__ == '__main__':
+    main()
diff --git a/src/utils.py b/src/utils.py
index 448ecb1..2f536b9 100644
--- a/src/utils.py
+++ b/src/utils.py
@@ -1,142 +1,130 @@
 import google
-import io
 import json
 import os
+import time
+import pandas as pd
 import oyaml as yaml
-import polars as pl
 import slack_sdk
-import time
-import uuid
-import warnings
-from google.cloud import bigquery
-from google.oauth2.service_account import Credentials
+import mimetypes
+import requests
+from urllib.parse import urlparse
 from pathlib import Path
+from google.cloud import bigquery, bigquery_storage, storage
+from google.oauth2.service_account import Credentials
 
 
-def get_params(source_name, params_path = 'params.yaml', envir = None):
-  with open(params_path) as f:
-    params = yaml.safe_load(f)
-
-  github_ref_name = os.getenv('GITHUB_REF_NAME')
-  params.update({'github_ref_name': github_ref_name})
-  if envir is None:
-    envir = 'prod' if github_ref_name == 'main' else 'dev'
-
-  y = [x for x in params['sources'] if x['name'] == source_name][0]
-  params['source_name'] = y['name']
-
-  z = [x for x in y['environments'] if x['name'] == envir][0]
-  params['environment'] = z.pop('name')
-  params.update(z)
-  del params['sources']
-
-  key = 'service_account_key'
-  if github_ref_name is None:
-    with open(Path('secrets', params[key])) as f:
-      params[key] = json.load(f)
-    with open(Path('secrets', 'slack_token.txt')) as f:
-      params['slack_token'] = f.read().strip('\n')
-    with open(Path('secrets', f'{source_name}.yaml')) as f:
-      params['source_params'] = yaml.safe_load(f)
-  else:
-    params[key] = json.loads(os.getenv('SERVICE_ACCOUNT_KEY'))
-    params['slack_token'] = os.getenv('SLACK_TOKEN')
-    params['source_params'] = yaml.safe_load(os.getenv('SOURCE_PARAMS'))
-
-  params['credentials'] = Credentials.from_service_account_info(params[key])
-  del params[key]
-  return params
-
-
-def json_dumps_list(x):
-  return json.dumps(x.to_list())
-
-
-def run_bigquery(query, credentials, num_tries = 5, wait_secs = 5):
-  client = bigquery.Client(credentials = credentials)
-  gtg = False
-  idx_try = 1
-  while not gtg and idx_try <= num_tries:
-    try:
-      query_job = client.query(query)
-      result = query_job.result()
-      gtg = True
-    except google.api_core.exceptions.BadRequest as e:
-      if 'due to concurrent update' in str(e) and (idx_try < num_tries):
-        time.sleep(wait_secs)
-        idx_try += 1
-      else:
-        raise e
-  return result
-
-
-def read_bigquery(query, credentials):
-  with warnings.catch_warnings(action = 'ignore'):
-    rows = run_bigquery(query, credentials).to_arrow()
-    df = pl.from_arrow(rows)
-  return df
-
-
-def read_bigquery_exists(table_name, params):
-  q = f"select * from `{params['dataset']}.__TABLES__` where table_id = '{table_name}'"
-  df = read_bigquery(q, params['credentials'])
-  return df.shape[0] > 0
-
-
-def get_bigquery_schema(df):
-  n = 'NUMERIC'
-  i = 'INT64'
-  s = 'STRING'
-  dtype_mappings = {
-    pl.Decimal: n, pl.Float32: n, pl.Float64: n,
-    pl.Int8: i, pl.Int16: i, pl.Int32: i, pl.Int64: i,
-    pl.UInt8: i, pl.UInt16: i, pl.UInt32: i, pl.UInt64: i,
-    pl.String: s, pl.Categorical: s, pl.Enum: s, pl.Utf8: s,
-    pl.Binary: 'BOOLEAN', pl.Boolean: 'BOOLEAN', pl.Date: 'DATE',
-    pl.Datetime('us', time_zone = 'UTC'): 'TIMESTAMP',
-    pl.Datetime('us', time_zone = None): 'DATETIME',  # won't work for other time zones
-    pl.Time: 'TIME', pl.Duration: 'INTERVAL', pl.Null: 'STRING'
-  }
-  schema = []
-  for j in range(df.shape[1]):
-    dtype = dtype_mappings[df.dtypes[j]]
-    schema.append(bigquery.SchemaField(df.columns[j], dtype))
-  return schema
-
-
-def write_bigquery(df, table_name, params, write_disposition = 'WRITE_EMPTY'):
-  schema = get_bigquery_schema(df)
-  client = bigquery.Client(credentials = params['credentials'])
-  with io.BytesIO() as stream:
-    df.write_parquet(stream)
-    stream.seek(0)
-    job_config = bigquery.LoadJobConfig(
-      source_format = 'PARQUET', schema = schema, write_disposition = write_disposition)
-    job = client.load_table_from_file(
-      stream, destination = f"{params['dataset']}.{table_name}",
-      project = params['project'], job_config = job_config)
-  job.result()
-  return df.shape[0]
-
-
-def add_extracted_columns(df, extracted_at = None):
-  if extracted_at is not None:
-    df = df.with_columns(_extracted_at = pl.lit(extracted_at))
-  uuids = [str(uuid.uuid4()) for _ in range(df.shape[0])]
-  df = df.with_columns(pl.Series('_extracted_uuid', uuids))
-  return df
-
-
-def get_slack_message_text(e, source_name):
-  text = (
-    f':warning: Sync for *{source_name}* failed with the following error:'
-    f'\n\n`{str(e)}`'
-  )
-  run_url = os.getenv('RUN_URL')
-  if run_url is not None:
-    text += f'\n\nPlease see the GitHub Actions <{run_url}|workflow run log>.'
-  return text
-
+def get_params(params_path = 'params.yaml', envir = None):
+    """
+    Load global settings and environment-specific bucket_name from params.yaml.
+    Returns a dict with credentials, GCS project IDs, bucket_name, turn headers, and Slack config.
+    """
+    # Load params file
+    with open(params_path) as f:
+        params = yaml.safe_load(f)
+
+    # Determine environment (dev vs prod) from GitHub Actions or override
+    github_ref_name = os.getenv('GITHUB_REF_NAME')
+    if envir is None:
+        envir = 'prod' if github_ref_name == 'main' else 'dev'
+
+    # Extract bucket_name for this environment
+    env_cfg = params['environments'][envir]
+    params['bucket_name'] = env_cfg['bucket_name']
+    # Clean up
+    del params['environments']
+
+    # Load service account credentials and turn headers
+    if github_ref_name is None:
+        # Local dev: read files from 'secrets/'
+        raw_key_path = Path('secrets', params['service_account_key_raw'])
+        analytics_key_path = Path('secrets', params['service_account_key_analytics'])
+        params['credentials_raw'] = Credentials.from_service_account_file(raw_key_path)
+        params['credentials_analytics'] = Credentials.from_service_account_file(analytics_key_path)
+
+        turn_headers_path = Path('secrets', params['turn_headers'])
+        params['turn_headers'] = json.load(open(turn_headers_path))
+
+        # Slack token from local file
+        params['slack_token'] = Path('secrets', 'slack_token.txt').read_text().strip()
+    else:
+        # CI: read JSON strings from environment variables
+        params['credentials_raw'] = Credentials.from_service_account_info(
+            json.loads(os.getenv('SERVICE_ACCOUNT_KEY_RAW', '{}'))
+        )
+        params['credentials_analytics'] = Credentials.from_service_account_info(
+            json.loads(os.getenv('SERVICE_ACCOUNT_KEY_ANALYTICS', '{}'))
+        )
+        params['turn_headers'] = json.loads(os.getenv('TURN_HEADERS', '{}'))
+        params['slack_token'] = os.getenv('SLACK_TOKEN')
+    return params
+
+
+def get_storage_client(project, credentials):
+    return storage.Client(project=project, credentials=credentials)
+
+
+def get_bigquery_client(project, credentials):
+    return bigquery.Client(project=project, credentials=credentials)
+
+def derive_filename(uri, mime_type):
+    """
+    Extract the basename from a URI; if missing an extension, guess from the MIME type.
+    """
+    path = urlparse(uri).path
+    filename = os.path.basename(path)
+    name, ext = os.path.splitext(filename)
+    if ext == '' and mime_type is not None:
+        guessed = mimetypes.guess_extension(mime_type)
+        ext = guessed or ''
+        filename = f"{name}{ext}"
+    return filename
+
+def run_read_bigquery(query, credentials, num_tries = 5, wait_secs = 5):
+    """
+    Execute a BigQuery query, retrying on concurrent-update errors.
+    """
+    bq_client = bigquery.Client(credentials = credentials)
+    for attempt in range(1, num_tries + 1):
+      try:
+          df = (bq_client.query(query)  # executes the query
+             .result()                  # waits for completion
+             .to_dataframe())
+          return df
+      except google.api_core.exceptions.BadRequest as e:
+          if 'due to concurrent update' in str(e) and attempt < num_tries:
+              time.sleep(wait_secs)
+          else:
+              raise e
+
+def transfer_file(row, bucket, turn_headers):
+    uri = row['uri']
+    media_type = row['media_type']
+    mime_type = row['mime_type']
+    phone = row['channel_phone']
+    headers = turn_headers[phone]
+
+    if headers is None:
+        return
+
+    response = requests.get(uri, headers=headers)
+    if response.status_code == 200:
+        filename = derive_filename(uri, mime_type)
+        destination = f"{media_type}/{filename}"
+        blob = bucket.blob(destination)
+        blob.upload_from_string(
+            response.content,
+            content_type=mimetypes.guess_type(filename)[0] or "application/octet-stream"
+        )
+
+def get_slack_message_text(error: Exception):
+    text = (
+        f":warning: Sync failed with the following error:"
+        f"\n\n`{str(error)}`"
+    )
+    run_url = os.getenv('RUN_URL')
+    if run_url:
+        text += f"\n\nSee the GitHub Actions <{run_url}|workflow log>."
+    return text
 
 def send_message_to_slack(text, channel_id, token):
   client = slack_sdk.WebClient(token = token)
diff --git a/uv.lock b/uv.lock
index 7505dfe..64afc53 100644
--- a/uv.lock
+++ b/uv.lock
@@ -1,22 +1,23 @@
 version = 1
+revision = 2
 requires-python = ">=3.13"
 
 [[package]]
 name = "cachetools"
 version = "5.5.0"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/c3/38/a0f315319737ecf45b4319a8cd1f3a908e29d9277b46942263292115eee7/cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a", size = 27661 }
+sdist = { url = "https://files.pythonhosted.org/packages/c3/38/a0f315319737ecf45b4319a8cd1f3a908e29d9277b46942263292115eee7/cachetools-5.5.0.tar.gz", hash = "sha256:2cc24fb4cbe39633fb7badd9db9ca6295d766d9c2995f245725a46715d050f2a", size = 27661, upload-time = "2024-08-18T20:28:44.639Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/a4/07/14f8ad37f2d12a5ce41206c21820d8cb6561b728e51fad4530dff0552a67/cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292", size = 9524 },
+    { url = "https://files.pythonhosted.org/packages/a4/07/14f8ad37f2d12a5ce41206c21820d8cb6561b728e51fad4530dff0552a67/cachetools-5.5.0-py3-none-any.whl", hash = "sha256:02134e8439cdc2ffb62023ce1debca2944c3f289d66bb17ead3ab3dede74b292", size = 9524, upload-time = "2024-08-18T20:28:43.404Z" },
 ]
 
 [[package]]
 name = "certifi"
 version = "2024.12.14"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/0f/bd/1d41ee578ce09523c81a15426705dd20969f5abf006d1afe8aeff0dd776a/certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db", size = 166010 }
+sdist = { url = "https://files.pythonhosted.org/packages/0f/bd/1d41ee578ce09523c81a15426705dd20969f5abf006d1afe8aeff0dd776a/certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db", size = 166010, upload-time = "2024-12-14T13:52:38.02Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/a5/32/8f6669fc4798494966bf446c8c4a162e0b5d893dff088afddf76414f70e1/certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", size = 164927 },
+    { url = "https://files.pythonhosted.org/packages/a5/32/8f6669fc4798494966bf446c8c4a162e0b5d893dff088afddf76414f70e1/certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", size = 164927, upload-time = "2024-12-14T13:52:36.114Z" },
 ]
 
 [[package]]
@@ -26,50 +27,41 @@ source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "pycparser" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621 }
+sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989 },
-    { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802 },
-    { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792 },
-    { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893 },
-    { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810 },
-    { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200 },
-    { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447 },
-    { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358 },
-    { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469 },
-    { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475 },
-    { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009 },
+    { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" },
+    { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" },
+    { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" },
+    { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" },
+    { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" },
+    { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" },
+    { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" },
+    { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" },
+    { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" },
+    { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" },
+    { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" },
 ]
 
 [[package]]
 name = "charset-normalizer"
 version = "3.4.1"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 }
+sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188, upload-time = "2024-12-24T18:12:35.43Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 },
-    { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 },
-    { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 },
-    { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 },
-    { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 },
-    { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 },
-    { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 },
-    { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 },
-    { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 },
-    { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 },
-    { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 },
-    { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 },
-    { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 },
-    { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 },
-]
-
-[[package]]
-name = "colorama"
-version = "0.4.6"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
+    { url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698, upload-time = "2024-12-24T18:11:05.834Z" },
+    { url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162, upload-time = "2024-12-24T18:11:07.064Z" },
+    { url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263, upload-time = "2024-12-24T18:11:08.374Z" },
+    { url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966, upload-time = "2024-12-24T18:11:09.831Z" },
+    { url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992, upload-time = "2024-12-24T18:11:12.03Z" },
+    { url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162, upload-time = "2024-12-24T18:11:13.372Z" },
+    { url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972, upload-time = "2024-12-24T18:11:14.628Z" },
+    { url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095, upload-time = "2024-12-24T18:11:17.672Z" },
+    { url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668, upload-time = "2024-12-24T18:11:18.989Z" },
+    { url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073, upload-time = "2024-12-24T18:11:21.507Z" },
+    { url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732, upload-time = "2024-12-24T18:11:22.774Z" },
+    { url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391, upload-time = "2024-12-24T18:11:24.139Z" },
+    { url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702, upload-time = "2024-12-24T18:11:26.535Z" },
+    { url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767, upload-time = "2024-12-24T18:12:32.852Z" },
 ]
 
 [[package]]
@@ -79,28 +71,43 @@ source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/91/4c/45dfa6829acffa344e3967d6006ee4ae8be57af746ae2eba1c431949b32c/cryptography-44.0.0.tar.gz", hash = "sha256:cd4e834f340b4293430701e772ec543b0fbe6c2dea510a5286fe0acabe153a02", size = 710657 }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/55/09/8cc67f9b84730ad330b3b72cf867150744bf07ff113cda21a15a1c6d2c7c/cryptography-44.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:84111ad4ff3f6253820e6d3e58be2cc2a00adb29335d4cacb5ab4d4d34f2a123", size = 6541833 },
-    { url = "https://files.pythonhosted.org/packages/7e/5b/3759e30a103144e29632e7cb72aec28cedc79e514b2ea8896bb17163c19b/cryptography-44.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15492a11f9e1b62ba9d73c210e2416724633167de94607ec6069ef724fad092", size = 3922710 },
-    { url = "https://files.pythonhosted.org/packages/5f/58/3b14bf39f1a0cfd679e753e8647ada56cddbf5acebffe7db90e184c76168/cryptography-44.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:831c3c4d0774e488fdc83a1923b49b9957d33287de923d58ebd3cec47a0ae43f", size = 4137546 },
-    { url = "https://files.pythonhosted.org/packages/98/65/13d9e76ca19b0ba5603d71ac8424b5694415b348e719db277b5edc985ff5/cryptography-44.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:761817a3377ef15ac23cd7834715081791d4ec77f9297ee694ca1ee9c2c7e5eb", size = 3915420 },
-    { url = "https://files.pythonhosted.org/packages/b1/07/40fe09ce96b91fc9276a9ad272832ead0fddedcba87f1190372af8e3039c/cryptography-44.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3c672a53c0fb4725a29c303be906d3c1fa99c32f58abe008a82705f9ee96f40b", size = 4154498 },
-    { url = "https://files.pythonhosted.org/packages/75/ea/af65619c800ec0a7e4034207aec543acdf248d9bffba0533342d1bd435e1/cryptography-44.0.0-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4ac4c9f37eba52cb6fbeaf5b59c152ea976726b865bd4cf87883a7e7006cc543", size = 3932569 },
-    { url = "https://files.pythonhosted.org/packages/c7/af/d1deb0c04d59612e3d5e54203159e284d3e7a6921e565bb0eeb6269bdd8a/cryptography-44.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ed3534eb1090483c96178fcb0f8893719d96d5274dfde98aa6add34614e97c8e", size = 4016721 },
-    { url = "https://files.pythonhosted.org/packages/bd/69/7ca326c55698d0688db867795134bdfac87136b80ef373aaa42b225d6dd5/cryptography-44.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f3f6fdfa89ee2d9d496e2c087cebef9d4fcbb0ad63c40e821b39f74bf48d9c5e", size = 4240915 },
-    { url = "https://files.pythonhosted.org/packages/ef/d4/cae11bf68c0f981e0413906c6dd03ae7fa864347ed5fac40021df1ef467c/cryptography-44.0.0-cp37-abi3-win32.whl", hash = "sha256:eb33480f1bad5b78233b0ad3e1b0be21e8ef1da745d8d2aecbb20671658b9053", size = 2757925 },
-    { url = "https://files.pythonhosted.org/packages/64/b1/50d7739254d2002acae64eed4fc43b24ac0cc44bf0a0d388d1ca06ec5bb1/cryptography-44.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:abc998e0c0eee3c8a1904221d3f67dcfa76422b23620173e28c11d3e626c21bd", size = 3202055 },
-    { url = "https://files.pythonhosted.org/packages/11/18/61e52a3d28fc1514a43b0ac291177acd1b4de00e9301aaf7ef867076ff8a/cryptography-44.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:660cb7312a08bc38be15b696462fa7cc7cd85c3ed9c576e81f4dc4d8b2b31591", size = 6542801 },
-    { url = "https://files.pythonhosted.org/packages/1a/07/5f165b6c65696ef75601b781a280fc3b33f1e0cd6aa5a92d9fb96c410e97/cryptography-44.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1923cb251c04be85eec9fda837661c67c1049063305d6be5721643c22dd4e2b7", size = 3922613 },
-    { url = "https://files.pythonhosted.org/packages/28/34/6b3ac1d80fc174812486561cf25194338151780f27e438526f9c64e16869/cryptography-44.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:404fdc66ee5f83a1388be54300ae978b2efd538018de18556dde92575e05defc", size = 4137925 },
-    { url = "https://files.pythonhosted.org/packages/d0/c7/c656eb08fd22255d21bc3129625ed9cd5ee305f33752ef2278711b3fa98b/cryptography-44.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5eb858beed7835e5ad1faba59e865109f3e52b3783b9ac21e7e47dc5554e289", size = 3915417 },
-    { url = "https://files.pythonhosted.org/packages/ef/82/72403624f197af0db6bac4e58153bc9ac0e6020e57234115db9596eee85d/cryptography-44.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f53c2c87e0fb4b0c00fa9571082a057e37690a8f12233306161c8f4b819960b7", size = 4155160 },
-    { url = "https://files.pythonhosted.org/packages/a2/cd/2f3c440913d4329ade49b146d74f2e9766422e1732613f57097fea61f344/cryptography-44.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e6fc8a08e116fb7c7dd1f040074c9d7b51d74a8ea40d4df2fc7aa08b76b9e6c", size = 3932331 },
-    { url = "https://files.pythonhosted.org/packages/7f/df/8be88797f0a1cca6e255189a57bb49237402b1880d6e8721690c5603ac23/cryptography-44.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d2436114e46b36d00f8b72ff57e598978b37399d2786fd39793c36c6d5cb1c64", size = 4017372 },
-    { url = "https://files.pythonhosted.org/packages/af/36/5ccc376f025a834e72b8e52e18746b927f34e4520487098e283a719c205e/cryptography-44.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a01956ddfa0a6790d594f5b34fc1bfa6098aca434696a03cfdbe469b8ed79285", size = 4239657 },
-    { url = "https://files.pythonhosted.org/packages/46/b0/f4f7d0d0bcfbc8dd6296c1449be326d04217c57afb8b2594f017eed95533/cryptography-44.0.0-cp39-abi3-win32.whl", hash = "sha256:eca27345e1214d1b9f9490d200f9db5a874479be914199194e746c893788d417", size = 2758672 },
-    { url = "https://files.pythonhosted.org/packages/97/9b/443270b9210f13f6ef240eff73fd32e02d381e7103969dc66ce8e89ee901/cryptography-44.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:708ee5f1bafe76d041b53a4f95eb28cdeb8d18da17e597d46d7833ee59b97ede", size = 3202071 },
+sdist = { url = "https://files.pythonhosted.org/packages/91/4c/45dfa6829acffa344e3967d6006ee4ae8be57af746ae2eba1c431949b32c/cryptography-44.0.0.tar.gz", hash = "sha256:cd4e834f340b4293430701e772ec543b0fbe6c2dea510a5286fe0acabe153a02", size = 710657, upload-time = "2024-11-27T18:07:10.168Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/55/09/8cc67f9b84730ad330b3b72cf867150744bf07ff113cda21a15a1c6d2c7c/cryptography-44.0.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:84111ad4ff3f6253820e6d3e58be2cc2a00adb29335d4cacb5ab4d4d34f2a123", size = 6541833, upload-time = "2024-11-27T18:05:55.475Z" },
+    { url = "https://files.pythonhosted.org/packages/7e/5b/3759e30a103144e29632e7cb72aec28cedc79e514b2ea8896bb17163c19b/cryptography-44.0.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b15492a11f9e1b62ba9d73c210e2416724633167de94607ec6069ef724fad092", size = 3922710, upload-time = "2024-11-27T18:05:58.621Z" },
+    { url = "https://files.pythonhosted.org/packages/5f/58/3b14bf39f1a0cfd679e753e8647ada56cddbf5acebffe7db90e184c76168/cryptography-44.0.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:831c3c4d0774e488fdc83a1923b49b9957d33287de923d58ebd3cec47a0ae43f", size = 4137546, upload-time = "2024-11-27T18:06:01.062Z" },
+    { url = "https://files.pythonhosted.org/packages/98/65/13d9e76ca19b0ba5603d71ac8424b5694415b348e719db277b5edc985ff5/cryptography-44.0.0-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:761817a3377ef15ac23cd7834715081791d4ec77f9297ee694ca1ee9c2c7e5eb", size = 3915420, upload-time = "2024-11-27T18:06:03.487Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/07/40fe09ce96b91fc9276a9ad272832ead0fddedcba87f1190372af8e3039c/cryptography-44.0.0-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:3c672a53c0fb4725a29c303be906d3c1fa99c32f58abe008a82705f9ee96f40b", size = 4154498, upload-time = "2024-11-27T18:06:05.763Z" },
+    { url = "https://files.pythonhosted.org/packages/75/ea/af65619c800ec0a7e4034207aec543acdf248d9bffba0533342d1bd435e1/cryptography-44.0.0-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:4ac4c9f37eba52cb6fbeaf5b59c152ea976726b865bd4cf87883a7e7006cc543", size = 3932569, upload-time = "2024-11-27T18:06:07.489Z" },
+    { url = "https://files.pythonhosted.org/packages/c7/af/d1deb0c04d59612e3d5e54203159e284d3e7a6921e565bb0eeb6269bdd8a/cryptography-44.0.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ed3534eb1090483c96178fcb0f8893719d96d5274dfde98aa6add34614e97c8e", size = 4016721, upload-time = "2024-11-27T18:06:11.57Z" },
+    { url = "https://files.pythonhosted.org/packages/bd/69/7ca326c55698d0688db867795134bdfac87136b80ef373aaa42b225d6dd5/cryptography-44.0.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:f3f6fdfa89ee2d9d496e2c087cebef9d4fcbb0ad63c40e821b39f74bf48d9c5e", size = 4240915, upload-time = "2024-11-27T18:06:13.515Z" },
+    { url = "https://files.pythonhosted.org/packages/ef/d4/cae11bf68c0f981e0413906c6dd03ae7fa864347ed5fac40021df1ef467c/cryptography-44.0.0-cp37-abi3-win32.whl", hash = "sha256:eb33480f1bad5b78233b0ad3e1b0be21e8ef1da745d8d2aecbb20671658b9053", size = 2757925, upload-time = "2024-11-27T18:06:16.019Z" },
+    { url = "https://files.pythonhosted.org/packages/64/b1/50d7739254d2002acae64eed4fc43b24ac0cc44bf0a0d388d1ca06ec5bb1/cryptography-44.0.0-cp37-abi3-win_amd64.whl", hash = "sha256:abc998e0c0eee3c8a1904221d3f67dcfa76422b23620173e28c11d3e626c21bd", size = 3202055, upload-time = "2024-11-27T18:06:19.113Z" },
+    { url = "https://files.pythonhosted.org/packages/11/18/61e52a3d28fc1514a43b0ac291177acd1b4de00e9301aaf7ef867076ff8a/cryptography-44.0.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:660cb7312a08bc38be15b696462fa7cc7cd85c3ed9c576e81f4dc4d8b2b31591", size = 6542801, upload-time = "2024-11-27T18:06:21.431Z" },
+    { url = "https://files.pythonhosted.org/packages/1a/07/5f165b6c65696ef75601b781a280fc3b33f1e0cd6aa5a92d9fb96c410e97/cryptography-44.0.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1923cb251c04be85eec9fda837661c67c1049063305d6be5721643c22dd4e2b7", size = 3922613, upload-time = "2024-11-27T18:06:24.314Z" },
+    { url = "https://files.pythonhosted.org/packages/28/34/6b3ac1d80fc174812486561cf25194338151780f27e438526f9c64e16869/cryptography-44.0.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:404fdc66ee5f83a1388be54300ae978b2efd538018de18556dde92575e05defc", size = 4137925, upload-time = "2024-11-27T18:06:27.079Z" },
+    { url = "https://files.pythonhosted.org/packages/d0/c7/c656eb08fd22255d21bc3129625ed9cd5ee305f33752ef2278711b3fa98b/cryptography-44.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:c5eb858beed7835e5ad1faba59e865109f3e52b3783b9ac21e7e47dc5554e289", size = 3915417, upload-time = "2024-11-27T18:06:28.959Z" },
+    { url = "https://files.pythonhosted.org/packages/ef/82/72403624f197af0db6bac4e58153bc9ac0e6020e57234115db9596eee85d/cryptography-44.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f53c2c87e0fb4b0c00fa9571082a057e37690a8f12233306161c8f4b819960b7", size = 4155160, upload-time = "2024-11-27T18:06:30.866Z" },
+    { url = "https://files.pythonhosted.org/packages/a2/cd/2f3c440913d4329ade49b146d74f2e9766422e1732613f57097fea61f344/cryptography-44.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:9e6fc8a08e116fb7c7dd1f040074c9d7b51d74a8ea40d4df2fc7aa08b76b9e6c", size = 3932331, upload-time = "2024-11-27T18:06:33.432Z" },
+    { url = "https://files.pythonhosted.org/packages/7f/df/8be88797f0a1cca6e255189a57bb49237402b1880d6e8721690c5603ac23/cryptography-44.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d2436114e46b36d00f8b72ff57e598978b37399d2786fd39793c36c6d5cb1c64", size = 4017372, upload-time = "2024-11-27T18:06:38.343Z" },
+    { url = "https://files.pythonhosted.org/packages/af/36/5ccc376f025a834e72b8e52e18746b927f34e4520487098e283a719c205e/cryptography-44.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a01956ddfa0a6790d594f5b34fc1bfa6098aca434696a03cfdbe469b8ed79285", size = 4239657, upload-time = "2024-11-27T18:06:41.045Z" },
+    { url = "https://files.pythonhosted.org/packages/46/b0/f4f7d0d0bcfbc8dd6296c1449be326d04217c57afb8b2594f017eed95533/cryptography-44.0.0-cp39-abi3-win32.whl", hash = "sha256:eca27345e1214d1b9f9490d200f9db5a874479be914199194e746c893788d417", size = 2758672, upload-time = "2024-11-27T18:06:43.566Z" },
+    { url = "https://files.pythonhosted.org/packages/97/9b/443270b9210f13f6ef240eff73fd32e02d381e7103969dc66ce8e89ee901/cryptography-44.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:708ee5f1bafe76d041b53a4f95eb28cdeb8d18da17e597d46d7833ee59b97ede", size = 3202071, upload-time = "2024-11-27T18:06:45.586Z" },
+]
+
+[[package]]
+name = "db-dtypes"
+version = "1.4.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "numpy" },
+    { name = "packaging" },
+    { name = "pandas" },
+    { name = "pyarrow" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/7d/c8/83fd7a11ef8d960da6c58d1a36b430b0a1818ae7a1da6efe887d96c35f40/db_dtypes-1.4.3.tar.gz", hash = "sha256:d3cb08c32939d24e05501f17694be8c1c54cfb4620d61fb56fb311e8c3d8885e", size = 33379, upload-time = "2025-05-12T13:54:21.736Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/db/91/66065d933b4814295fd0ddc16a66ef193dff14bf8d15895723f38640a3ab/db_dtypes-1.4.3-py3-none-any.whl", hash = "sha256:a1c92b819af947fae1701d80a71f2a0eac08f825ca52cf0c68aeba80577ae966", size = 18110, upload-time = "2025-05-12T13:54:20.146Z" },
 ]
 
 [[package]]
@@ -110,9 +117,9 @@ source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "wrapt" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/2e/a3/53e7d78a6850ffdd394d7048a31a6f14e44900adedf190f9a165f6b69439/deprecated-1.2.15.tar.gz", hash = "sha256:683e561a90de76239796e6b6feac66b99030d2dd3fcf61ef996330f14bbb9b0d", size = 2977612 }
+sdist = { url = "https://files.pythonhosted.org/packages/2e/a3/53e7d78a6850ffdd394d7048a31a6f14e44900adedf190f9a165f6b69439/deprecated-1.2.15.tar.gz", hash = "sha256:683e561a90de76239796e6b6feac66b99030d2dd3fcf61ef996330f14bbb9b0d", size = 2977612, upload-time = "2024-11-15T14:42:06.39Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/1d/8f/c7f227eb42cfeaddce3eb0c96c60cbca37797fa7b34f8e1aeadf6c5c0983/Deprecated-1.2.15-py2.py3-none-any.whl", hash = "sha256:353bc4a8ac4bfc96800ddab349d89c25dec1079f65fd53acdcc1e0b975b21320", size = 9941 },
+    { url = "https://files.pythonhosted.org/packages/1d/8f/c7f227eb42cfeaddce3eb0c96c60cbca37797fa7b34f8e1aeadf6c5c0983/Deprecated-1.2.15-py2.py3-none-any.whl", hash = "sha256:353bc4a8ac4bfc96800ddab349d89c25dec1079f65fd53acdcc1e0b975b21320", size = 9941, upload-time = "2024-11-15T14:42:03.315Z" },
 ]
 
 [[package]]
@@ -126,9 +133,9 @@ dependencies = [
     { name = "protobuf" },
     { name = "requests" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/81/56/d70d66ed1b5ab5f6c27bf80ec889585ad8f865ff32acbafd3b2ef0bfb5d0/google_api_core-2.24.0.tar.gz", hash = "sha256:e255640547a597a4da010876d333208ddac417d60add22b6851a0c66a831fcaf", size = 162647 }
+sdist = { url = "https://files.pythonhosted.org/packages/81/56/d70d66ed1b5ab5f6c27bf80ec889585ad8f865ff32acbafd3b2ef0bfb5d0/google_api_core-2.24.0.tar.gz", hash = "sha256:e255640547a597a4da010876d333208ddac417d60add22b6851a0c66a831fcaf", size = 162647, upload-time = "2024-12-09T20:19:37.995Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/a1/76/65b8b94e74bf1b6d1cc38d916089670c4da5029d25762441d8c5c19e51dd/google_api_core-2.24.0-py3-none-any.whl", hash = "sha256:10d82ac0fca69c82a25b3efdeefccf6f28e02ebb97925a8cce8edbfe379929d9", size = 158576 },
+    { url = "https://files.pythonhosted.org/packages/a1/76/65b8b94e74bf1b6d1cc38d916089670c4da5029d25762441d8c5c19e51dd/google_api_core-2.24.0-py3-none-any.whl", hash = "sha256:10d82ac0fca69c82a25b3efdeefccf6f28e02ebb97925a8cce8edbfe379929d9", size = 158576, upload-time = "2024-12-09T20:19:35.905Z" },
 ]
 
 [package.optional-dependencies]
@@ -146,9 +153,9 @@ dependencies = [
     { name = "pyasn1-modules" },
     { name = "rsa" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/46/af/b25763b9d35dfc2c6f9c3ec34d8d3f1ba760af3a7b7e8d5c5f0579522c45/google_auth-2.37.0.tar.gz", hash = "sha256:0054623abf1f9c83492c63d3f47e77f0a544caa3d40b2d98e099a611c2dd5d00", size = 268878 }
+sdist = { url = "https://files.pythonhosted.org/packages/46/af/b25763b9d35dfc2c6f9c3ec34d8d3f1ba760af3a7b7e8d5c5f0579522c45/google_auth-2.37.0.tar.gz", hash = "sha256:0054623abf1f9c83492c63d3f47e77f0a544caa3d40b2d98e099a611c2dd5d00", size = 268878, upload-time = "2024-12-11T20:14:35.058Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/8d/8d/4d5d5f9f500499f7bd4c93903b43e8d6976f3fc6f064637ded1a85d09b07/google_auth-2.37.0-py2.py3-none-any.whl", hash = "sha256:42664f18290a6be591be5329a96fe30184be1a1badb7292a7f686a9659de9ca0", size = 209829 },
+    { url = "https://files.pythonhosted.org/packages/8d/8d/4d5d5f9f500499f7bd4c93903b43e8d6976f3fc6f064637ded1a85d09b07/google_auth-2.37.0-py2.py3-none-any.whl", hash = "sha256:42664f18290a6be591be5329a96fe30184be1a1badb7292a7f686a9659de9ca0", size = 209829, upload-time = "2024-12-11T20:14:32.657Z" },
 ]
 
 [[package]]
@@ -164,29 +171,61 @@ dependencies = [
     { name = "python-dateutil" },
     { name = "requests" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/c0/05/633ce6686b1fed2cd364fa4698bfa6d586263cd4795d012584f8097061e1/google_cloud_bigquery-3.27.0.tar.gz", hash = "sha256:379c524054d7b090fa56d0c22662cc6e6458a6229b6754c0e7177e3a73421d2c", size = 456964 }
+sdist = { url = "https://files.pythonhosted.org/packages/c0/05/633ce6686b1fed2cd364fa4698bfa6d586263cd4795d012584f8097061e1/google_cloud_bigquery-3.27.0.tar.gz", hash = "sha256:379c524054d7b090fa56d0c22662cc6e6458a6229b6754c0e7177e3a73421d2c", size = 456964, upload-time = "2024-11-11T16:50:00.712Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/f5/40/4b11a4a8839de8ce802a3ccd60b34e70ce10d13d434a560534ba98f0ea3f/google_cloud_bigquery-3.27.0-py2.py3-none-any.whl", hash = "sha256:b53b0431e5ba362976a4cd8acce72194b4116cdf8115030c7b339b884603fcc3", size = 240100, upload-time = "2024-11-11T16:49:58.805Z" },
+]
+
+[[package]]
+name = "google-cloud-bigquery-storage"
+version = "2.31.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "google-api-core", extra = ["grpc"] },
+    { name = "google-auth" },
+    { name = "proto-plus" },
+    { name = "protobuf" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/de/a1/fc51fd5db62aa6ebd2a20cac239adf636061d3b640bf0b4c0847c476acea/google_cloud_bigquery_storage-2.31.0.tar.gz", hash = "sha256:e4b42df3374dbc9575268c89d5dec47fced075c44904c463b12aed2b01be6790", size = 256446, upload-time = "2025-04-23T17:20:12.51Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/f5/40/4b11a4a8839de8ce802a3ccd60b34e70ce10d13d434a560534ba98f0ea3f/google_cloud_bigquery-3.27.0-py2.py3-none-any.whl", hash = "sha256:b53b0431e5ba362976a4cd8acce72194b4116cdf8115030c7b339b884603fcc3", size = 240100 },
+    { url = "https://files.pythonhosted.org/packages/eb/60/8fd89f8874f4717435508ee719a888cfd24132f7ff4530b9effedede80cf/google_cloud_bigquery_storage-2.31.0-py3-none-any.whl", hash = "sha256:1721792f39f5ecb49b8503cf197ee8ab79f7deebf17fc4a4a44585959336365b", size = 256459, upload-time = "2025-04-23T17:20:11.07Z" },
 ]
 
 [[package]]
 name = "google-cloud-core"
-version = "2.4.1"
+version = "2.4.3"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+    { name = "google-api-core" },
+    { name = "google-auth" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/d6/b8/2b53838d2acd6ec6168fd284a990c76695e84c65deee79c9f3a4276f6b4f/google_cloud_core-2.4.3.tar.gz", hash = "sha256:1fab62d7102844b278fe6dead3af32408b1df3eb06f5c7e8634cbd40edc4da53", size = 35861, upload-time = "2025-03-10T21:05:38.948Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/40/86/bda7241a8da2d28a754aad2ba0f6776e35b67e37c36ae0c45d49370f1014/google_cloud_core-2.4.3-py2.py3-none-any.whl", hash = "sha256:5130f9f4c14b4fafdff75c79448f9495cfade0d8775facf1b09c3bf67e027f6e", size = 29348, upload-time = "2025-03-10T21:05:37.785Z" },
+]
+
+[[package]]
+name = "google-cloud-storage"
+version = "3.1.0"
 source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "google-api-core" },
     { name = "google-auth" },
+    { name = "google-cloud-core" },
+    { name = "google-crc32c" },
+    { name = "google-resumable-media" },
+    { name = "requests" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/b8/1f/9d1e0ba6919668608570418a9a51e47070ac15aeff64261fb092d8be94c0/google-cloud-core-2.4.1.tar.gz", hash = "sha256:9b7749272a812bde58fff28868d0c5e2f585b82f37e09a1f6ed2d4d10f134073", size = 35587 }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/08/52143124415a889bbab60a8ecede1e31ea0e8d992ca078317886f26dc3be/google_cloud_storage-3.1.0.tar.gz", hash = "sha256:944273179897c7c8a07ee15f2e6466a02da0c7c4b9ecceac2a26017cb2972049", size = 7666527, upload-time = "2025-02-28T00:20:02.383Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/5e/0f/2e2061e3fbcb9d535d5da3f58cc8de4947df1786fe6a1355960feb05a681/google_cloud_core-2.4.1-py2.py3-none-any.whl", hash = "sha256:a9e6a4422b9ac5c29f79a0ede9485473338e2ce78d91f2370c01e730eab22e61", size = 29233 },
+    { url = "https://files.pythonhosted.org/packages/13/b8/c99c965659f45efa73080477c49ffddf7b9aecb00806be8422560bb5b824/google_cloud_storage-3.1.0-py2.py3-none-any.whl", hash = "sha256:eaf36966b68660a9633f03b067e4a10ce09f1377cae3ff9f2c699f69a81c66c6", size = 174861, upload-time = "2025-02-28T00:19:59.459Z" },
 ]
 
 [[package]]
 name = "google-crc32c"
 version = "1.6.0"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/67/72/c3298da1a3773102359c5a78f20dae8925f5ea876e37354415f68594a6fb/google_crc32c-1.6.0.tar.gz", hash = "sha256:6eceb6ad197656a1ff49ebfbbfa870678c75be4344feb35ac1edf694309413dc", size = 14472 }
+sdist = { url = "https://files.pythonhosted.org/packages/67/72/c3298da1a3773102359c5a78f20dae8925f5ea876e37354415f68594a6fb/google_crc32c-1.6.0.tar.gz", hash = "sha256:6eceb6ad197656a1ff49ebfbbfa870678c75be4344feb35ac1edf694309413dc", size = 14472, upload-time = "2024-09-03T11:44:35.585Z" }
 
 [[package]]
 name = "google-resumable-media"
@@ -195,9 +234,9 @@ source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "google-crc32c" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/58/5a/0efdc02665dca14e0837b62c8a1a93132c264bd02054a15abb2218afe0ae/google_resumable_media-2.7.2.tar.gz", hash = "sha256:5280aed4629f2b60b847b0d42f9857fd4935c11af266744df33d8074cae92fe0", size = 2163099 }
+sdist = { url = "https://files.pythonhosted.org/packages/58/5a/0efdc02665dca14e0837b62c8a1a93132c264bd02054a15abb2218afe0ae/google_resumable_media-2.7.2.tar.gz", hash = "sha256:5280aed4629f2b60b847b0d42f9857fd4935c11af266744df33d8074cae92fe0", size = 2163099, upload-time = "2024-08-07T22:20:38.555Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/82/35/b8d3baf8c46695858cb9d8835a53baa1eeb9906ddaf2f728a5f5b640fd1e/google_resumable_media-2.7.2-py2.py3-none-any.whl", hash = "sha256:3ce7551e9fe6d99e9a126101d2536612bb73486721951e9562fee0f90c6ababa", size = 81251 },
+    { url = "https://files.pythonhosted.org/packages/82/35/b8d3baf8c46695858cb9d8835a53baa1eeb9906ddaf2f728a5f5b640fd1e/google_resumable_media-2.7.2-py2.py3-none-any.whl", hash = "sha256:3ce7551e9fe6d99e9a126101d2536612bb73486721951e9562fee0f90c6ababa", size = 81251, upload-time = "2024-08-07T22:20:36.409Z" },
 ]
 
 [[package]]
@@ -207,26 +246,26 @@ source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "protobuf" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/ff/a7/8e9cccdb1c49870de6faea2a2764fa23f627dd290633103540209f03524c/googleapis_common_protos-1.66.0.tar.gz", hash = "sha256:c3e7b33d15fdca5374cc0a7346dd92ffa847425cc4ea941d970f13680052ec8c", size = 114376 }
+sdist = { url = "https://files.pythonhosted.org/packages/ff/a7/8e9cccdb1c49870de6faea2a2764fa23f627dd290633103540209f03524c/googleapis_common_protos-1.66.0.tar.gz", hash = "sha256:c3e7b33d15fdca5374cc0a7346dd92ffa847425cc4ea941d970f13680052ec8c", size = 114376, upload-time = "2024-11-12T17:33:38.494Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/a0/0f/c0713fb2b3d28af4b2fded3291df1c4d4f79a00d15c2374a9e010870016c/googleapis_common_protos-1.66.0-py2.py3-none-any.whl", hash = "sha256:d7abcd75fabb2e0ec9f74466401f6c119a0b498e27370e9be4c94cb7e382b8ed", size = 221682 },
+    { url = "https://files.pythonhosted.org/packages/a0/0f/c0713fb2b3d28af4b2fded3291df1c4d4f79a00d15c2374a9e010870016c/googleapis_common_protos-1.66.0-py2.py3-none-any.whl", hash = "sha256:d7abcd75fabb2e0ec9f74466401f6c119a0b498e27370e9be4c94cb7e382b8ed", size = 221682, upload-time = "2024-11-12T17:33:37.067Z" },
 ]
 
 [[package]]
 name = "grpcio"
 version = "1.69.0"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e4/87/06a145284cbe86c91ca517fe6b57be5efbb733c0d6374b407f0992054d18/grpcio-1.69.0.tar.gz", hash = "sha256:936fa44241b5379c5afc344e1260d467bee495747eaf478de825bab2791da6f5", size = 12738244 }
+sdist = { url = "https://files.pythonhosted.org/packages/e4/87/06a145284cbe86c91ca517fe6b57be5efbb733c0d6374b407f0992054d18/grpcio-1.69.0.tar.gz", hash = "sha256:936fa44241b5379c5afc344e1260d467bee495747eaf478de825bab2791da6f5", size = 12738244, upload-time = "2025-01-05T05:53:20.27Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/54/47/3ff4501365f56b7cc16617695dbd4fd838c5e362bc7fa9fee09d592f7d78/grpcio-1.69.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:a78a06911d4081a24a1761d16215a08e9b6d4d29cdbb7e427e6c7e17b06bcc5d", size = 5162928 },
-    { url = "https://files.pythonhosted.org/packages/c0/63/437174c5fa951052c9ecc5f373f62af6f3baf25f3f5ef35cbf561806b371/grpcio-1.69.0-cp313-cp313-macosx_10_14_universal2.whl", hash = "sha256:dc5a351927d605b2721cbb46158e431dd49ce66ffbacb03e709dc07a491dde35", size = 11103027 },
-    { url = "https://files.pythonhosted.org/packages/53/df/53566a6fdc26b6d1f0585896e1cc4825961039bca5a6a314ff29d79b5d5b/grpcio-1.69.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:3629d8a8185f5139869a6a17865d03113a260e311e78fbe313f1a71603617589", size = 5659277 },
-    { url = "https://files.pythonhosted.org/packages/e6/4c/b8a0c4f71498b6f9be5ca6d290d576cf2af9d95fd9827c47364f023969ad/grpcio-1.69.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9a281878feeb9ae26db0622a19add03922a028d4db684658f16d546601a4870", size = 6305255 },
-    { url = "https://files.pythonhosted.org/packages/ef/55/d9aa05eb3dfcf6aa946aaf986740ec07fc5189f20e2cbeb8c5d278ffd00f/grpcio-1.69.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cc614e895177ab7e4b70f154d1a7c97e152577ea101d76026d132b7aaba003b", size = 5920240 },
-    { url = "https://files.pythonhosted.org/packages/ea/eb/774b27c51e3e386dfe6c491a710f6f87ffdb20d88ec6c3581e047d9354a2/grpcio-1.69.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:1ee76cd7e2e49cf9264f6812d8c9ac1b85dda0eaea063af07292400f9191750e", size = 6652974 },
-    { url = "https://files.pythonhosted.org/packages/59/98/96de14e6e7d89123813d58c246d9b0f1fbd24f9277f5295264e60861d9d6/grpcio-1.69.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:0470fa911c503af59ec8bc4c82b371ee4303ececbbdc055f55ce48e38b20fd67", size = 6215757 },
-    { url = "https://files.pythonhosted.org/packages/7d/5b/ce922e0785910b10756fabc51fd294260384a44bea41651dadc4e47ddc82/grpcio-1.69.0-cp313-cp313-win32.whl", hash = "sha256:b650f34aceac8b2d08a4c8d7dc3e8a593f4d9e26d86751ebf74ebf5107d927de", size = 3642488 },
-    { url = "https://files.pythonhosted.org/packages/5d/04/11329e6ca1ceeb276df2d9c316b5e170835a687a4d0f778dba8294657e36/grpcio-1.69.0-cp313-cp313-win_amd64.whl", hash = "sha256:028337786f11fecb5d7b7fa660475a06aabf7e5e52b5ac2df47414878c0ce7ea", size = 4399968 },
+    { url = "https://files.pythonhosted.org/packages/54/47/3ff4501365f56b7cc16617695dbd4fd838c5e362bc7fa9fee09d592f7d78/grpcio-1.69.0-cp313-cp313-linux_armv7l.whl", hash = "sha256:a78a06911d4081a24a1761d16215a08e9b6d4d29cdbb7e427e6c7e17b06bcc5d", size = 5162928, upload-time = "2025-01-05T05:47:02.894Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/63/437174c5fa951052c9ecc5f373f62af6f3baf25f3f5ef35cbf561806b371/grpcio-1.69.0-cp313-cp313-macosx_10_14_universal2.whl", hash = "sha256:dc5a351927d605b2721cbb46158e431dd49ce66ffbacb03e709dc07a491dde35", size = 11103027, upload-time = "2025-01-05T05:47:05.864Z" },
+    { url = "https://files.pythonhosted.org/packages/53/df/53566a6fdc26b6d1f0585896e1cc4825961039bca5a6a314ff29d79b5d5b/grpcio-1.69.0-cp313-cp313-manylinux_2_17_aarch64.whl", hash = "sha256:3629d8a8185f5139869a6a17865d03113a260e311e78fbe313f1a71603617589", size = 5659277, upload-time = "2025-01-05T05:47:09.235Z" },
+    { url = "https://files.pythonhosted.org/packages/e6/4c/b8a0c4f71498b6f9be5ca6d290d576cf2af9d95fd9827c47364f023969ad/grpcio-1.69.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c9a281878feeb9ae26db0622a19add03922a028d4db684658f16d546601a4870", size = 6305255, upload-time = "2025-01-05T05:47:15.997Z" },
+    { url = "https://files.pythonhosted.org/packages/ef/55/d9aa05eb3dfcf6aa946aaf986740ec07fc5189f20e2cbeb8c5d278ffd00f/grpcio-1.69.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cc614e895177ab7e4b70f154d1a7c97e152577ea101d76026d132b7aaba003b", size = 5920240, upload-time = "2025-01-05T05:47:20.611Z" },
+    { url = "https://files.pythonhosted.org/packages/ea/eb/774b27c51e3e386dfe6c491a710f6f87ffdb20d88ec6c3581e047d9354a2/grpcio-1.69.0-cp313-cp313-musllinux_1_1_i686.whl", hash = "sha256:1ee76cd7e2e49cf9264f6812d8c9ac1b85dda0eaea063af07292400f9191750e", size = 6652974, upload-time = "2025-01-05T05:47:25.562Z" },
+    { url = "https://files.pythonhosted.org/packages/59/98/96de14e6e7d89123813d58c246d9b0f1fbd24f9277f5295264e60861d9d6/grpcio-1.69.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:0470fa911c503af59ec8bc4c82b371ee4303ececbbdc055f55ce48e38b20fd67", size = 6215757, upload-time = "2025-01-05T05:47:30.013Z" },
+    { url = "https://files.pythonhosted.org/packages/7d/5b/ce922e0785910b10756fabc51fd294260384a44bea41651dadc4e47ddc82/grpcio-1.69.0-cp313-cp313-win32.whl", hash = "sha256:b650f34aceac8b2d08a4c8d7dc3e8a593f4d9e26d86751ebf74ebf5107d927de", size = 3642488, upload-time = "2025-01-05T05:47:34.376Z" },
+    { url = "https://files.pythonhosted.org/packages/5d/04/11329e6ca1ceeb276df2d9c316b5e170835a687a4d0f778dba8294657e36/grpcio-1.69.0-cp313-cp313-win_amd64.whl", hash = "sha256:028337786f11fecb5d7b7fa660475a06aabf7e5e52b5ac2df47414878c0ce7ea", size = 4399968, upload-time = "2025-01-05T05:47:38.496Z" },
 ]
 
 [[package]]
@@ -238,18 +277,46 @@ dependencies = [
     { name = "grpcio" },
     { name = "protobuf" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/02/35/52dc0d8300f879dbf9cdc95764cee9f56d5a212998cfa1a8871b262df2a4/grpcio_status-1.69.0.tar.gz", hash = "sha256:595ef84e5178d6281caa732ccf68ff83259241608d26b0e9c40a5e66eee2a2d2", size = 13662 }
+sdist = { url = "https://files.pythonhosted.org/packages/02/35/52dc0d8300f879dbf9cdc95764cee9f56d5a212998cfa1a8871b262df2a4/grpcio_status-1.69.0.tar.gz", hash = "sha256:595ef84e5178d6281caa732ccf68ff83259241608d26b0e9c40a5e66eee2a2d2", size = 13662, upload-time = "2025-01-05T05:53:36.931Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/f6/e2/346a766a4232f74f45f8bc70e636fc3a6677e6bc3893382187829085f12e/grpcio_status-1.69.0-py3-none-any.whl", hash = "sha256:d6b2a3c9562c03a817c628d7ba9a925e209c228762d6d7677ae5c9401a542853", size = 14428 },
+    { url = "https://files.pythonhosted.org/packages/f6/e2/346a766a4232f74f45f8bc70e636fc3a6677e6bc3893382187829085f12e/grpcio_status-1.69.0-py3-none-any.whl", hash = "sha256:d6b2a3c9562c03a817c628d7ba9a925e209c228762d6d7677ae5c9401a542853", size = 14428, upload-time = "2025-01-05T05:50:29.538Z" },
 ]
 
 [[package]]
 name = "idna"
 version = "3.10"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 }
+sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
+    { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" },
+]
+
+[[package]]
+name = "numpy"
+version = "2.2.5"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/dc/b2/ce4b867d8cd9c0ee84938ae1e6a6f7926ebf928c9090d036fc3c6a04f946/numpy-2.2.5.tar.gz", hash = "sha256:a9c0d994680cd991b1cb772e8b297340085466a6fe964bc9d4e80f5e2f43c291", size = 20273920, upload-time = "2025-04-19T23:27:42.561Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/e2/a0/0aa7f0f4509a2e07bd7a509042967c2fab635690d4f48c6c7b3afd4f448c/numpy-2.2.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:059b51b658f4414fff78c6d7b1b4e18283ab5fa56d270ff212d5ba0c561846f4", size = 20935102, upload-time = "2025-04-19T22:41:16.234Z" },
+    { url = "https://files.pythonhosted.org/packages/7e/e4/a6a9f4537542912ec513185396fce52cdd45bdcf3e9d921ab02a93ca5aa9/numpy-2.2.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:47f9ed103af0bc63182609044b0490747e03bd20a67e391192dde119bf43d52f", size = 14191709, upload-time = "2025-04-19T22:41:38.472Z" },
+    { url = "https://files.pythonhosted.org/packages/be/65/72f3186b6050bbfe9c43cb81f9df59ae63603491d36179cf7a7c8d216758/numpy-2.2.5-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:261a1ef047751bb02f29dfe337230b5882b54521ca121fc7f62668133cb119c9", size = 5149173, upload-time = "2025-04-19T22:41:47.823Z" },
+    { url = "https://files.pythonhosted.org/packages/e5/e9/83e7a9432378dde5802651307ae5e9ea07bb72b416728202218cd4da2801/numpy-2.2.5-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4520caa3807c1ceb005d125a75e715567806fed67e315cea619d5ec6e75a4191", size = 6684502, upload-time = "2025-04-19T22:41:58.689Z" },
+    { url = "https://files.pythonhosted.org/packages/ea/27/b80da6c762394c8ee516b74c1f686fcd16c8f23b14de57ba0cad7349d1d2/numpy-2.2.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d14b17b9be5f9c9301f43d2e2a4886a33b53f4e6fdf9ca2f4cc60aeeee76372", size = 14084417, upload-time = "2025-04-19T22:42:19.897Z" },
+    { url = "https://files.pythonhosted.org/packages/aa/fc/ebfd32c3e124e6a1043e19c0ab0769818aa69050ce5589b63d05ff185526/numpy-2.2.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ba321813a00e508d5421104464510cc962a6f791aa2fca1c97b1e65027da80d", size = 16133807, upload-time = "2025-04-19T22:42:44.433Z" },
+    { url = "https://files.pythonhosted.org/packages/bf/9b/4cc171a0acbe4666f7775cfd21d4eb6bb1d36d3a0431f48a73e9212d2278/numpy-2.2.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4cbdef3ddf777423060c6f81b5694bad2dc9675f110c4b2a60dc0181543fac7", size = 15575611, upload-time = "2025-04-19T22:43:09.928Z" },
+    { url = "https://files.pythonhosted.org/packages/a3/45/40f4135341850df48f8edcf949cf47b523c404b712774f8855a64c96ef29/numpy-2.2.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:54088a5a147ab71a8e7fdfd8c3601972751ded0739c6b696ad9cb0343e21ab73", size = 17895747, upload-time = "2025-04-19T22:43:36.983Z" },
+    { url = "https://files.pythonhosted.org/packages/f8/4c/b32a17a46f0ffbde8cc82df6d3daeaf4f552e346df143e1b188a701a8f09/numpy-2.2.5-cp313-cp313-win32.whl", hash = "sha256:c8b82a55ef86a2d8e81b63da85e55f5537d2157165be1cb2ce7cfa57b6aef38b", size = 6309594, upload-time = "2025-04-19T22:47:10.523Z" },
+    { url = "https://files.pythonhosted.org/packages/13/ae/72e6276feb9ef06787365b05915bfdb057d01fceb4a43cb80978e518d79b/numpy-2.2.5-cp313-cp313-win_amd64.whl", hash = "sha256:d8882a829fd779f0f43998e931c466802a77ca1ee0fe25a3abe50278616b1471", size = 12638356, upload-time = "2025-04-19T22:47:30.253Z" },
+    { url = "https://files.pythonhosted.org/packages/79/56/be8b85a9f2adb688e7ded6324e20149a03541d2b3297c3ffc1a73f46dedb/numpy-2.2.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e8b025c351b9f0e8b5436cf28a07fa4ac0204d67b38f01433ac7f9b870fa38c6", size = 20963778, upload-time = "2025-04-19T22:44:09.251Z" },
+    { url = "https://files.pythonhosted.org/packages/ff/77/19c5e62d55bff507a18c3cdff82e94fe174957bad25860a991cac719d3ab/numpy-2.2.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:8dfa94b6a4374e7851bbb6f35e6ded2120b752b063e6acdd3157e4d2bb922eba", size = 14207279, upload-time = "2025-04-19T22:44:31.383Z" },
+    { url = "https://files.pythonhosted.org/packages/75/22/aa11f22dc11ff4ffe4e849d9b63bbe8d4ac6d5fae85ddaa67dfe43be3e76/numpy-2.2.5-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:97c8425d4e26437e65e1d189d22dff4a079b747ff9c2788057bfb8114ce1e133", size = 5199247, upload-time = "2025-04-19T22:44:40.361Z" },
+    { url = "https://files.pythonhosted.org/packages/4f/6c/12d5e760fc62c08eded0394f62039f5a9857f758312bf01632a81d841459/numpy-2.2.5-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:352d330048c055ea6db701130abc48a21bec690a8d38f8284e00fab256dc1376", size = 6711087, upload-time = "2025-04-19T22:44:51.188Z" },
+    { url = "https://files.pythonhosted.org/packages/ef/94/ece8280cf4218b2bee5cec9567629e61e51b4be501e5c6840ceb593db945/numpy-2.2.5-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b4c0773b6ada798f51f0f8e30c054d32304ccc6e9c5d93d46cb26f3d385ab19", size = 14059964, upload-time = "2025-04-19T22:45:12.451Z" },
+    { url = "https://files.pythonhosted.org/packages/39/41/c5377dac0514aaeec69115830a39d905b1882819c8e65d97fc60e177e19e/numpy-2.2.5-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:55f09e00d4dccd76b179c0f18a44f041e5332fd0e022886ba1c0bbf3ea4a18d0", size = 16121214, upload-time = "2025-04-19T22:45:37.734Z" },
+    { url = "https://files.pythonhosted.org/packages/db/54/3b9f89a943257bc8e187145c6bc0eb8e3d615655f7b14e9b490b053e8149/numpy-2.2.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:02f226baeefa68f7d579e213d0f3493496397d8f1cff5e2b222af274c86a552a", size = 15575788, upload-time = "2025-04-19T22:46:01.908Z" },
+    { url = "https://files.pythonhosted.org/packages/b1/c4/2e407e85df35b29f79945751b8f8e671057a13a376497d7fb2151ba0d290/numpy-2.2.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c26843fd58f65da9491165072da2cccc372530681de481ef670dcc8e27cfb066", size = 17893672, upload-time = "2025-04-19T22:46:28.585Z" },
+    { url = "https://files.pythonhosted.org/packages/29/7e/d0b44e129d038dba453f00d0e29ebd6eaf2f06055d72b95b9947998aca14/numpy-2.2.5-cp313-cp313t-win32.whl", hash = "sha256:1a161c2c79ab30fe4501d5a2bbfe8b162490757cf90b7f05be8b80bc02f7bb8e", size = 6377102, upload-time = "2025-04-19T22:46:39.949Z" },
+    { url = "https://files.pythonhosted.org/packages/63/be/b85e4aa4bf42c6502851b971f1c326d583fcc68227385f92089cf50a7b45/numpy-2.2.5-cp313-cp313t-win_amd64.whl", hash = "sha256:d403c84991b5ad291d3809bace5e85f4bbf44a04bdc9a88ed2bb1807b3360bb8", size = 12750096, upload-time = "2025-04-19T22:47:00.147Z" },
 ]
 
 [[package]]
@@ -259,31 +326,45 @@ source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "pyyaml" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/00/71/c721b9a524f6fe6f73469c90ec44784f0b2b1b23c438da7cc7daac1ede76/oyaml-1.0.tar.gz", hash = "sha256:ed8fc096811f4763e1907dce29c35895d6d5936c4d0400fe843a91133d4744ed", size = 2914 }
+sdist = { url = "https://files.pythonhosted.org/packages/00/71/c721b9a524f6fe6f73469c90ec44784f0b2b1b23c438da7cc7daac1ede76/oyaml-1.0.tar.gz", hash = "sha256:ed8fc096811f4763e1907dce29c35895d6d5936c4d0400fe843a91133d4744ed", size = 2914, upload-time = "2020-08-18T04:37:43.397Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/37/aa/111610d8bf5b1bb7a295a048fc648cec346347a8b0be5881defd2d1b4a52/oyaml-1.0-py2.py3-none-any.whl", hash = "sha256:3a378747b7fb2425533d1ce41962d6921cda075d46bb480a158d45242d156323", size = 2992 },
+    { url = "https://files.pythonhosted.org/packages/37/aa/111610d8bf5b1bb7a295a048fc648cec346347a8b0be5881defd2d1b4a52/oyaml-1.0-py2.py3-none-any.whl", hash = "sha256:3a378747b7fb2425533d1ce41962d6921cda075d46bb480a158d45242d156323", size = 2992, upload-time = "2020-08-18T04:37:38.263Z" },
 ]
 
 [[package]]
 name = "packaging"
 version = "24.2"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950 }
+sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950, upload-time = "2024-11-08T09:47:47.202Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451 },
+    { url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451, upload-time = "2024-11-08T09:47:44.722Z" },
 ]
 
 [[package]]
-name = "polars"
-version = "1.17.0"
+name = "pandas"
+version = "2.2.3"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f5/0c/92509090d9be713795b1650672ce4efadbe656f9886a85d633f8449649e6/polars-1.17.0.tar.gz", hash = "sha256:f9e9cc1cd1e31a21e521624f70c5c0277162c0f1937d48d35c356d53571aa20c", size = 4220537 }
+dependencies = [
+    { name = "numpy" },
+    { name = "python-dateutil" },
+    { name = "pytz" },
+    { name = "tzdata" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/9c/d6/9f8431bacc2e19dca897724cd097b1bb224a6ad5433784a44b587c7c13af/pandas-2.2.3.tar.gz", hash = "sha256:4f18ba62b61d7e192368b84517265a99b4d7ee8912f8708660fb4a366cc82667", size = 4399213, upload-time = "2024-09-20T13:10:04.827Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/78/c5/41e37379560259810cb522d208e56f0085a0ab39b046b33b198dd137ec86/polars-1.17.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:616c93faaf6bbbdfbe6add3299d5baae2eabf8bc728c22d3d6582dabd25f5bfe", size = 32996177 },
-    { url = "https://files.pythonhosted.org/packages/36/98/8d5607f16b0deda70f9da159891e794f84f77bca2b226ec8edd36824d302/polars-1.17.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:444d432ab97d7c57ebba8fb05b3c616d4c3853d0ec181f6347317ab170719669", size = 28775767 },
-    { url = "https://files.pythonhosted.org/packages/7b/6f/907267f46ae13f35c63e05be5cf10a1e00b305f381d73b033396ba3df9ef/polars-1.17.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f8b7d41167c7f381baf9a44c4a7945db74435fde591960106dfceb0e4764a20a", size = 36375574 },
-    { url = "https://files.pythonhosted.org/packages/a3/d2/facdb05ae58f7a1d4f3c75f9e6a60258e16c0c7c1643899ed39545866ed2/polars-1.17.0-cp39-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:fb800b7b7ae56ff4e62350aeed38c3ef82dc95fcffe99c2552fc40cd9965485b", size = 32668367 },
-    { url = "https://files.pythonhosted.org/packages/d4/68/34153dc47b5f8c40d7f41a474cad4539a0d4eb41be81a70b0c1566a0efc1/polars-1.17.0-cp39-abi3-win_amd64.whl", hash = "sha256:e42847a813f9cfb969839e27cf9ab4c73b8c08a8729b764ec77f8efe0abd745f", size = 35934441 },
+    { url = "https://files.pythonhosted.org/packages/64/22/3b8f4e0ed70644e85cfdcd57454686b9057c6c38d2f74fe4b8bc2527214a/pandas-2.2.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f00d1345d84d8c86a63e476bb4955e46458b304b9575dcf71102b5c705320015", size = 12477643, upload-time = "2024-09-20T13:09:25.522Z" },
+    { url = "https://files.pythonhosted.org/packages/e4/93/b3f5d1838500e22c8d793625da672f3eec046b1a99257666c94446969282/pandas-2.2.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3508d914817e153ad359d7e069d752cdd736a247c322d932eb89e6bc84217f28", size = 11281573, upload-time = "2024-09-20T13:09:28.012Z" },
+    { url = "https://files.pythonhosted.org/packages/f5/94/6c79b07f0e5aab1dcfa35a75f4817f5c4f677931d4234afcd75f0e6a66ca/pandas-2.2.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22a9d949bfc9a502d320aa04e5d02feab689d61da4e7764b62c30b991c42c5f0", size = 15196085, upload-time = "2024-09-20T19:02:10.451Z" },
+    { url = "https://files.pythonhosted.org/packages/e8/31/aa8da88ca0eadbabd0a639788a6da13bb2ff6edbbb9f29aa786450a30a91/pandas-2.2.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f3a255b2c19987fbbe62a9dfd6cff7ff2aa9ccab3fc75218fd4b7530f01efa24", size = 12711809, upload-time = "2024-09-20T13:09:30.814Z" },
+    { url = "https://files.pythonhosted.org/packages/ee/7c/c6dbdb0cb2a4344cacfb8de1c5808ca885b2e4dcfde8008266608f9372af/pandas-2.2.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:800250ecdadb6d9c78eae4990da62743b857b470883fa27f652db8bdde7f6659", size = 16356316, upload-time = "2024-09-20T19:02:13.825Z" },
+    { url = "https://files.pythonhosted.org/packages/57/b7/8b757e7d92023b832869fa8881a992696a0bfe2e26f72c9ae9f255988d42/pandas-2.2.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6374c452ff3ec675a8f46fd9ab25c4ad0ba590b71cf0656f8b6daa5202bca3fb", size = 14022055, upload-time = "2024-09-20T13:09:33.462Z" },
+    { url = "https://files.pythonhosted.org/packages/3b/bc/4b18e2b8c002572c5a441a64826252ce5da2aa738855747247a971988043/pandas-2.2.3-cp313-cp313-win_amd64.whl", hash = "sha256:61c5ad4043f791b61dd4752191d9f07f0ae412515d59ba8f005832a532f8736d", size = 11481175, upload-time = "2024-09-20T13:09:35.871Z" },
+    { url = "https://files.pythonhosted.org/packages/76/a3/a5d88146815e972d40d19247b2c162e88213ef51c7c25993942c39dbf41d/pandas-2.2.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:3b71f27954685ee685317063bf13c7709a7ba74fc996b84fc6821c59b0f06468", size = 12615650, upload-time = "2024-09-20T13:09:38.685Z" },
+    { url = "https://files.pythonhosted.org/packages/9c/8c/f0fd18f6140ddafc0c24122c8a964e48294acc579d47def376fef12bcb4a/pandas-2.2.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:38cf8125c40dae9d5acc10fa66af8ea6fdf760b2714ee482ca691fc66e6fcb18", size = 11290177, upload-time = "2024-09-20T13:09:41.141Z" },
+    { url = "https://files.pythonhosted.org/packages/ed/f9/e995754eab9c0f14c6777401f7eece0943840b7a9fc932221c19d1abee9f/pandas-2.2.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ba96630bc17c875161df3818780af30e43be9b166ce51c9a18c1feae342906c2", size = 14651526, upload-time = "2024-09-20T19:02:16.905Z" },
+    { url = "https://files.pythonhosted.org/packages/25/b0/98d6ae2e1abac4f35230aa756005e8654649d305df9a28b16b9ae4353bff/pandas-2.2.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1db71525a1538b30142094edb9adc10be3f3e176748cd7acc2240c2f2e5aa3a4", size = 11871013, upload-time = "2024-09-20T13:09:44.39Z" },
+    { url = "https://files.pythonhosted.org/packages/cc/57/0f72a10f9db6a4628744c8e8f0df4e6e21de01212c7c981d31e50ffc8328/pandas-2.2.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:15c0e1e02e93116177d29ff83e8b1619c93ddc9c49083f237d4312337a61165d", size = 15711620, upload-time = "2024-09-20T19:02:20.639Z" },
+    { url = "https://files.pythonhosted.org/packages/ab/5f/b38085618b950b79d2d9164a711c52b10aefc0ae6833b96f626b7021b2ed/pandas-2.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ad5b65698ab28ed8d7f18790a0dc58005c7629f227be9ecc1072aa74c0c1d43a", size = 13098436, upload-time = "2024-09-20T13:09:48.112Z" },
 ]
 
 [[package]]
@@ -293,53 +374,53 @@ source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "protobuf" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/7e/05/74417b2061e1bf1b82776037cad97094228fa1c1b6e82d08a78d3fb6ddb6/proto_plus-1.25.0.tar.gz", hash = "sha256:fbb17f57f7bd05a68b7707e745e26528b0b3c34e378db91eef93912c54982d91", size = 56124 }
+sdist = { url = "https://files.pythonhosted.org/packages/7e/05/74417b2061e1bf1b82776037cad97094228fa1c1b6e82d08a78d3fb6ddb6/proto_plus-1.25.0.tar.gz", hash = "sha256:fbb17f57f7bd05a68b7707e745e26528b0b3c34e378db91eef93912c54982d91", size = 56124, upload-time = "2024-10-23T15:03:39.579Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/dd/25/0b7cc838ae3d76d46539020ec39fc92bfc9acc29367e58fe912702c2a79e/proto_plus-1.25.0-py3-none-any.whl", hash = "sha256:c91fc4a65074ade8e458e95ef8bac34d4008daa7cce4a12d6707066fca648961", size = 50126 },
+    { url = "https://files.pythonhosted.org/packages/dd/25/0b7cc838ae3d76d46539020ec39fc92bfc9acc29367e58fe912702c2a79e/proto_plus-1.25.0-py3-none-any.whl", hash = "sha256:c91fc4a65074ade8e458e95ef8bac34d4008daa7cce4a12d6707066fca648961", size = 50126, upload-time = "2024-10-23T15:03:38.415Z" },
 ]
 
 [[package]]
 name = "protobuf"
 version = "5.29.3"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/f7/d1/e0a911544ca9993e0f17ce6d3cc0932752356c1b0a834397f28e63479344/protobuf-5.29.3.tar.gz", hash = "sha256:5da0f41edaf117bde316404bad1a486cb4ededf8e4a54891296f648e8e076620", size = 424945 }
+sdist = { url = "https://files.pythonhosted.org/packages/f7/d1/e0a911544ca9993e0f17ce6d3cc0932752356c1b0a834397f28e63479344/protobuf-5.29.3.tar.gz", hash = "sha256:5da0f41edaf117bde316404bad1a486cb4ededf8e4a54891296f648e8e076620", size = 424945, upload-time = "2025-01-08T21:38:51.572Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/dc/7a/1e38f3cafa022f477ca0f57a1f49962f21ad25850c3ca0acd3b9d0091518/protobuf-5.29.3-cp310-abi3-win32.whl", hash = "sha256:3ea51771449e1035f26069c4c7fd51fba990d07bc55ba80701c78f886bf9c888", size = 422708 },
-    { url = "https://files.pythonhosted.org/packages/61/fa/aae8e10512b83de633f2646506a6d835b151edf4b30d18d73afd01447253/protobuf-5.29.3-cp310-abi3-win_amd64.whl", hash = "sha256:a4fa6f80816a9a0678429e84973f2f98cbc218cca434abe8db2ad0bffc98503a", size = 434508 },
-    { url = "https://files.pythonhosted.org/packages/dd/04/3eaedc2ba17a088961d0e3bd396eac764450f431621b58a04ce898acd126/protobuf-5.29.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a8434404bbf139aa9e1300dbf989667a83d42ddda9153d8ab76e0d5dcaca484e", size = 417825 },
-    { url = "https://files.pythonhosted.org/packages/4f/06/7c467744d23c3979ce250397e26d8ad8eeb2bea7b18ca12ad58313c1b8d5/protobuf-5.29.3-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:daaf63f70f25e8689c072cfad4334ca0ac1d1e05a92fc15c54eb9cf23c3efd84", size = 319573 },
-    { url = "https://files.pythonhosted.org/packages/a8/45/2ebbde52ad2be18d3675b6bee50e68cd73c9e0654de77d595540b5129df8/protobuf-5.29.3-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:c027e08a08be10b67c06bf2370b99c811c466398c357e615ca88c91c07f0910f", size = 319672 },
-    { url = "https://files.pythonhosted.org/packages/fd/b2/ab07b09e0f6d143dfb839693aa05765257bceaa13d03bf1a696b78323e7a/protobuf-5.29.3-py3-none-any.whl", hash = "sha256:0a18ed4a24198528f2333802eb075e59dea9d679ab7a6c5efb017a59004d849f", size = 172550 },
+    { url = "https://files.pythonhosted.org/packages/dc/7a/1e38f3cafa022f477ca0f57a1f49962f21ad25850c3ca0acd3b9d0091518/protobuf-5.29.3-cp310-abi3-win32.whl", hash = "sha256:3ea51771449e1035f26069c4c7fd51fba990d07bc55ba80701c78f886bf9c888", size = 422708, upload-time = "2025-01-08T21:38:31.799Z" },
+    { url = "https://files.pythonhosted.org/packages/61/fa/aae8e10512b83de633f2646506a6d835b151edf4b30d18d73afd01447253/protobuf-5.29.3-cp310-abi3-win_amd64.whl", hash = "sha256:a4fa6f80816a9a0678429e84973f2f98cbc218cca434abe8db2ad0bffc98503a", size = 434508, upload-time = "2025-01-08T21:38:35.489Z" },
+    { url = "https://files.pythonhosted.org/packages/dd/04/3eaedc2ba17a088961d0e3bd396eac764450f431621b58a04ce898acd126/protobuf-5.29.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:a8434404bbf139aa9e1300dbf989667a83d42ddda9153d8ab76e0d5dcaca484e", size = 417825, upload-time = "2025-01-08T21:38:36.642Z" },
+    { url = "https://files.pythonhosted.org/packages/4f/06/7c467744d23c3979ce250397e26d8ad8eeb2bea7b18ca12ad58313c1b8d5/protobuf-5.29.3-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:daaf63f70f25e8689c072cfad4334ca0ac1d1e05a92fc15c54eb9cf23c3efd84", size = 319573, upload-time = "2025-01-08T21:38:37.896Z" },
+    { url = "https://files.pythonhosted.org/packages/a8/45/2ebbde52ad2be18d3675b6bee50e68cd73c9e0654de77d595540b5129df8/protobuf-5.29.3-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:c027e08a08be10b67c06bf2370b99c811c466398c357e615ca88c91c07f0910f", size = 319672, upload-time = "2025-01-08T21:38:40.204Z" },
+    { url = "https://files.pythonhosted.org/packages/fd/b2/ab07b09e0f6d143dfb839693aa05765257bceaa13d03bf1a696b78323e7a/protobuf-5.29.3-py3-none-any.whl", hash = "sha256:0a18ed4a24198528f2333802eb075e59dea9d679ab7a6c5efb017a59004d849f", size = 172550, upload-time = "2025-01-08T21:38:50.439Z" },
 ]
 
 [[package]]
 name = "pyarrow"
 version = "18.1.0"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/7f/7b/640785a9062bb00314caa8a387abce547d2a420cf09bd6c715fe659ccffb/pyarrow-18.1.0.tar.gz", hash = "sha256:9386d3ca9c145b5539a1cfc75df07757dff870168c959b473a0bccbc3abc8c73", size = 1118671 }
+sdist = { url = "https://files.pythonhosted.org/packages/7f/7b/640785a9062bb00314caa8a387abce547d2a420cf09bd6c715fe659ccffb/pyarrow-18.1.0.tar.gz", hash = "sha256:9386d3ca9c145b5539a1cfc75df07757dff870168c959b473a0bccbc3abc8c73", size = 1118671, upload-time = "2024-11-26T02:01:48.62Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/cb/87/aa4d249732edef6ad88899399047d7e49311a55749d3c373007d034ee471/pyarrow-18.1.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:84e314d22231357d473eabec709d0ba285fa706a72377f9cc8e1cb3c8013813b", size = 29497406 },
-    { url = "https://files.pythonhosted.org/packages/3c/c7/ed6adb46d93a3177540e228b5ca30d99fc8ea3b13bdb88b6f8b6467e2cb7/pyarrow-18.1.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:f591704ac05dfd0477bb8f8e0bd4b5dc52c1cadf50503858dce3a15db6e46ff2", size = 30835095 },
-    { url = "https://files.pythonhosted.org/packages/41/d7/ed85001edfb96200ff606943cff71d64f91926ab42828676c0fc0db98963/pyarrow-18.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acb7564204d3c40babf93a05624fc6a8ec1ab1def295c363afc40b0c9e66c191", size = 39194527 },
-    { url = "https://files.pythonhosted.org/packages/59/16/35e28eab126342fa391593415d79477e89582de411bb95232f28b131a769/pyarrow-18.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:74de649d1d2ccb778f7c3afff6085bd5092aed4c23df9feeb45dd6b16f3811aa", size = 40131443 },
-    { url = "https://files.pythonhosted.org/packages/0c/95/e855880614c8da20f4cd74fa85d7268c725cf0013dc754048593a38896a0/pyarrow-18.1.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:f96bd502cb11abb08efea6dab09c003305161cb6c9eafd432e35e76e7fa9b90c", size = 38608750 },
-    { url = "https://files.pythonhosted.org/packages/54/9d/f253554b1457d4fdb3831b7bd5f8f00f1795585a606eabf6fec0a58a9c38/pyarrow-18.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:36ac22d7782554754a3b50201b607d553a8d71b78cdf03b33c1125be4b52397c", size = 40066690 },
-    { url = "https://files.pythonhosted.org/packages/2f/58/8912a2563e6b8273e8aa7b605a345bba5a06204549826f6493065575ebc0/pyarrow-18.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:25dbacab8c5952df0ca6ca0af28f50d45bd31c1ff6fcf79e2d120b4a65ee7181", size = 25081054 },
-    { url = "https://files.pythonhosted.org/packages/82/f9/d06ddc06cab1ada0c2f2fd205ac8c25c2701182de1b9c4bf7a0a44844431/pyarrow-18.1.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6a276190309aba7bc9d5bd2933230458b3521a4317acfefe69a354f2fe59f2bc", size = 29525542 },
-    { url = "https://files.pythonhosted.org/packages/ab/94/8917e3b961810587ecbdaa417f8ebac0abb25105ae667b7aa11c05876976/pyarrow-18.1.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:ad514dbfcffe30124ce655d72771ae070f30bf850b48bc4d9d3b25993ee0e386", size = 30829412 },
-    { url = "https://files.pythonhosted.org/packages/5e/e3/3b16c3190f3d71d3b10f6758d2d5f7779ef008c4fd367cedab3ed178a9f7/pyarrow-18.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aebc13a11ed3032d8dd6e7171eb6e86d40d67a5639d96c35142bd568b9299324", size = 39119106 },
-    { url = "https://files.pythonhosted.org/packages/1d/d6/5d704b0d25c3c79532f8c0639f253ec2803b897100f64bcb3f53ced236e5/pyarrow-18.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6cf5c05f3cee251d80e98726b5c7cc9f21bab9e9783673bac58e6dfab57ecc8", size = 40090940 },
-    { url = "https://files.pythonhosted.org/packages/37/29/366bc7e588220d74ec00e497ac6710c2833c9176f0372fe0286929b2d64c/pyarrow-18.1.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:11b676cd410cf162d3f6a70b43fb9e1e40affbc542a1e9ed3681895f2962d3d9", size = 38548177 },
-    { url = "https://files.pythonhosted.org/packages/c8/11/fabf6ecabb1fe5b7d96889228ca2a9158c4c3bb732e3b8ee3f7f6d40b703/pyarrow-18.1.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:b76130d835261b38f14fc41fdfb39ad8d672afb84c447126b84d5472244cfaba", size = 40043567 },
+    { url = "https://files.pythonhosted.org/packages/cb/87/aa4d249732edef6ad88899399047d7e49311a55749d3c373007d034ee471/pyarrow-18.1.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:84e314d22231357d473eabec709d0ba285fa706a72377f9cc8e1cb3c8013813b", size = 29497406, upload-time = "2024-11-26T02:00:14.469Z" },
+    { url = "https://files.pythonhosted.org/packages/3c/c7/ed6adb46d93a3177540e228b5ca30d99fc8ea3b13bdb88b6f8b6467e2cb7/pyarrow-18.1.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:f591704ac05dfd0477bb8f8e0bd4b5dc52c1cadf50503858dce3a15db6e46ff2", size = 30835095, upload-time = "2024-11-26T02:00:19.347Z" },
+    { url = "https://files.pythonhosted.org/packages/41/d7/ed85001edfb96200ff606943cff71d64f91926ab42828676c0fc0db98963/pyarrow-18.1.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acb7564204d3c40babf93a05624fc6a8ec1ab1def295c363afc40b0c9e66c191", size = 39194527, upload-time = "2024-11-26T02:00:24.085Z" },
+    { url = "https://files.pythonhosted.org/packages/59/16/35e28eab126342fa391593415d79477e89582de411bb95232f28b131a769/pyarrow-18.1.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:74de649d1d2ccb778f7c3afff6085bd5092aed4c23df9feeb45dd6b16f3811aa", size = 40131443, upload-time = "2024-11-26T02:00:29.483Z" },
+    { url = "https://files.pythonhosted.org/packages/0c/95/e855880614c8da20f4cd74fa85d7268c725cf0013dc754048593a38896a0/pyarrow-18.1.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:f96bd502cb11abb08efea6dab09c003305161cb6c9eafd432e35e76e7fa9b90c", size = 38608750, upload-time = "2024-11-26T02:00:34.069Z" },
+    { url = "https://files.pythonhosted.org/packages/54/9d/f253554b1457d4fdb3831b7bd5f8f00f1795585a606eabf6fec0a58a9c38/pyarrow-18.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:36ac22d7782554754a3b50201b607d553a8d71b78cdf03b33c1125be4b52397c", size = 40066690, upload-time = "2024-11-26T02:00:39.603Z" },
+    { url = "https://files.pythonhosted.org/packages/2f/58/8912a2563e6b8273e8aa7b605a345bba5a06204549826f6493065575ebc0/pyarrow-18.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:25dbacab8c5952df0ca6ca0af28f50d45bd31c1ff6fcf79e2d120b4a65ee7181", size = 25081054, upload-time = "2024-11-26T02:00:43.611Z" },
+    { url = "https://files.pythonhosted.org/packages/82/f9/d06ddc06cab1ada0c2f2fd205ac8c25c2701182de1b9c4bf7a0a44844431/pyarrow-18.1.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:6a276190309aba7bc9d5bd2933230458b3521a4317acfefe69a354f2fe59f2bc", size = 29525542, upload-time = "2024-11-26T02:00:48.094Z" },
+    { url = "https://files.pythonhosted.org/packages/ab/94/8917e3b961810587ecbdaa417f8ebac0abb25105ae667b7aa11c05876976/pyarrow-18.1.0-cp313-cp313t-macosx_12_0_x86_64.whl", hash = "sha256:ad514dbfcffe30124ce655d72771ae070f30bf850b48bc4d9d3b25993ee0e386", size = 30829412, upload-time = "2024-11-26T02:00:52.458Z" },
+    { url = "https://files.pythonhosted.org/packages/5e/e3/3b16c3190f3d71d3b10f6758d2d5f7779ef008c4fd367cedab3ed178a9f7/pyarrow-18.1.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:aebc13a11ed3032d8dd6e7171eb6e86d40d67a5639d96c35142bd568b9299324", size = 39119106, upload-time = "2024-11-26T02:00:57.219Z" },
+    { url = "https://files.pythonhosted.org/packages/1d/d6/5d704b0d25c3c79532f8c0639f253ec2803b897100f64bcb3f53ced236e5/pyarrow-18.1.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d6cf5c05f3cee251d80e98726b5c7cc9f21bab9e9783673bac58e6dfab57ecc8", size = 40090940, upload-time = "2024-11-26T02:01:02.31Z" },
+    { url = "https://files.pythonhosted.org/packages/37/29/366bc7e588220d74ec00e497ac6710c2833c9176f0372fe0286929b2d64c/pyarrow-18.1.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:11b676cd410cf162d3f6a70b43fb9e1e40affbc542a1e9ed3681895f2962d3d9", size = 38548177, upload-time = "2024-11-26T02:01:07.371Z" },
+    { url = "https://files.pythonhosted.org/packages/c8/11/fabf6ecabb1fe5b7d96889228ca2a9158c4c3bb732e3b8ee3f7f6d40b703/pyarrow-18.1.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:b76130d835261b38f14fc41fdfb39ad8d672afb84c447126b84d5472244cfaba", size = 40043567, upload-time = "2024-11-26T02:01:12.931Z" },
 ]
 
 [[package]]
 name = "pyasn1"
 version = "0.6.1"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322 }
+sdist = { url = "https://files.pythonhosted.org/packages/ba/e9/01f1a64245b89f039897cb0130016d79f77d52669aae6ee7b159a6c4c018/pyasn1-0.6.1.tar.gz", hash = "sha256:6f580d2bdd84365380830acf45550f2511469f673cb4a5ae3857a3170128b034", size = 145322, upload-time = "2024-09-10T22:41:42.55Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135 },
+    { url = "https://files.pythonhosted.org/packages/c8/f1/d6a797abb14f6283c0ddff96bbdd46937f64122b8c925cab503dd37f8214/pyasn1-0.6.1-py3-none-any.whl", hash = "sha256:0d632f46f2ba09143da3a8afe9e33fb6f92fa2320ab7e886e2d0f7672af84629", size = 83135, upload-time = "2024-09-11T16:00:36.122Z" },
 ]
 
 [[package]]
@@ -349,18 +430,18 @@ source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "pyasn1" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/1d/67/6afbf0d507f73c32d21084a79946bfcfca5fbc62a72057e9c23797a737c9/pyasn1_modules-0.4.1.tar.gz", hash = "sha256:c28e2dbf9c06ad61c71a075c7e0f9fd0f1b0bb2d2ad4377f240d33ac2ab60a7c", size = 310028 }
+sdist = { url = "https://files.pythonhosted.org/packages/1d/67/6afbf0d507f73c32d21084a79946bfcfca5fbc62a72057e9c23797a737c9/pyasn1_modules-0.4.1.tar.gz", hash = "sha256:c28e2dbf9c06ad61c71a075c7e0f9fd0f1b0bb2d2ad4377f240d33ac2ab60a7c", size = 310028, upload-time = "2024-09-10T22:42:08.349Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/77/89/bc88a6711935ba795a679ea6ebee07e128050d6382eaa35a0a47c8032bdc/pyasn1_modules-0.4.1-py3-none-any.whl", hash = "sha256:49bfa96b45a292b711e986f222502c1c9a5e1f4e568fc30e2574a6c7d07838fd", size = 181537 },
+    { url = "https://files.pythonhosted.org/packages/77/89/bc88a6711935ba795a679ea6ebee07e128050d6382eaa35a0a47c8032bdc/pyasn1_modules-0.4.1-py3-none-any.whl", hash = "sha256:49bfa96b45a292b711e986f222502c1c9a5e1f4e568fc30e2574a6c7d07838fd", size = 181537, upload-time = "2024-09-11T16:02:10.336Z" },
 ]
 
 [[package]]
 name = "pycparser"
 version = "2.22"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736 }
+sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552 },
+    { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" },
 ]
 
 [[package]]
@@ -375,18 +456,18 @@ dependencies = [
     { name = "typing-extensions" },
     { name = "urllib3" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/16/ce/aa91d30040d9552c274e7ea8bd10a977600d508d579a4bb262b95eccf961/pygithub-2.5.0.tar.gz", hash = "sha256:e1613ac508a9be710920d26eb18b1905ebd9926aa49398e88151c1b526aad3cf", size = 3552804 }
+sdist = { url = "https://files.pythonhosted.org/packages/16/ce/aa91d30040d9552c274e7ea8bd10a977600d508d579a4bb262b95eccf961/pygithub-2.5.0.tar.gz", hash = "sha256:e1613ac508a9be710920d26eb18b1905ebd9926aa49398e88151c1b526aad3cf", size = 3552804, upload-time = "2024-11-06T20:50:07.168Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/37/05/bfbdbbc5d8aafd8dae9b3b6877edca561fccd8528ef5edc4e7b6d23721b5/PyGithub-2.5.0-py3-none-any.whl", hash = "sha256:b0b635999a658ab8e08720bdd3318893ff20e2275f6446fcf35bf3f44f2c0fd2", size = 375935 },
+    { url = "https://files.pythonhosted.org/packages/37/05/bfbdbbc5d8aafd8dae9b3b6877edca561fccd8528ef5edc4e7b6d23721b5/PyGithub-2.5.0-py3-none-any.whl", hash = "sha256:b0b635999a658ab8e08720bdd3318893ff20e2275f6446fcf35bf3f44f2c0fd2", size = 375935, upload-time = "2024-11-06T20:50:04.931Z" },
 ]
 
 [[package]]
 name = "pyjwt"
 version = "2.10.1"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785 }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997 },
+    { url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
 ]
 
 [package.optional-dependencies]
@@ -401,17 +482,17 @@ source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "cffi" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/a7/22/27582568be639dfe22ddb3902225f91f2f17ceff88ce80e4db396c8986da/PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba", size = 3392854 }
+sdist = { url = "https://files.pythonhosted.org/packages/a7/22/27582568be639dfe22ddb3902225f91f2f17ceff88ce80e4db396c8986da/PyNaCl-1.5.0.tar.gz", hash = "sha256:8ac7448f09ab85811607bdd21ec2464495ac8b7c66d146bf545b0f08fb9220ba", size = 3392854, upload-time = "2022-01-07T22:05:41.134Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/ce/75/0b8ede18506041c0bf23ac4d8e2971b4161cd6ce630b177d0a08eb0d8857/PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1", size = 349920 },
-    { url = "https://files.pythonhosted.org/packages/59/bb/fddf10acd09637327a97ef89d2a9d621328850a72f1fdc8c08bdf72e385f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92", size = 601722 },
-    { url = "https://files.pythonhosted.org/packages/5d/70/87a065c37cca41a75f2ce113a5a2c2aa7533be648b184ade58971b5f7ccc/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394", size = 680087 },
-    { url = "https://files.pythonhosted.org/packages/ee/87/f1bb6a595f14a327e8285b9eb54d41fef76c585a0edef0a45f6fc95de125/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d", size = 856678 },
-    { url = "https://files.pythonhosted.org/packages/66/28/ca86676b69bf9f90e710571b67450508484388bfce09acf8a46f0b8c785f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858", size = 1133660 },
-    { url = "https://files.pythonhosted.org/packages/3d/85/c262db650e86812585e2bc59e497a8f59948a005325a11bbbc9ecd3fe26b/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b", size = 663824 },
-    { url = "https://files.pythonhosted.org/packages/fd/1a/cc308a884bd299b651f1633acb978e8596c71c33ca85e9dc9fa33a5399b9/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff", size = 1117912 },
-    { url = "https://files.pythonhosted.org/packages/25/2d/b7df6ddb0c2a33afdb358f8af6ea3b8c4d1196ca45497dd37a56f0c122be/PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543", size = 204624 },
-    { url = "https://files.pythonhosted.org/packages/5e/22/d3db169895faaf3e2eda892f005f433a62db2decbcfbc2f61e6517adfa87/PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93", size = 212141 },
+    { url = "https://files.pythonhosted.org/packages/ce/75/0b8ede18506041c0bf23ac4d8e2971b4161cd6ce630b177d0a08eb0d8857/PyNaCl-1.5.0-cp36-abi3-macosx_10_10_universal2.whl", hash = "sha256:401002a4aaa07c9414132aaed7f6836ff98f59277a234704ff66878c2ee4a0d1", size = 349920, upload-time = "2022-01-07T22:05:49.156Z" },
+    { url = "https://files.pythonhosted.org/packages/59/bb/fddf10acd09637327a97ef89d2a9d621328850a72f1fdc8c08bdf72e385f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:52cb72a79269189d4e0dc537556f4740f7f0a9ec41c1322598799b0bdad4ef92", size = 601722, upload-time = "2022-01-07T22:05:50.989Z" },
+    { url = "https://files.pythonhosted.org/packages/5d/70/87a065c37cca41a75f2ce113a5a2c2aa7533be648b184ade58971b5f7ccc/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a36d4a9dda1f19ce6e03c9a784a2921a4b726b02e1c736600ca9c22029474394", size = 680087, upload-time = "2022-01-07T22:05:52.539Z" },
+    { url = "https://files.pythonhosted.org/packages/ee/87/f1bb6a595f14a327e8285b9eb54d41fef76c585a0edef0a45f6fc95de125/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_24_x86_64.whl", hash = "sha256:0c84947a22519e013607c9be43706dd42513f9e6ae5d39d3613ca1e142fba44d", size = 856678, upload-time = "2022-01-07T22:05:54.251Z" },
+    { url = "https://files.pythonhosted.org/packages/66/28/ca86676b69bf9f90e710571b67450508484388bfce09acf8a46f0b8c785f/PyNaCl-1.5.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06b8f6fa7f5de8d5d2f7573fe8c863c051225a27b61e6860fd047b1775807858", size = 1133660, upload-time = "2022-01-07T22:05:56.056Z" },
+    { url = "https://files.pythonhosted.org/packages/3d/85/c262db650e86812585e2bc59e497a8f59948a005325a11bbbc9ecd3fe26b/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a422368fc821589c228f4c49438a368831cb5bbc0eab5ebe1d7fac9dded6567b", size = 663824, upload-time = "2022-01-07T22:05:57.434Z" },
+    { url = "https://files.pythonhosted.org/packages/fd/1a/cc308a884bd299b651f1633acb978e8596c71c33ca85e9dc9fa33a5399b9/PyNaCl-1.5.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:61f642bf2378713e2c2e1de73444a3778e5f0a38be6fee0fe532fe30060282ff", size = 1117912, upload-time = "2022-01-07T22:05:58.665Z" },
+    { url = "https://files.pythonhosted.org/packages/25/2d/b7df6ddb0c2a33afdb358f8af6ea3b8c4d1196ca45497dd37a56f0c122be/PyNaCl-1.5.0-cp36-abi3-win32.whl", hash = "sha256:e46dae94e34b085175f8abb3b0aaa7da40767865ac82c928eeb9e57e1ea8a543", size = 204624, upload-time = "2022-01-07T22:06:00.085Z" },
+    { url = "https://files.pythonhosted.org/packages/5e/22/d3db169895faaf3e2eda892f005f433a62db2decbcfbc2f61e6517adfa87/PyNaCl-1.5.0-cp36-abi3-win_amd64.whl", hash = "sha256:20f42270d27e1b6a29f54032090b972d97f0a1b0948cc52392041ef7831fee93", size = 212141, upload-time = "2022-01-07T22:06:01.861Z" },
 ]
 
 [[package]]
@@ -421,26 +502,35 @@ source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "six" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 }
+sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
+]
+
+[[package]]
+name = "pytz"
+version = "2025.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f8/bf/abbd3cdfb8fbc7fb3d4d38d320f2441b1e7cbe29be4f23797b4a2b5d8aac/pytz-2025.2.tar.gz", hash = "sha256:360b9e3dbb49a209c21ad61809c7fb453643e048b38924c765813546746e81c3", size = 320884, upload-time = "2025-03-25T02:25:00.538Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 },
+    { url = "https://files.pythonhosted.org/packages/81/c4/34e93fe5f5429d7570ec1fa436f1986fb1f00c3e0f43a589fe2bbcd22c3f/pytz-2025.2-py2.py3-none-any.whl", hash = "sha256:5ddf76296dd8c44c26eb8f4b6f35488f3ccbf6fbbd7adee0b7262d43f0ec2f00", size = 509225, upload-time = "2025-03-25T02:24:58.468Z" },
 ]
 
 [[package]]
 name = "pyyaml"
 version = "6.0.2"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631 }
+sdist = { url = "https://files.pythonhosted.org/packages/54/ed/79a089b6be93607fa5cdaedf301d7dfb23af5f25c398d5ead2525b063e17/pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e", size = 130631, upload-time = "2024-08-06T20:33:50.674Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309 },
-    { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679 },
-    { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428 },
-    { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361 },
-    { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523 },
-    { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660 },
-    { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597 },
-    { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527 },
-    { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446 },
+    { url = "https://files.pythonhosted.org/packages/ef/e3/3af305b830494fa85d95f6d95ef7fa73f2ee1cc8ef5b495c7c3269fb835f/PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba", size = 181309, upload-time = "2024-08-06T20:32:43.4Z" },
+    { url = "https://files.pythonhosted.org/packages/45/9f/3b1c20a0b7a3200524eb0076cc027a970d320bd3a6592873c85c92a08731/PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1", size = 171679, upload-time = "2024-08-06T20:32:44.801Z" },
+    { url = "https://files.pythonhosted.org/packages/7c/9a/337322f27005c33bcb656c655fa78325b730324c78620e8328ae28b64d0c/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133", size = 733428, upload-time = "2024-08-06T20:32:46.432Z" },
+    { url = "https://files.pythonhosted.org/packages/a3/69/864fbe19e6c18ea3cc196cbe5d392175b4cf3d5d0ac1403ec3f2d237ebb5/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484", size = 763361, upload-time = "2024-08-06T20:32:51.188Z" },
+    { url = "https://files.pythonhosted.org/packages/04/24/b7721e4845c2f162d26f50521b825fb061bc0a5afcf9a386840f23ea19fa/PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5", size = 759523, upload-time = "2024-08-06T20:32:53.019Z" },
+    { url = "https://files.pythonhosted.org/packages/2b/b2/e3234f59ba06559c6ff63c4e10baea10e5e7df868092bf9ab40e5b9c56b6/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc", size = 726660, upload-time = "2024-08-06T20:32:54.708Z" },
+    { url = "https://files.pythonhosted.org/packages/fe/0f/25911a9f080464c59fab9027482f822b86bf0608957a5fcc6eaac85aa515/PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652", size = 751597, upload-time = "2024-08-06T20:32:56.985Z" },
+    { url = "https://files.pythonhosted.org/packages/14/0d/e2c3b43bbce3cf6bd97c840b46088a3031085179e596d4929729d8d68270/PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183", size = 140527, upload-time = "2024-08-06T20:33:03.001Z" },
+    { url = "https://files.pythonhosted.org/packages/fa/de/02b54f42487e3d3c6efb3f89428677074ca7bf43aae402517bc7cca949f3/PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563", size = 156446, upload-time = "2024-08-06T20:33:04.33Z" },
 ]
 
 [[package]]
@@ -453,9 +543,9 @@ dependencies = [
     { name = "idna" },
     { name = "urllib3" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 }
+sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 },
+    { url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" },
 ]
 
 [[package]]
@@ -465,124 +555,116 @@ source = { registry = "https://pypi.org/simple" }
 dependencies = [
     { name = "pyasn1" },
 ]
-sdist = { url = "https://files.pythonhosted.org/packages/aa/65/7d973b89c4d2351d7fb232c2e452547ddfa243e93131e7cfa766da627b52/rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21", size = 29711 }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/65/7d973b89c4d2351d7fb232c2e452547ddfa243e93131e7cfa766da627b52/rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21", size = 29711, upload-time = "2022-07-20T10:28:36.115Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/49/97/fa78e3d2f65c02c8e1268b9aba606569fe97f6c8f7c2d74394553347c145/rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7", size = 34315 },
+    { url = "https://files.pythonhosted.org/packages/49/97/fa78e3d2f65c02c8e1268b9aba606569fe97f6c8f7c2d74394553347c145/rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7", size = 34315, upload-time = "2022-07-20T10:28:34.978Z" },
 ]
 
 [[package]]
 name = "six"
 version = "1.17.0"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 }
+sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 },
+    { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
 ]
 
 [[package]]
 name = "slack-sdk"
 version = "3.34.0"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/6e/ff/6eb67fd5bd179fa804dbd859d88d872d3ae343955e63a319a73a132d406f/slack_sdk-3.34.0.tar.gz", hash = "sha256:ff61db7012160eed742285ea91f11c72b7a38a6500a7f6c5335662b4bc6b853d", size = 233629 }
+sdist = { url = "https://files.pythonhosted.org/packages/6e/ff/6eb67fd5bd179fa804dbd859d88d872d3ae343955e63a319a73a132d406f/slack_sdk-3.34.0.tar.gz", hash = "sha256:ff61db7012160eed742285ea91f11c72b7a38a6500a7f6c5335662b4bc6b853d", size = 233629, upload-time = "2024-12-17T22:20:30.612Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/25/2d/8724ef191cb64907de1e4e4436462955501e00f859a53d0aa794d0d060ff/slack_sdk-3.34.0-py2.py3-none-any.whl", hash = "sha256:c61f57f310d85be83466db5a98ab6ae3bb2e5587437b54fa0daa8fae6a0feffa", size = 292480 },
+    { url = "https://files.pythonhosted.org/packages/25/2d/8724ef191cb64907de1e4e4436462955501e00f859a53d0aa794d0d060ff/slack_sdk-3.34.0-py2.py3-none-any.whl", hash = "sha256:c61f57f310d85be83466db5a98ab6ae3bb2e5587437b54fa0daa8fae6a0feffa", size = 292480, upload-time = "2024-12-17T22:20:28.949Z" },
 ]
 
 [[package]]
-name = "sync-andhra-pradesh-ccp"
+name = "sync-turn-attachments"
 version = "0.1.0"
 source = { virtual = "." }
 dependencies = [
+    { name = "db-dtypes" },
     { name = "google-cloud-bigquery" },
+    { name = "google-cloud-bigquery-storage" },
+    { name = "google-cloud-storage" },
+    { name = "google-crc32c" },
     { name = "oyaml" },
-    { name = "polars" },
+    { name = "pandas" },
     { name = "pyarrow" },
     { name = "pygithub" },
     { name = "requests" },
     { name = "slack-sdk" },
-    { name = "tqdm" },
-    { name = "xlsxwriter" },
 ]
 
 [package.metadata]
 requires-dist = [
+    { name = "db-dtypes", specifier = ">=1.0.4" },
     { name = "google-cloud-bigquery", specifier = ">=3.27.0" },
+    { name = "google-cloud-bigquery-storage", specifier = ">=2.0.0" },
+    { name = "google-cloud-storage", specifier = ">=2.0.0" },
+    { name = "google-crc32c", specifier = ">=1.5.0" },
     { name = "oyaml", specifier = ">=1.0" },
-    { name = "polars", specifier = "==1.17.0" },
+    { name = "pandas", specifier = ">=1.3.0" },
     { name = "pyarrow", specifier = ">=18.1.0" },
     { name = "pygithub", specifier = ">=2.5.0" },
     { name = "requests", specifier = ">=2.32.3" },
     { name = "slack-sdk", specifier = ">=3.34.0" },
-    { name = "tqdm", specifier = ">=4.67.1" },
-    { name = "xlsxwriter", specifier = ">=3.2.0" },
 ]
 
 [[package]]
-name = "tqdm"
-version = "4.67.1"
+name = "typing-extensions"
+version = "4.12.2"
 source = { registry = "https://pypi.org/simple" }
-dependencies = [
-    { name = "colorama", marker = "platform_system == 'Windows'" },
-]
-sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737 }
+sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321, upload-time = "2024-06-07T18:52:15.995Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540 },
+    { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438, upload-time = "2024-06-07T18:52:13.582Z" },
 ]
 
 [[package]]
-name = "typing-extensions"
-version = "4.12.2"
+name = "tzdata"
+version = "2025.2"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/df/db/f35a00659bc03fec321ba8bce9420de607a1d37f8342eee1863174c69557/typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8", size = 85321 }
+sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/26/9f/ad63fc0248c5379346306f8668cda6e2e2e9c95e01216d2b8ffd9ff037d0/typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", size = 37438 },
+    { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" },
 ]
 
 [[package]]
 name = "urllib3"
 version = "2.3.0"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268 }
+sdist = { url = "https://files.pythonhosted.org/packages/aa/63/e53da845320b757bf29ef6a9062f5c669fe997973f966045cb019c3f4b66/urllib3-2.3.0.tar.gz", hash = "sha256:f8c5449b3cf0861679ce7e0503c7b44b5ec981bec0d1d3795a07f1ba96f0204d", size = 307268, upload-time = "2024-12-22T07:47:30.032Z" }
 wheels = [
-    { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369 },
+    { url = "https://files.pythonhosted.org/packages/c8/19/4ec628951a74043532ca2cf5d97b7b14863931476d117c471e8e2b1eb39f/urllib3-2.3.0-py3-none-any.whl", hash = "sha256:1cee9ad369867bfdbbb48b7dd50374c0967a0bb7710050facf0dd6911440e3df", size = 128369, upload-time = "2024-12-22T07:47:28.074Z" },
 ]
 
 [[package]]
 name = "wrapt"
 version = "1.17.2"
 source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/c3/fc/e91cc220803d7bc4db93fb02facd8461c37364151b8494762cc88b0fbcef/wrapt-1.17.2.tar.gz", hash = "sha256:41388e9d4d1522446fe79d3213196bd9e3b301a336965b9e27ca2788ebd122f3", size = 55531 }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/ce/b9/0ffd557a92f3b11d4c5d5e0c5e4ad057bd9eb8586615cdaf901409920b14/wrapt-1.17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6ed6ffac43aecfe6d86ec5b74b06a5be33d5bb9243d055141e8cabb12aa08125", size = 53800 },
-    { url = "https://files.pythonhosted.org/packages/c0/ef/8be90a0b7e73c32e550c73cfb2fa09db62234227ece47b0e80a05073b375/wrapt-1.17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35621ae4c00e056adb0009f8e86e28eb4a41a4bfa8f9bfa9fca7d343fe94f998", size = 38824 },
-    { url = "https://files.pythonhosted.org/packages/36/89/0aae34c10fe524cce30fe5fc433210376bce94cf74d05b0d68344c8ba46e/wrapt-1.17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a604bf7a053f8362d27eb9fefd2097f82600b856d5abe996d623babd067b1ab5", size = 38920 },
-    { url = "https://files.pythonhosted.org/packages/3b/24/11c4510de906d77e0cfb5197f1b1445d4fec42c9a39ea853d482698ac681/wrapt-1.17.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cbabee4f083b6b4cd282f5b817a867cf0b1028c54d445b7ec7cfe6505057cf8", size = 88690 },
-    { url = "https://files.pythonhosted.org/packages/71/d7/cfcf842291267bf455b3e266c0c29dcb675b5540ee8b50ba1699abf3af45/wrapt-1.17.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49703ce2ddc220df165bd2962f8e03b84c89fee2d65e1c24a7defff6f988f4d6", size = 80861 },
-    { url = "https://files.pythonhosted.org/packages/d5/66/5d973e9f3e7370fd686fb47a9af3319418ed925c27d72ce16b791231576d/wrapt-1.17.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8112e52c5822fc4253f3901b676c55ddf288614dc7011634e2719718eaa187dc", size = 89174 },
-    { url = "https://files.pythonhosted.org/packages/a7/d3/8e17bb70f6ae25dabc1aaf990f86824e4fd98ee9cadf197054e068500d27/wrapt-1.17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fee687dce376205d9a494e9c121e27183b2a3df18037f89d69bd7b35bcf59e2", size = 86721 },
-    { url = "https://files.pythonhosted.org/packages/6f/54/f170dfb278fe1c30d0ff864513cff526d624ab8de3254b20abb9cffedc24/wrapt-1.17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:18983c537e04d11cf027fbb60a1e8dfd5190e2b60cc27bc0808e653e7b218d1b", size = 79763 },
-    { url = "https://files.pythonhosted.org/packages/4a/98/de07243751f1c4a9b15c76019250210dd3486ce098c3d80d5f729cba029c/wrapt-1.17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:703919b1633412ab54bcf920ab388735832fdcb9f9a00ae49387f0fe67dad504", size = 87585 },
-    { url = "https://files.pythonhosted.org/packages/f9/f0/13925f4bd6548013038cdeb11ee2cbd4e37c30f8bfd5db9e5a2a370d6e20/wrapt-1.17.2-cp313-cp313-win32.whl", hash = "sha256:abbb9e76177c35d4e8568e58650aa6926040d6a9f6f03435b7a522bf1c487f9a", size = 36676 },
-    { url = "https://files.pythonhosted.org/packages/bf/ae/743f16ef8c2e3628df3ddfd652b7d4c555d12c84b53f3d8218498f4ade9b/wrapt-1.17.2-cp313-cp313-win_amd64.whl", hash = "sha256:69606d7bb691b50a4240ce6b22ebb319c1cfb164e5f6569835058196e0f3a845", size = 38871 },
-    { url = "https://files.pythonhosted.org/packages/3d/bc/30f903f891a82d402ffb5fda27ec1d621cc97cb74c16fea0b6141f1d4e87/wrapt-1.17.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:4a721d3c943dae44f8e243b380cb645a709ba5bd35d3ad27bc2ed947e9c68192", size = 56312 },
-    { url = "https://files.pythonhosted.org/packages/8a/04/c97273eb491b5f1c918857cd26f314b74fc9b29224521f5b83f872253725/wrapt-1.17.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:766d8bbefcb9e00c3ac3b000d9acc51f1b399513f44d77dfe0eb026ad7c9a19b", size = 40062 },
-    { url = "https://files.pythonhosted.org/packages/4e/ca/3b7afa1eae3a9e7fefe499db9b96813f41828b9fdb016ee836c4c379dadb/wrapt-1.17.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e496a8ce2c256da1eb98bd15803a79bee00fc351f5dfb9ea82594a3f058309e0", size = 40155 },
-    { url = "https://files.pythonhosted.org/packages/89/be/7c1baed43290775cb9030c774bc53c860db140397047cc49aedaf0a15477/wrapt-1.17.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d615e4fe22f4ad3528448c193b218e077656ca9ccb22ce2cb20db730f8d306", size = 113471 },
-    { url = "https://files.pythonhosted.org/packages/32/98/4ed894cf012b6d6aae5f5cc974006bdeb92f0241775addad3f8cd6ab71c8/wrapt-1.17.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5aaeff38654462bc4b09023918b7f21790efb807f54c000a39d41d69cf552cb", size = 101208 },
-    { url = "https://files.pythonhosted.org/packages/ea/fd/0c30f2301ca94e655e5e057012e83284ce8c545df7661a78d8bfca2fac7a/wrapt-1.17.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a7d15bbd2bc99e92e39f49a04653062ee6085c0e18b3b7512a4f2fe91f2d681", size = 109339 },
-    { url = "https://files.pythonhosted.org/packages/75/56/05d000de894c4cfcb84bcd6b1df6214297b8089a7bd324c21a4765e49b14/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3890b508a23299083e065f435a492b5435eba6e304a7114d2f919d400888cc6", size = 110232 },
-    { url = "https://files.pythonhosted.org/packages/53/f8/c3f6b2cf9b9277fb0813418e1503e68414cd036b3b099c823379c9575e6d/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8c8b293cd65ad716d13d8dd3624e42e5a19cc2a2f1acc74b30c2c13f15cb61a6", size = 100476 },
-    { url = "https://files.pythonhosted.org/packages/a7/b1/0bb11e29aa5139d90b770ebbfa167267b1fc548d2302c30c8f7572851738/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c82b8785d98cdd9fed4cac84d765d234ed3251bd6afe34cb7ac523cb93e8b4f", size = 106377 },
-    { url = "https://files.pythonhosted.org/packages/6a/e1/0122853035b40b3f333bbb25f1939fc1045e21dd518f7f0922b60c156f7c/wrapt-1.17.2-cp313-cp313t-win32.whl", hash = "sha256:13e6afb7fe71fe7485a4550a8844cc9ffbe263c0f1a1eea569bc7091d4898555", size = 37986 },
-    { url = "https://files.pythonhosted.org/packages/09/5e/1655cf481e079c1f22d0cabdd4e51733679932718dc23bf2db175f329b76/wrapt-1.17.2-cp313-cp313t-win_amd64.whl", hash = "sha256:eaf675418ed6b3b31c7a989fd007fa7c3be66ce14e5c3b27336383604c9da85c", size = 40750 },
-    { url = "https://files.pythonhosted.org/packages/2d/82/f56956041adef78f849db6b289b282e72b55ab8045a75abad81898c28d19/wrapt-1.17.2-py3-none-any.whl", hash = "sha256:b18f2d1533a71f069c7f82d524a52599053d4c7166e9dd374ae2136b7f40f7c8", size = 23594 },
-]
-
-[[package]]
-name = "xlsxwriter"
-version = "3.2.0"
-source = { registry = "https://pypi.org/simple" }
-sdist = { url = "https://files.pythonhosted.org/packages/a6/c3/b36fa44a0610a0f65d2e65ba6a262cbe2554b819f1449731971f7c16ea3c/XlsxWriter-3.2.0.tar.gz", hash = "sha256:9977d0c661a72866a61f9f7a809e25ebbb0fb7036baa3b9fe74afcfca6b3cb8c", size = 198732 }
-wheels = [
-    { url = "https://files.pythonhosted.org/packages/a7/ea/53d1fe468e63e092cf16e2c18d16f50c29851242f9dd12d6a66e0d7f0d02/XlsxWriter-3.2.0-py3-none-any.whl", hash = "sha256:ecfd5405b3e0e228219bcaf24c2ca0915e012ca9464a14048021d21a995d490e", size = 159925 },
+sdist = { url = "https://files.pythonhosted.org/packages/c3/fc/e91cc220803d7bc4db93fb02facd8461c37364151b8494762cc88b0fbcef/wrapt-1.17.2.tar.gz", hash = "sha256:41388e9d4d1522446fe79d3213196bd9e3b301a336965b9e27ca2788ebd122f3", size = 55531, upload-time = "2025-01-14T10:35:45.465Z" }
+wheels = [
+    { url = "https://files.pythonhosted.org/packages/ce/b9/0ffd557a92f3b11d4c5d5e0c5e4ad057bd9eb8586615cdaf901409920b14/wrapt-1.17.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6ed6ffac43aecfe6d86ec5b74b06a5be33d5bb9243d055141e8cabb12aa08125", size = 53800, upload-time = "2025-01-14T10:34:21.571Z" },
+    { url = "https://files.pythonhosted.org/packages/c0/ef/8be90a0b7e73c32e550c73cfb2fa09db62234227ece47b0e80a05073b375/wrapt-1.17.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:35621ae4c00e056adb0009f8e86e28eb4a41a4bfa8f9bfa9fca7d343fe94f998", size = 38824, upload-time = "2025-01-14T10:34:22.999Z" },
+    { url = "https://files.pythonhosted.org/packages/36/89/0aae34c10fe524cce30fe5fc433210376bce94cf74d05b0d68344c8ba46e/wrapt-1.17.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a604bf7a053f8362d27eb9fefd2097f82600b856d5abe996d623babd067b1ab5", size = 38920, upload-time = "2025-01-14T10:34:25.386Z" },
+    { url = "https://files.pythonhosted.org/packages/3b/24/11c4510de906d77e0cfb5197f1b1445d4fec42c9a39ea853d482698ac681/wrapt-1.17.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cbabee4f083b6b4cd282f5b817a867cf0b1028c54d445b7ec7cfe6505057cf8", size = 88690, upload-time = "2025-01-14T10:34:28.058Z" },
+    { url = "https://files.pythonhosted.org/packages/71/d7/cfcf842291267bf455b3e266c0c29dcb675b5540ee8b50ba1699abf3af45/wrapt-1.17.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:49703ce2ddc220df165bd2962f8e03b84c89fee2d65e1c24a7defff6f988f4d6", size = 80861, upload-time = "2025-01-14T10:34:29.167Z" },
+    { url = "https://files.pythonhosted.org/packages/d5/66/5d973e9f3e7370fd686fb47a9af3319418ed925c27d72ce16b791231576d/wrapt-1.17.2-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8112e52c5822fc4253f3901b676c55ddf288614dc7011634e2719718eaa187dc", size = 89174, upload-time = "2025-01-14T10:34:31.702Z" },
+    { url = "https://files.pythonhosted.org/packages/a7/d3/8e17bb70f6ae25dabc1aaf990f86824e4fd98ee9cadf197054e068500d27/wrapt-1.17.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:9fee687dce376205d9a494e9c121e27183b2a3df18037f89d69bd7b35bcf59e2", size = 86721, upload-time = "2025-01-14T10:34:32.91Z" },
+    { url = "https://files.pythonhosted.org/packages/6f/54/f170dfb278fe1c30d0ff864513cff526d624ab8de3254b20abb9cffedc24/wrapt-1.17.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:18983c537e04d11cf027fbb60a1e8dfd5190e2b60cc27bc0808e653e7b218d1b", size = 79763, upload-time = "2025-01-14T10:34:34.903Z" },
+    { url = "https://files.pythonhosted.org/packages/4a/98/de07243751f1c4a9b15c76019250210dd3486ce098c3d80d5f729cba029c/wrapt-1.17.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:703919b1633412ab54bcf920ab388735832fdcb9f9a00ae49387f0fe67dad504", size = 87585, upload-time = "2025-01-14T10:34:36.13Z" },
+    { url = "https://files.pythonhosted.org/packages/f9/f0/13925f4bd6548013038cdeb11ee2cbd4e37c30f8bfd5db9e5a2a370d6e20/wrapt-1.17.2-cp313-cp313-win32.whl", hash = "sha256:abbb9e76177c35d4e8568e58650aa6926040d6a9f6f03435b7a522bf1c487f9a", size = 36676, upload-time = "2025-01-14T10:34:37.962Z" },
+    { url = "https://files.pythonhosted.org/packages/bf/ae/743f16ef8c2e3628df3ddfd652b7d4c555d12c84b53f3d8218498f4ade9b/wrapt-1.17.2-cp313-cp313-win_amd64.whl", hash = "sha256:69606d7bb691b50a4240ce6b22ebb319c1cfb164e5f6569835058196e0f3a845", size = 38871, upload-time = "2025-01-14T10:34:39.13Z" },
+    { url = "https://files.pythonhosted.org/packages/3d/bc/30f903f891a82d402ffb5fda27ec1d621cc97cb74c16fea0b6141f1d4e87/wrapt-1.17.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:4a721d3c943dae44f8e243b380cb645a709ba5bd35d3ad27bc2ed947e9c68192", size = 56312, upload-time = "2025-01-14T10:34:40.604Z" },
+    { url = "https://files.pythonhosted.org/packages/8a/04/c97273eb491b5f1c918857cd26f314b74fc9b29224521f5b83f872253725/wrapt-1.17.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:766d8bbefcb9e00c3ac3b000d9acc51f1b399513f44d77dfe0eb026ad7c9a19b", size = 40062, upload-time = "2025-01-14T10:34:45.011Z" },
+    { url = "https://files.pythonhosted.org/packages/4e/ca/3b7afa1eae3a9e7fefe499db9b96813f41828b9fdb016ee836c4c379dadb/wrapt-1.17.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e496a8ce2c256da1eb98bd15803a79bee00fc351f5dfb9ea82594a3f058309e0", size = 40155, upload-time = "2025-01-14T10:34:47.25Z" },
+    { url = "https://files.pythonhosted.org/packages/89/be/7c1baed43290775cb9030c774bc53c860db140397047cc49aedaf0a15477/wrapt-1.17.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d615e4fe22f4ad3528448c193b218e077656ca9ccb22ce2cb20db730f8d306", size = 113471, upload-time = "2025-01-14T10:34:50.934Z" },
+    { url = "https://files.pythonhosted.org/packages/32/98/4ed894cf012b6d6aae5f5cc974006bdeb92f0241775addad3f8cd6ab71c8/wrapt-1.17.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a5aaeff38654462bc4b09023918b7f21790efb807f54c000a39d41d69cf552cb", size = 101208, upload-time = "2025-01-14T10:34:52.297Z" },
+    { url = "https://files.pythonhosted.org/packages/ea/fd/0c30f2301ca94e655e5e057012e83284ce8c545df7661a78d8bfca2fac7a/wrapt-1.17.2-cp313-cp313t-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9a7d15bbd2bc99e92e39f49a04653062ee6085c0e18b3b7512a4f2fe91f2d681", size = 109339, upload-time = "2025-01-14T10:34:53.489Z" },
+    { url = "https://files.pythonhosted.org/packages/75/56/05d000de894c4cfcb84bcd6b1df6214297b8089a7bd324c21a4765e49b14/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e3890b508a23299083e065f435a492b5435eba6e304a7114d2f919d400888cc6", size = 110232, upload-time = "2025-01-14T10:34:55.327Z" },
+    { url = "https://files.pythonhosted.org/packages/53/f8/c3f6b2cf9b9277fb0813418e1503e68414cd036b3b099c823379c9575e6d/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:8c8b293cd65ad716d13d8dd3624e42e5a19cc2a2f1acc74b30c2c13f15cb61a6", size = 100476, upload-time = "2025-01-14T10:34:58.055Z" },
+    { url = "https://files.pythonhosted.org/packages/a7/b1/0bb11e29aa5139d90b770ebbfa167267b1fc548d2302c30c8f7572851738/wrapt-1.17.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:4c82b8785d98cdd9fed4cac84d765d234ed3251bd6afe34cb7ac523cb93e8b4f", size = 106377, upload-time = "2025-01-14T10:34:59.3Z" },
+    { url = "https://files.pythonhosted.org/packages/6a/e1/0122853035b40b3f333bbb25f1939fc1045e21dd518f7f0922b60c156f7c/wrapt-1.17.2-cp313-cp313t-win32.whl", hash = "sha256:13e6afb7fe71fe7485a4550a8844cc9ffbe263c0f1a1eea569bc7091d4898555", size = 37986, upload-time = "2025-01-14T10:35:00.498Z" },
+    { url = "https://files.pythonhosted.org/packages/09/5e/1655cf481e079c1f22d0cabdd4e51733679932718dc23bf2db175f329b76/wrapt-1.17.2-cp313-cp313t-win_amd64.whl", hash = "sha256:eaf675418ed6b3b31c7a989fd007fa7c3be66ce14e5c3b27336383604c9da85c", size = 40750, upload-time = "2025-01-14T10:35:03.378Z" },
+    { url = "https://files.pythonhosted.org/packages/2d/82/f56956041adef78f849db6b289b282e72b55ab8045a75abad81898c28d19/wrapt-1.17.2-py3-none-any.whl", hash = "sha256:b18f2d1533a71f069c7f82d524a52599053d4c7166e9dd374ae2136b7f40f7c8", size = 23594, upload-time = "2025-01-14T10:35:44.018Z" },
 ]


Reviews:
user: login: jakejh
state: CHANGES_REQUESTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T07:21:18Z
user: login: jakejh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-19T17:35:58Z
user: login: AmmarAhmedAlvi
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-24T02:22:34Z
user: login: AmmarAhmedAlvi
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-24T02:26:17Z
user: login: jakejh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-24T08:25:23Z
user: login: jakejh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-24T08:26:33Z
user: login: jakejh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-26T14:50:03Z
user: login: jakejh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-26T14:51:43Z
user: login: AmmarAhmedAlvi
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-26T14:56:12Z
user: login: jakejh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-26T14:58:36Z
user: login: AmmarAhmedAlvi
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-26T15:02:12Z
user: login: jakejh
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-26T15:12:42Z
user: login: AmmarAhmedAlvi
state: COMMENTED
author_association: COLLABORATOR
submitted_at: 2025-05-26T15:51:54Z
user: login: jakejh
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-27T12:39:57Z

Review comments:
diff_hunk: @@ -0,0 +1,36 @@
+name: Sync Whatsapp Attachments
+
+on:
+  schedule:
+    - cron: '0 0 * * *'
+  workflow_dispatch:
+
+jobs:
+  sync:
+    runs-on: ubuntu-latest
+    env:
+      SOURCE_PARAMS: ${{ secrets.TURN_ATTACHMENTS }}
+      SERVICE_ACCOUNT_KEY_RAW: ${{ secrets.SERVICE_ACCOUNT_KEY_RAW }}
+      SERVICE_ACCOUNT_KEY_ANALYTICS: ${{ secrets.SERVICE_ACCOUNT_KEY_ANALYTICS }}
+      TURN_HEADERS: ${{ secrets.TURN_AUTH }}
+      SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }}
+      RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+
+      - name: Install uv
+        uses: astral-sh/setup-uv@v5
+        with:
+          version: '0.7.3'
+          enable-cache: true
+
+      - name: Set up Python
+        run: uv python install
+
+      - name: Install the Project
+        run: uv sync --locked
+
+      - name: Run script
+        run: uv run python src/turn_attachments.py
path: .github/workflows/sync_whatsapp_attachments.yaml
user: login: jakejh
body: is there a reason you removed the "-m" for using a module? the advantage of "uv run -m src.andhra_pradesh_ccp" is you can load and test the module during development.
created_at: 2025-05-19T07:10:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,36 @@
+name: Sync Whatsapp Attachments
+
+on:
+  schedule:
+    - cron: '0 0 * * *'
+  workflow_dispatch:
+
+jobs:
+  sync:
+    runs-on: ubuntu-latest
+    env:
+      SOURCE_PARAMS: ${{ secrets.TURN_ATTACHMENTS }}
+      SERVICE_ACCOUNT_KEY_RAW: ${{ secrets.SERVICE_ACCOUNT_KEY_RAW }}
+      SERVICE_ACCOUNT_KEY_ANALYTICS: ${{ secrets.SERVICE_ACCOUNT_KEY_ANALYTICS }}
+      TURN_HEADERS: ${{ secrets.TURN_AUTH }}
+      SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }}
+      RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+
+      - name: Install uv
+        uses: astral-sh/setup-uv@v5
+        with:
+          version: '0.7.3'
+          enable-cache: true
+
+      - name: Set up Python
+        run: uv python install
+
+      - name: Install the Project
+        run: uv sync --locked
+
+      - name: Run script
+        run: uv run python src/turn_attachments.py
path: .github/workflows/sync_whatsapp_attachments.yaml
user: login: jakejh
body: add newline.
created_at: 2025-05-19T07:10:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -18,30 +16,18 @@ When syncing to BigQuery, the code fetches data starting with 30 days prior to t
    5. Run python scripts using `uv run path_to_script.py`.
 
 2. Configure the repository secrets locally.
-   1. Create a file secrets/andhra_pradesh_ccp.yml as follows, replacing `xxx` as appropriate:
-
-      ```yaml
-      url: xxx
-      username: xxx
-      password: xxx
-      ```
-      The `url` is currently a proxy URL, because the actual API is inaccessible within GitHub Actions due to an unresolvable misconfiguration on the API server.
-   2. Create a file secrets/andhra_pradesh_mlhp.yml as follows, replacing `xxx` as appropriate:
-
-      ```yaml
-      url: xxx
-      key: xxx
-      ```
-   3. Create a file secrets/slack_token.txt that contains the Slack bot token.
-   4. Create a file secrets/service_account_key.json that contains the JSON key for the service account that will connect to BigQuery (airbyte-user).
+   1. Create a file secrets/slack_token.txt that contains the Slack bot token.
+   2. Create a file secrets/service_account_key_raw.json that contains the JSON key for the service account that will dump the data to the storage buckets (airbyte-user). Ensure that this has 'Storage Object User' permission on the dev and prod buckets.
+   3. Create a file secrets/service_account_key_analytics.json that contains the JSON key for the service account that will connect to BigQuery (metabase-user).
+   4. Create a file secrets/turn_auth.json that contains the JSON key for the API headers used by turn for each line (available on passbolt).
    5. Create a file secrets/gh_pat.txt that contains a GitHub fine-grained personal access token that has
path: README.md
user: login: jakejh
body: should be able to remove this item.
created_at: 2025-05-19T07:11:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,77 @@
+import requests
+import mimetypes
+import argparse
+import utils
+from pathlib import Path
+
+def parse_args():
+    parser = argparse.ArgumentParser(description="Sync WhatsApp attachments to GCS and notify on errors.")
+    parser.add_argument(
+        '--params-path',
+        type=Path,
+        default=Path('params.yaml'),
+        help='Path to the params.yaml file'
+    )
+    return parser.parse_args()
+
+
+def main():
+    args = parse_args()
+    params = utils.get_params(args.params_path)
+
+    try:
+        # Fetch recent attachments from BigQuery
+        project_analytics = params['project_analytics']
+        creds_analytics = params['credentials_analytics']
+        query = f"""
+          SELECT *
+          FROM `{project_analytics}.prod.res_message_attachments`
+          WHERE inserted_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 1 DAY)
+            AND media_type NOT IN ('location', 'sticker')
+          ORDER BY channel_phone
+          """
+        df = utils.run_read_bigquery(query, creds_analytics)
+
+        # Prepare GCS bucket
+        storage_client = utils.get_storage_client(params['project_raw'], params['credentials_raw'])
+        bucket = storage_client.bucket(params['bucket_name'])
+
+        # Download each attachment and upload to GCS
+        for _, row in df[1:4].iterrows():
+            uri = row['uri']
+            media_type = row['media_type']
+            mime_type = row['mime_type']
+            phone = row['channel_phone']
+            headers = params['turn_headers'].get(phone)
path: src/turn_attachments.py
user: login: jakejh
body: reason not to use `params['turn_headers'][phone]`?
created_at: 2025-05-19T07:13:55Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,77 @@
+import requests
+import mimetypes
+import argparse
+import utils
+from pathlib import Path
+
+def parse_args():
+    parser = argparse.ArgumentParser(description="Sync WhatsApp attachments to GCS and notify on errors.")
+    parser.add_argument(
+        '--params-path',
+        type=Path,
+        default=Path('params.yaml'),
+        help='Path to the params.yaml file'
+    )
+    return parser.parse_args()
+
+
+def main():
+    args = parse_args()
+    params = utils.get_params(args.params_path)
+
+    try:
+        # Fetch recent attachments from BigQuery
+        project_analytics = params['project_analytics']
+        creds_analytics = params['credentials_analytics']
+        query = f"""
+          SELECT *
+          FROM `{project_analytics}.prod.res_message_attachments`
+          WHERE inserted_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 1 DAY)
+            AND media_type NOT IN ('location', 'sticker')
+          ORDER BY channel_phone
+          """
+        df = utils.run_read_bigquery(query, creds_analytics)
+
+        # Prepare GCS bucket
+        storage_client = utils.get_storage_client(params['project_raw'], params['credentials_raw'])
+        bucket = storage_client.bucket(params['bucket_name'])
+
+        # Download each attachment and upload to GCS
+        for _, row in df[1:4].iterrows():
+            uri = row['uri']
+            media_type = row['media_type']
+            mime_type = row['mime_type']
+            phone = row['channel_phone']
+            headers = params['turn_headers'].get(phone)
+            
+            if headers is None:
+                #print(f"No headers configured for {phone!r}, skipping {uri}")
+                continue
+
+            response = requests.get(uri, headers=headers)
+            if response.status_code != 200:
+                #print(f"Skipping {uri}: status {response.status_code}")
+                continue
+
+            filename = utils.derive_filename(uri, mime_type)
+            destination = f"{media_type}/{filename}"
+            blob = bucket.blob(destination)
+            blob.upload_from_string(
+                response.content,
+                content_type=mimetypes.guess_type(filename)[0] or "application/octet-stream"
+            )
+            print(f"Saved {filename}")
+
+    except Exception as e:
+        # Notify on Slack if running in prod
+        text = utils.get_slack_message_text(e)
+        utils.send_message_to_slack(
+            text,
+            params['slack_channel_id'],
+            params['slack_token']
+        )
+        raise e
+
+
+if __name__ == '__main__':
+    main()
path: src/turn_attachments.py
user: login: jakejh
body: add newline.
created_at: 2025-05-19T07:14:21Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,130 +1,127 @@
 import google
-import io
 import json
 import os
+import time
+import pandas as pd
 import oyaml as yaml
-import polars as pl
 import slack_sdk
-import time
-import uuid
-import warnings
-from google.cloud import bigquery
-from google.oauth2.service_account import Credentials
+import mimetypes
+from urllib.parse import urlparse
 from pathlib import Path
+from google.cloud import bigquery, bigquery_storage, storage
+from google.oauth2.service_account import Credentials
 
 
-def get_params(source_name, params_path = 'params.yaml', envir = None):
-  with open(params_path) as f:
-    params = yaml.safe_load(f)
-
-  github_ref_name = os.getenv('GITHUB_REF_NAME')
-  params.update({'github_ref_name': github_ref_name})
-  if envir is None:
-    envir = 'prod' if github_ref_name == 'main' else 'dev'
-
-  y = [x for x in params['sources'] if x['name'] == source_name][0]
-  params['source_name'] = y['name']
-
-  z = [x for x in y['environments'] if x['name'] == envir][0]
-  params['environment'] = z.pop('name')
-  params.update(z)
-  del params['sources']
-
-  key = 'service_account_key'
-  if github_ref_name is None:
-    with open(Path('secrets', params[key])) as f:
-      params[key] = json.load(f)
-    with open(Path('secrets', 'slack_token.txt')) as f:
-      params['slack_token'] = f.read().strip('\n')
-    with open(Path('secrets', f'{source_name}.yaml')) as f:
-      params['source_params'] = yaml.safe_load(f)
-  else:
-    params[key] = json.loads(os.getenv('SERVICE_ACCOUNT_KEY'))
-    params['slack_token'] = os.getenv('SLACK_TOKEN')
-    params['source_params'] = yaml.safe_load(os.getenv('SOURCE_PARAMS'))
-
-  params['credentials'] = Credentials.from_service_account_info(params[key])
-  del params[key]
-  return params
-
-
-def json_dumps_list(x):
-  return json.dumps(x.to_list())
-
-
-def run_bigquery(query, credentials, num_tries = 5, wait_secs = 5):
-  client = bigquery.Client(credentials = credentials)
-  gtg = False
-  idx_try = 1
-  while not gtg and idx_try <= num_tries:
-    try:
-      query_job = client.query(query)
-      result = query_job.result()
-      gtg = True
-    except google.api_core.exceptions.BadRequest as e:
-      if 'due to concurrent update' in str(e) and (idx_try < num_tries):
-        time.sleep(wait_secs)
-        idx_try += 1
-      else:
-        raise e
-  return result
-
-
-def read_bigquery(query, credentials):
-  with warnings.catch_warnings(action = 'ignore'):
-    rows = run_bigquery(query, credentials).to_arrow()
-    df = pl.from_arrow(rows)
-  return df
-
-
-def read_bigquery_exists(table_name, params):
-  q = f"select * from `{params['dataset']}.__TABLES__` where table_id = '{table_name}'"
-  df = read_bigquery(q, params['credentials'])
-  return df.shape[0] > 0
-
-
-def get_bigquery_schema(df):
-  n = 'NUMERIC'
-  i = 'INT64'
-  s = 'STRING'
-  dtype_mappings = {
-    pl.Decimal: n, pl.Float32: n, pl.Float64: n,
-    pl.Int8: i, pl.Int16: i, pl.Int32: i, pl.Int64: i,
-    pl.UInt8: i, pl.UInt16: i, pl.UInt32: i, pl.UInt64: i,
-    pl.String: s, pl.Categorical: s, pl.Enum: s, pl.Utf8: s,
-    pl.Binary: 'BOOLEAN', pl.Boolean: 'BOOLEAN', pl.Date: 'DATE',
-    pl.Datetime('us', time_zone = 'UTC'): 'TIMESTAMP',
-    pl.Datetime('us', time_zone = None): 'DATETIME',  # won't work for other time zones
-    pl.Time: 'TIME', pl.Duration: 'INTERVAL', pl.Null: 'STRING'
-  }
-  schema = []
-  for j in range(df.shape[1]):
-    dtype = dtype_mappings[df.dtypes[j]]
-    schema.append(bigquery.SchemaField(df.columns[j], dtype))
-  return schema
-
-
-def write_bigquery(df, table_name, params, write_disposition = 'WRITE_EMPTY'):
-  schema = get_bigquery_schema(df)
-  client = bigquery.Client(credentials = params['credentials'])
-  with io.BytesIO() as stream:
-    df.write_parquet(stream)
-    stream.seek(0)
-    job_config = bigquery.LoadJobConfig(
-      source_format = 'PARQUET', schema = schema, write_disposition = write_disposition)
-    job = client.load_table_from_file(
-      stream, destination = f"{params['dataset']}.{table_name}",
-      project = params['project'], job_config = job_config)
-  job.result()
-  return df.shape[0]
-
-
-def add_extracted_columns(df, extracted_at = None):
-  if extracted_at is not None:
-    df = df.with_columns(_extracted_at = pl.lit(extracted_at))
-  uuids = [str(uuid.uuid4()) for _ in range(df.shape[0])]
-  df = df.with_columns(pl.Series('_extracted_uuid', uuids))
-  return df
+def get_params(params_path = 'params.yaml', envir = None):
+    """
+    Load global settings and environment-specific bucket_name from params.yaml.
+    Returns a dict with credentials, GCS project IDs, bucket_name, turn headers, and Slack config.
+    """
+    # Load params file
+    with open(params_path) as f:
+        params = yaml.safe_load(f)
+
+    # Determine environment (dev vs prod) from GitHub Actions or override
+    github_ref_name = os.getenv('GITHUB_REF_NAME')
+    if envir is None:
+        envir = 'prod' if github_ref_name == 'main' else 'dev'
+
+    # Extract bucket_name for this environment
+    env_cfg = params['environments'][envir]
+    params['bucket_name'] = env_cfg['bucket_name']
+    # Clean up
+    del params['environments']
+
+    # Load service account credentials and turn headers
+    if github_ref_name is None:
+        # Local dev: read files from 'secrets/'
+        raw_key_path = Path('secrets', params['service_account_key_raw'])
+        analytics_key_path = Path('secrets', params['service_account_key_analytics'])
+        params['credentials_raw'] = Credentials.from_service_account_file(raw_key_path)
+        params['credentials_analytics'] = Credentials.from_service_account_file(analytics_key_path)
+
+        turn_headers_path = Path('secrets', params['turn_headers'])
+        params['turn_headers'] = json.load(open(turn_headers_path))
+
+        # Slack token from local file
+        params['slack_token'] = Path('secrets', 'slack_token.txt').read_text().strip()
+    else:
+        # CI: read JSON strings from environment variables
+        params['credentials_raw'] = Credentials.from_service_account_info(
+            json.loads(os.getenv('SERVICE_ACCOUNT_KEY_RAW', '{}'))
+        )
+        params['credentials_analytics'] = Credentials.from_service_account_info(
+            json.loads(os.getenv('SERVICE_ACCOUNT_KEY_ANALYTICS', '{}'))
+        )
+        params['turn_headers'] = json.loads(os.getenv('TURN_HEADERS', '{}'))
+        params['slack_token'] = os.getenv('SLACK_TOKEN')
+
+    # Slack channel ID (same for dev/prod)
+    params['slack_channel_id'] = params['slack_channel_id']
path: src/utils.py
user: login: jakejh
body: not sure what this is doing.
created_at: 2025-05-19T07:15:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,130 +1,127 @@
 import google
-import io
 import json
 import os
+import time
+import pandas as pd
 import oyaml as yaml
-import polars as pl
 import slack_sdk
-import time
-import uuid
-import warnings
-from google.cloud import bigquery
-from google.oauth2.service_account import Credentials
+import mimetypes
+from urllib.parse import urlparse
 from pathlib import Path
+from google.cloud import bigquery, bigquery_storage, storage
+from google.oauth2.service_account import Credentials
 
 
-def get_params(source_name, params_path = 'params.yaml', envir = None):
-  with open(params_path) as f:
-    params = yaml.safe_load(f)
-
-  github_ref_name = os.getenv('GITHUB_REF_NAME')
-  params.update({'github_ref_name': github_ref_name})
-  if envir is None:
-    envir = 'prod' if github_ref_name == 'main' else 'dev'
-
-  y = [x for x in params['sources'] if x['name'] == source_name][0]
-  params['source_name'] = y['name']
-
-  z = [x for x in y['environments'] if x['name'] == envir][0]
-  params['environment'] = z.pop('name')
-  params.update(z)
-  del params['sources']
-
-  key = 'service_account_key'
-  if github_ref_name is None:
-    with open(Path('secrets', params[key])) as f:
-      params[key] = json.load(f)
-    with open(Path('secrets', 'slack_token.txt')) as f:
-      params['slack_token'] = f.read().strip('\n')
-    with open(Path('secrets', f'{source_name}.yaml')) as f:
-      params['source_params'] = yaml.safe_load(f)
-  else:
-    params[key] = json.loads(os.getenv('SERVICE_ACCOUNT_KEY'))
-    params['slack_token'] = os.getenv('SLACK_TOKEN')
-    params['source_params'] = yaml.safe_load(os.getenv('SOURCE_PARAMS'))
-
-  params['credentials'] = Credentials.from_service_account_info(params[key])
-  del params[key]
-  return params
-
-
-def json_dumps_list(x):
-  return json.dumps(x.to_list())
-
-
-def run_bigquery(query, credentials, num_tries = 5, wait_secs = 5):
-  client = bigquery.Client(credentials = credentials)
-  gtg = False
-  idx_try = 1
-  while not gtg and idx_try <= num_tries:
-    try:
-      query_job = client.query(query)
-      result = query_job.result()
-      gtg = True
-    except google.api_core.exceptions.BadRequest as e:
-      if 'due to concurrent update' in str(e) and (idx_try < num_tries):
-        time.sleep(wait_secs)
-        idx_try += 1
-      else:
-        raise e
-  return result
-
-
-def read_bigquery(query, credentials):
-  with warnings.catch_warnings(action = 'ignore'):
-    rows = run_bigquery(query, credentials).to_arrow()
-    df = pl.from_arrow(rows)
-  return df
-
-
-def read_bigquery_exists(table_name, params):
-  q = f"select * from `{params['dataset']}.__TABLES__` where table_id = '{table_name}'"
-  df = read_bigquery(q, params['credentials'])
-  return df.shape[0] > 0
-
-
-def get_bigquery_schema(df):
-  n = 'NUMERIC'
-  i = 'INT64'
-  s = 'STRING'
-  dtype_mappings = {
-    pl.Decimal: n, pl.Float32: n, pl.Float64: n,
-    pl.Int8: i, pl.Int16: i, pl.Int32: i, pl.Int64: i,
-    pl.UInt8: i, pl.UInt16: i, pl.UInt32: i, pl.UInt64: i,
-    pl.String: s, pl.Categorical: s, pl.Enum: s, pl.Utf8: s,
-    pl.Binary: 'BOOLEAN', pl.Boolean: 'BOOLEAN', pl.Date: 'DATE',
-    pl.Datetime('us', time_zone = 'UTC'): 'TIMESTAMP',
-    pl.Datetime('us', time_zone = None): 'DATETIME',  # won't work for other time zones
-    pl.Time: 'TIME', pl.Duration: 'INTERVAL', pl.Null: 'STRING'
-  }
-  schema = []
-  for j in range(df.shape[1]):
-    dtype = dtype_mappings[df.dtypes[j]]
-    schema.append(bigquery.SchemaField(df.columns[j], dtype))
-  return schema
-
-
-def write_bigquery(df, table_name, params, write_disposition = 'WRITE_EMPTY'):
-  schema = get_bigquery_schema(df)
-  client = bigquery.Client(credentials = params['credentials'])
-  with io.BytesIO() as stream:
-    df.write_parquet(stream)
-    stream.seek(0)
-    job_config = bigquery.LoadJobConfig(
-      source_format = 'PARQUET', schema = schema, write_disposition = write_disposition)
-    job = client.load_table_from_file(
-      stream, destination = f"{params['dataset']}.{table_name}",
-      project = params['project'], job_config = job_config)
-  job.result()
-  return df.shape[0]
-
-
-def add_extracted_columns(df, extracted_at = None):
-  if extracted_at is not None:
-    df = df.with_columns(_extracted_at = pl.lit(extracted_at))
-  uuids = [str(uuid.uuid4()) for _ in range(df.shape[0])]
-  df = df.with_columns(pl.Series('_extracted_uuid', uuids))
-  return df
+def get_params(params_path = 'params.yaml', envir = None):
+    """
+    Load global settings and environment-specific bucket_name from params.yaml.
+    Returns a dict with credentials, GCS project IDs, bucket_name, turn headers, and Slack config.
+    """
+    # Load params file
+    with open(params_path) as f:
+        params = yaml.safe_load(f)
+
+    # Determine environment (dev vs prod) from GitHub Actions or override
+    github_ref_name = os.getenv('GITHUB_REF_NAME')
+    if envir is None:
+        envir = 'prod' if github_ref_name == 'main' else 'dev'
+
+    # Extract bucket_name for this environment
+    env_cfg = params['environments'][envir]
+    params['bucket_name'] = env_cfg['bucket_name']
+    # Clean up
+    del params['environments']
+
+    # Load service account credentials and turn headers
+    if github_ref_name is None:
+        # Local dev: read files from 'secrets/'
+        raw_key_path = Path('secrets', params['service_account_key_raw'])
+        analytics_key_path = Path('secrets', params['service_account_key_analytics'])
+        params['credentials_raw'] = Credentials.from_service_account_file(raw_key_path)
+        params['credentials_analytics'] = Credentials.from_service_account_file(analytics_key_path)
+
+        turn_headers_path = Path('secrets', params['turn_headers'])
+        params['turn_headers'] = json.load(open(turn_headers_path))
+
+        # Slack token from local file
+        params['slack_token'] = Path('secrets', 'slack_token.txt').read_text().strip()
+    else:
+        # CI: read JSON strings from environment variables
+        params['credentials_raw'] = Credentials.from_service_account_info(
+            json.loads(os.getenv('SERVICE_ACCOUNT_KEY_RAW', '{}'))
+        )
+        params['credentials_analytics'] = Credentials.from_service_account_info(
+            json.loads(os.getenv('SERVICE_ACCOUNT_KEY_ANALYTICS', '{}'))
+        )
+        params['turn_headers'] = json.loads(os.getenv('TURN_HEADERS', '{}'))
+        params['slack_token'] = os.getenv('SLACK_TOKEN')
+
+    # Slack channel ID (same for dev/prod)
+    params['slack_channel_id'] = params['slack_channel_id']
+
+    return params
+
+
+def get_storage_client(project, credentials):
+    return storage.Client(project=project, credentials=credentials)
+
+
+def get_bigquery_client(project, credentials):
+    return bigquery.Client(project=project, credentials=credentials)
+
+def derive_filename(uri, mime_type):
+    """
+    Extract the basename from a URI; if missing an extension, guess from the MIME type.
+    """
+    path = urlparse(uri).path
+    filename = os.path.basename(path)
+    name, ext = os.path.splitext(filename)
+    if not ext and mime_type:
path: src/utils.py
user: login: jakejh
body: would prefer to make this logic more explicit.
created_at: 2025-05-19T07:17:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,77 @@
+import requests
+import mimetypes
+import argparse
+import utils
+from pathlib import Path
+
+def parse_args():
+    parser = argparse.ArgumentParser(description="Sync WhatsApp attachments to GCS and notify on errors.")
+    parser.add_argument(
+        '--params-path',
+        type=Path,
+        default=Path('params.yaml'),
+        help='Path to the params.yaml file'
+    )
+    return parser.parse_args()
+
+
+def main():
+    args = parse_args()
+    params = utils.get_params(args.params_path)
+
+    try:
+        # Fetch recent attachments from BigQuery
+        project_analytics = params['project_analytics']
+        creds_analytics = params['credentials_analytics']
+        query = f"""
+          SELECT *
+          FROM `{project_analytics}.prod.res_message_attachments`
+          WHERE inserted_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 1 DAY)
+            AND media_type NOT IN ('location', 'sticker')
+          ORDER BY channel_phone
+          """
+        df = utils.run_read_bigquery(query, creds_analytics)
+
+        # Prepare GCS bucket
+        storage_client = utils.get_storage_client(params['project_raw'], params['credentials_raw'])
+        bucket = storage_client.bucket(params['bucket_name'])
+
+        # Download each attachment and upload to GCS
+        for _, row in df[1:4].iterrows():
+            uri = row['uri']
+            media_type = row['media_type']
+            mime_type = row['mime_type']
+            phone = row['channel_phone']
+            headers = params['turn_headers'].get(phone)
+            
+            if headers is None:
+                #print(f"No headers configured for {phone!r}, skipping {uri}")
+                continue
+
+            response = requests.get(uri, headers=headers)
+            if response.status_code != 200:
path: src/turn_attachments.py
user: login: jakejh
body: for clarity, would consider reversing the logic of the `if` and putting the code below inside the `if`.
created_at: 2025-05-19T07:21:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,36 @@
+name: Sync Whatsapp Attachments
+
+on:
+  schedule:
+    - cron: '0 0 * * *'
+  workflow_dispatch:
+
+jobs:
+  sync:
+    runs-on: ubuntu-latest
+    env:
+      SOURCE_PARAMS: ${{ secrets.TURN_ATTACHMENTS }}
+      SERVICE_ACCOUNT_KEY_RAW: ${{ secrets.SERVICE_ACCOUNT_KEY_RAW }}
+      SERVICE_ACCOUNT_KEY_ANALYTICS: ${{ secrets.SERVICE_ACCOUNT_KEY_ANALYTICS }}
+      TURN_HEADERS: ${{ secrets.TURN_AUTH }}
+      SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }}
+      RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+
+      - name: Install uv
+        uses: astral-sh/setup-uv@v5
path: .github/workflows/sync_whatsapp_attachments.yaml
user: login: jakejh
body: should be able to set this to "v6", and to remove the "with:" block, since caching is enabled by default on github-hosted runners.
created_at: 2025-05-19T17:35:58Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,36 @@
+name: Sync Whatsapp Attachments
+
+on:
+  schedule:
+    - cron: '0 0 * * *'
+  workflow_dispatch:
+
+jobs:
+  sync:
+    runs-on: ubuntu-latest
+    env:
+      SOURCE_PARAMS: ${{ secrets.TURN_ATTACHMENTS }}
+      SERVICE_ACCOUNT_KEY_RAW: ${{ secrets.SERVICE_ACCOUNT_KEY_RAW }}
+      SERVICE_ACCOUNT_KEY_ANALYTICS: ${{ secrets.SERVICE_ACCOUNT_KEY_ANALYTICS }}
+      TURN_HEADERS: ${{ secrets.TURN_AUTH }}
+      SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }}
+      RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+
+      - name: Install uv
+        uses: astral-sh/setup-uv@v5
+        with:
+          version: '0.7.3'
+          enable-cache: true
+
+      - name: Set up Python
+        run: uv python install
+
+      - name: Install the Project
+        run: uv sync --locked
+
+      - name: Run script
+        run: uv run python src/turn_attachments.py
path: .github/workflows/sync_whatsapp_attachments.yaml
user: login: AmmarAhmedAlvi
body: Oh I didn't know that. Can you explain a bit more? How is it helpful to put in the workflow?
created_at: 2025-05-24T02:22:34Z
author_association: COLLABORATOR
diff_hunk: @@ -1,130 +1,127 @@
 import google
-import io
 import json
 import os
+import time
+import pandas as pd
 import oyaml as yaml
-import polars as pl
 import slack_sdk
-import time
-import uuid
-import warnings
-from google.cloud import bigquery
-from google.oauth2.service_account import Credentials
+import mimetypes
+from urllib.parse import urlparse
 from pathlib import Path
+from google.cloud import bigquery, bigquery_storage, storage
+from google.oauth2.service_account import Credentials
 
 
-def get_params(source_name, params_path = 'params.yaml', envir = None):
-  with open(params_path) as f:
-    params = yaml.safe_load(f)
-
-  github_ref_name = os.getenv('GITHUB_REF_NAME')
-  params.update({'github_ref_name': github_ref_name})
-  if envir is None:
-    envir = 'prod' if github_ref_name == 'main' else 'dev'
-
-  y = [x for x in params['sources'] if x['name'] == source_name][0]
-  params['source_name'] = y['name']
-
-  z = [x for x in y['environments'] if x['name'] == envir][0]
-  params['environment'] = z.pop('name')
-  params.update(z)
-  del params['sources']
-
-  key = 'service_account_key'
-  if github_ref_name is None:
-    with open(Path('secrets', params[key])) as f:
-      params[key] = json.load(f)
-    with open(Path('secrets', 'slack_token.txt')) as f:
-      params['slack_token'] = f.read().strip('\n')
-    with open(Path('secrets', f'{source_name}.yaml')) as f:
-      params['source_params'] = yaml.safe_load(f)
-  else:
-    params[key] = json.loads(os.getenv('SERVICE_ACCOUNT_KEY'))
-    params['slack_token'] = os.getenv('SLACK_TOKEN')
-    params['source_params'] = yaml.safe_load(os.getenv('SOURCE_PARAMS'))
-
-  params['credentials'] = Credentials.from_service_account_info(params[key])
-  del params[key]
-  return params
-
-
-def json_dumps_list(x):
-  return json.dumps(x.to_list())
-
-
-def run_bigquery(query, credentials, num_tries = 5, wait_secs = 5):
-  client = bigquery.Client(credentials = credentials)
-  gtg = False
-  idx_try = 1
-  while not gtg and idx_try <= num_tries:
-    try:
-      query_job = client.query(query)
-      result = query_job.result()
-      gtg = True
-    except google.api_core.exceptions.BadRequest as e:
-      if 'due to concurrent update' in str(e) and (idx_try < num_tries):
-        time.sleep(wait_secs)
-        idx_try += 1
-      else:
-        raise e
-  return result
-
-
-def read_bigquery(query, credentials):
-  with warnings.catch_warnings(action = 'ignore'):
-    rows = run_bigquery(query, credentials).to_arrow()
-    df = pl.from_arrow(rows)
-  return df
-
-
-def read_bigquery_exists(table_name, params):
-  q = f"select * from `{params['dataset']}.__TABLES__` where table_id = '{table_name}'"
-  df = read_bigquery(q, params['credentials'])
-  return df.shape[0] > 0
-
-
-def get_bigquery_schema(df):
-  n = 'NUMERIC'
-  i = 'INT64'
-  s = 'STRING'
-  dtype_mappings = {
-    pl.Decimal: n, pl.Float32: n, pl.Float64: n,
-    pl.Int8: i, pl.Int16: i, pl.Int32: i, pl.Int64: i,
-    pl.UInt8: i, pl.UInt16: i, pl.UInt32: i, pl.UInt64: i,
-    pl.String: s, pl.Categorical: s, pl.Enum: s, pl.Utf8: s,
-    pl.Binary: 'BOOLEAN', pl.Boolean: 'BOOLEAN', pl.Date: 'DATE',
-    pl.Datetime('us', time_zone = 'UTC'): 'TIMESTAMP',
-    pl.Datetime('us', time_zone = None): 'DATETIME',  # won't work for other time zones
-    pl.Time: 'TIME', pl.Duration: 'INTERVAL', pl.Null: 'STRING'
-  }
-  schema = []
-  for j in range(df.shape[1]):
-    dtype = dtype_mappings[df.dtypes[j]]
-    schema.append(bigquery.SchemaField(df.columns[j], dtype))
-  return schema
-
-
-def write_bigquery(df, table_name, params, write_disposition = 'WRITE_EMPTY'):
-  schema = get_bigquery_schema(df)
-  client = bigquery.Client(credentials = params['credentials'])
-  with io.BytesIO() as stream:
-    df.write_parquet(stream)
-    stream.seek(0)
-    job_config = bigquery.LoadJobConfig(
-      source_format = 'PARQUET', schema = schema, write_disposition = write_disposition)
-    job = client.load_table_from_file(
-      stream, destination = f"{params['dataset']}.{table_name}",
-      project = params['project'], job_config = job_config)
-  job.result()
-  return df.shape[0]
-
-
-def add_extracted_columns(df, extracted_at = None):
-  if extracted_at is not None:
-    df = df.with_columns(_extracted_at = pl.lit(extracted_at))
-  uuids = [str(uuid.uuid4()) for _ in range(df.shape[0])]
-  df = df.with_columns(pl.Series('_extracted_uuid', uuids))
-  return df
+def get_params(params_path = 'params.yaml', envir = None):
+    """
+    Load global settings and environment-specific bucket_name from params.yaml.
+    Returns a dict with credentials, GCS project IDs, bucket_name, turn headers, and Slack config.
+    """
+    # Load params file
+    with open(params_path) as f:
+        params = yaml.safe_load(f)
+
+    # Determine environment (dev vs prod) from GitHub Actions or override
+    github_ref_name = os.getenv('GITHUB_REF_NAME')
+    if envir is None:
+        envir = 'prod' if github_ref_name == 'main' else 'dev'
+
+    # Extract bucket_name for this environment
+    env_cfg = params['environments'][envir]
+    params['bucket_name'] = env_cfg['bucket_name']
+    # Clean up
+    del params['environments']
+
+    # Load service account credentials and turn headers
+    if github_ref_name is None:
+        # Local dev: read files from 'secrets/'
+        raw_key_path = Path('secrets', params['service_account_key_raw'])
+        analytics_key_path = Path('secrets', params['service_account_key_analytics'])
+        params['credentials_raw'] = Credentials.from_service_account_file(raw_key_path)
+        params['credentials_analytics'] = Credentials.from_service_account_file(analytics_key_path)
+
+        turn_headers_path = Path('secrets', params['turn_headers'])
+        params['turn_headers'] = json.load(open(turn_headers_path))
+
+        # Slack token from local file
+        params['slack_token'] = Path('secrets', 'slack_token.txt').read_text().strip()
+    else:
+        # CI: read JSON strings from environment variables
+        params['credentials_raw'] = Credentials.from_service_account_info(
+            json.loads(os.getenv('SERVICE_ACCOUNT_KEY_RAW', '{}'))
+        )
+        params['credentials_analytics'] = Credentials.from_service_account_info(
+            json.loads(os.getenv('SERVICE_ACCOUNT_KEY_ANALYTICS', '{}'))
+        )
+        params['turn_headers'] = json.loads(os.getenv('TURN_HEADERS', '{}'))
+        params['slack_token'] = os.getenv('SLACK_TOKEN')
+
+    # Slack channel ID (same for dev/prod)
+    params['slack_channel_id'] = params['slack_channel_id']
+
+    return params
+
+
+def get_storage_client(project, credentials):
+    return storage.Client(project=project, credentials=credentials)
+
+
+def get_bigquery_client(project, credentials):
+    return bigquery.Client(project=project, credentials=credentials)
+
+def derive_filename(uri, mime_type):
+    """
+    Extract the basename from a URI; if missing an extension, guess from the MIME type.
+    """
+    path = urlparse(uri).path
+    filename = os.path.basename(path)
+    name, ext = os.path.splitext(filename)
+    if not ext and mime_type:
path: src/utils.py
user: login: AmmarAhmedAlvi
body: I did put a comment in the function to describe it. What do you think needs to be made more explicit about the logic here?
created_at: 2025-05-24T02:26:10Z
author_association: COLLABORATOR
diff_hunk: @@ -1,130 +1,127 @@
 import google
-import io
 import json
 import os
+import time
+import pandas as pd
 import oyaml as yaml
-import polars as pl
 import slack_sdk
-import time
-import uuid
-import warnings
-from google.cloud import bigquery
-from google.oauth2.service_account import Credentials
+import mimetypes
+from urllib.parse import urlparse
 from pathlib import Path
+from google.cloud import bigquery, bigquery_storage, storage
+from google.oauth2.service_account import Credentials
 
 
-def get_params(source_name, params_path = 'params.yaml', envir = None):
-  with open(params_path) as f:
-    params = yaml.safe_load(f)
-
-  github_ref_name = os.getenv('GITHUB_REF_NAME')
-  params.update({'github_ref_name': github_ref_name})
-  if envir is None:
-    envir = 'prod' if github_ref_name == 'main' else 'dev'
-
-  y = [x for x in params['sources'] if x['name'] == source_name][0]
-  params['source_name'] = y['name']
-
-  z = [x for x in y['environments'] if x['name'] == envir][0]
-  params['environment'] = z.pop('name')
-  params.update(z)
-  del params['sources']
-
-  key = 'service_account_key'
-  if github_ref_name is None:
-    with open(Path('secrets', params[key])) as f:
-      params[key] = json.load(f)
-    with open(Path('secrets', 'slack_token.txt')) as f:
-      params['slack_token'] = f.read().strip('\n')
-    with open(Path('secrets', f'{source_name}.yaml')) as f:
-      params['source_params'] = yaml.safe_load(f)
-  else:
-    params[key] = json.loads(os.getenv('SERVICE_ACCOUNT_KEY'))
-    params['slack_token'] = os.getenv('SLACK_TOKEN')
-    params['source_params'] = yaml.safe_load(os.getenv('SOURCE_PARAMS'))
-
-  params['credentials'] = Credentials.from_service_account_info(params[key])
-  del params[key]
-  return params
-
-
-def json_dumps_list(x):
-  return json.dumps(x.to_list())
-
-
-def run_bigquery(query, credentials, num_tries = 5, wait_secs = 5):
-  client = bigquery.Client(credentials = credentials)
-  gtg = False
-  idx_try = 1
-  while not gtg and idx_try <= num_tries:
-    try:
-      query_job = client.query(query)
-      result = query_job.result()
-      gtg = True
-    except google.api_core.exceptions.BadRequest as e:
-      if 'due to concurrent update' in str(e) and (idx_try < num_tries):
-        time.sleep(wait_secs)
-        idx_try += 1
-      else:
-        raise e
-  return result
-
-
-def read_bigquery(query, credentials):
-  with warnings.catch_warnings(action = 'ignore'):
-    rows = run_bigquery(query, credentials).to_arrow()
-    df = pl.from_arrow(rows)
-  return df
-
-
-def read_bigquery_exists(table_name, params):
-  q = f"select * from `{params['dataset']}.__TABLES__` where table_id = '{table_name}'"
-  df = read_bigquery(q, params['credentials'])
-  return df.shape[0] > 0
-
-
-def get_bigquery_schema(df):
-  n = 'NUMERIC'
-  i = 'INT64'
-  s = 'STRING'
-  dtype_mappings = {
-    pl.Decimal: n, pl.Float32: n, pl.Float64: n,
-    pl.Int8: i, pl.Int16: i, pl.Int32: i, pl.Int64: i,
-    pl.UInt8: i, pl.UInt16: i, pl.UInt32: i, pl.UInt64: i,
-    pl.String: s, pl.Categorical: s, pl.Enum: s, pl.Utf8: s,
-    pl.Binary: 'BOOLEAN', pl.Boolean: 'BOOLEAN', pl.Date: 'DATE',
-    pl.Datetime('us', time_zone = 'UTC'): 'TIMESTAMP',
-    pl.Datetime('us', time_zone = None): 'DATETIME',  # won't work for other time zones
-    pl.Time: 'TIME', pl.Duration: 'INTERVAL', pl.Null: 'STRING'
-  }
-  schema = []
-  for j in range(df.shape[1]):
-    dtype = dtype_mappings[df.dtypes[j]]
-    schema.append(bigquery.SchemaField(df.columns[j], dtype))
-  return schema
-
-
-def write_bigquery(df, table_name, params, write_disposition = 'WRITE_EMPTY'):
-  schema = get_bigquery_schema(df)
-  client = bigquery.Client(credentials = params['credentials'])
-  with io.BytesIO() as stream:
-    df.write_parquet(stream)
-    stream.seek(0)
-    job_config = bigquery.LoadJobConfig(
-      source_format = 'PARQUET', schema = schema, write_disposition = write_disposition)
-    job = client.load_table_from_file(
-      stream, destination = f"{params['dataset']}.{table_name}",
-      project = params['project'], job_config = job_config)
-  job.result()
-  return df.shape[0]
-
-
-def add_extracted_columns(df, extracted_at = None):
-  if extracted_at is not None:
-    df = df.with_columns(_extracted_at = pl.lit(extracted_at))
-  uuids = [str(uuid.uuid4()) for _ in range(df.shape[0])]
-  df = df.with_columns(pl.Series('_extracted_uuid', uuids))
-  return df
+def get_params(params_path = 'params.yaml', envir = None):
+    """
+    Load global settings and environment-specific bucket_name from params.yaml.
+    Returns a dict with credentials, GCS project IDs, bucket_name, turn headers, and Slack config.
+    """
+    # Load params file
+    with open(params_path) as f:
+        params = yaml.safe_load(f)
+
+    # Determine environment (dev vs prod) from GitHub Actions or override
+    github_ref_name = os.getenv('GITHUB_REF_NAME')
+    if envir is None:
+        envir = 'prod' if github_ref_name == 'main' else 'dev'
+
+    # Extract bucket_name for this environment
+    env_cfg = params['environments'][envir]
+    params['bucket_name'] = env_cfg['bucket_name']
+    # Clean up
+    del params['environments']
+
+    # Load service account credentials and turn headers
+    if github_ref_name is None:
+        # Local dev: read files from 'secrets/'
+        raw_key_path = Path('secrets', params['service_account_key_raw'])
+        analytics_key_path = Path('secrets', params['service_account_key_analytics'])
+        params['credentials_raw'] = Credentials.from_service_account_file(raw_key_path)
+        params['credentials_analytics'] = Credentials.from_service_account_file(analytics_key_path)
+
+        turn_headers_path = Path('secrets', params['turn_headers'])
+        params['turn_headers'] = json.load(open(turn_headers_path))
+
+        # Slack token from local file
+        params['slack_token'] = Path('secrets', 'slack_token.txt').read_text().strip()
+    else:
+        # CI: read JSON strings from environment variables
+        params['credentials_raw'] = Credentials.from_service_account_info(
+            json.loads(os.getenv('SERVICE_ACCOUNT_KEY_RAW', '{}'))
+        )
+        params['credentials_analytics'] = Credentials.from_service_account_info(
+            json.loads(os.getenv('SERVICE_ACCOUNT_KEY_ANALYTICS', '{}'))
+        )
+        params['turn_headers'] = json.loads(os.getenv('TURN_HEADERS', '{}'))
+        params['slack_token'] = os.getenv('SLACK_TOKEN')
+
+    # Slack channel ID (same for dev/prod)
+    params['slack_channel_id'] = params['slack_channel_id']
+
+    return params
+
+
+def get_storage_client(project, credentials):
+    return storage.Client(project=project, credentials=credentials)
+
+
+def get_bigquery_client(project, credentials):
+    return bigquery.Client(project=project, credentials=credentials)
+
+def derive_filename(uri, mime_type):
+    """
+    Extract the basename from a URI; if missing an extension, guess from the MIME type.
+    """
+    path = urlparse(uri).path
+    filename = os.path.basename(path)
+    name, ext = os.path.splitext(filename)
+    if not ext and mime_type:
path: src/utils.py
user: login: jakejh
body: `not ext and mime_type` is ambiguous to me, because neither of those variables is a boolean.
created_at: 2025-05-24T08:25:23Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,36 @@
+name: Sync Whatsapp Attachments
+
+on:
+  schedule:
+    - cron: '0 0 * * *'
+  workflow_dispatch:
+
+jobs:
+  sync:
+    runs-on: ubuntu-latest
+    env:
+      SOURCE_PARAMS: ${{ secrets.TURN_ATTACHMENTS }}
+      SERVICE_ACCOUNT_KEY_RAW: ${{ secrets.SERVICE_ACCOUNT_KEY_RAW }}
+      SERVICE_ACCOUNT_KEY_ANALYTICS: ${{ secrets.SERVICE_ACCOUNT_KEY_ANALYTICS }}
+      TURN_HEADERS: ${{ secrets.TURN_AUTH }}
+      SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }}
+      RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+
+    steps:
+      - name: Checkout code
+        uses: actions/checkout@v4
+
+      - name: Install uv
+        uses: astral-sh/setup-uv@v5
+        with:
+          version: '0.7.3'
+          enable-cache: true
+
+      - name: Set up Python
+        run: uv python install
+
+      - name: Install the Project
+        run: uv sync --locked
+
+      - name: Run script
+        run: uv run python src/turn_attachments.py
path: .github/workflows/sync_whatsapp_attachments.yaml
user: login: jakejh
body: https://stackoverflow.com/questions/7610001/what-is-the-purpose-of-the-m-switch
created_at: 2025-05-24T08:26:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,16 +1,12 @@
 slack_channel_id: C080138T9S6
-service_account_key: service_account_key.json
-project: noorahealth-raw
-sources:
-  - name: andhra_pradesh_ccp
-    environments:
-      - name: prod
-        dataset: andhra_pradesh_ccp
-      - name: dev
-        dataset: andhra_pradesh_ccp_dev
-  - name: andhra_pradesh_mlhp
-    environments:
-      - name: prod
-        dataset: andhra_pradesh_mlhp
-      - name: dev
-        dataset: andhra_pradesh_mlhp_dev
+service_account_key_raw: service_account_key_raw.json
+service_account_key_analytics: service_account_key_analytics.json
+turn_headers: turn_auth.json
+project_raw: noorahealth-raw
+project_analytics: noorahealth-analytics
+
+environments:
+  dev:
+    bucket_name: noora_test_bucket
+  prod:
+    bucket_name: noora_whatsapp_attachments_inbound
path: params.yaml
user: login: jakejh
body: add newline.
created_at: 2025-05-26T14:50:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,78 @@
+import requests
+import mimetypes
+import argparse
+import utils
+from pathlib import Path
+
+def parse_args():
+    parser = argparse.ArgumentParser(description="Sync WhatsApp attachments to GCS and notify on errors.")
+    parser.add_argument(
+        '--params-path',
+        type=Path,
+        default=Path('params.yaml'),
+        help='Path to the params.yaml file'
+    )
+    return parser.parse_args()
+
+
+def main():
+    args = parse_args()
+    params = utils.get_params(args.params_path)
+
+    try:
+        # Fetch recent attachments from BigQuery
+        project_analytics = params['project_analytics']
+        creds_analytics = params['credentials_analytics']
+        query = f"""
+          SELECT *
+          FROM `{project_analytics}.prod.res_message_attachments`
+          WHERE inserted_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 28 HOUR)
+            AND media_type NOT IN ('location', 'sticker')
+          ORDER BY channel_phone
+          """
+        df = utils.run_read_bigquery(query, creds_analytics)
+
+        # Prepare GCS bucket
+        storage_client = utils.get_storage_client(params['project_raw'], params['credentials_raw'])
+        bucket = storage_client.bucket(params['bucket_name'])
+
+        # Download each attachment and upload to GCS
+        for _, row in df.iterrows():
+            uri = row['uri']
+            media_type = row['media_type']
+            mime_type = row['mime_type']
+            phone = row['channel_phone']
+            #headers = params['turn_headers'].get(phone)
+            headers = params['turn_headers'][phone]
+
+            if headers is None:
+                #print(f"No headers configured for {phone!r}, skipping {uri}")
+                continue
+
+            response = requests.get(uri, headers=headers)
+            if response.status_code == 200:
+                filename = utils.derive_filename(uri, mime_type)
+                destination = f"{media_type}/{filename}"
+                blob = bucket.blob(destination)
+                blob.upload_from_string(
+                    response.content,
+                    content_type=mimetypes.guess_type(filename)[0] or "application/octet-stream"
+                )
+                print(f"Saved {filename}")
+            else:
path: src/turn_attachments.py
user: login: jakejh
body: is this `else` necessary?
created_at: 2025-05-26T14:51:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,130 +1,127 @@
 import google
-import io
 import json
 import os
+import time
+import pandas as pd
 import oyaml as yaml
-import polars as pl
 import slack_sdk
-import time
-import uuid
-import warnings
-from google.cloud import bigquery
-from google.oauth2.service_account import Credentials
+import mimetypes
+from urllib.parse import urlparse
 from pathlib import Path
+from google.cloud import bigquery, bigquery_storage, storage
+from google.oauth2.service_account import Credentials
 
 
-def get_params(source_name, params_path = 'params.yaml', envir = None):
-  with open(params_path) as f:
-    params = yaml.safe_load(f)
-
-  github_ref_name = os.getenv('GITHUB_REF_NAME')
-  params.update({'github_ref_name': github_ref_name})
-  if envir is None:
-    envir = 'prod' if github_ref_name == 'main' else 'dev'
-
-  y = [x for x in params['sources'] if x['name'] == source_name][0]
-  params['source_name'] = y['name']
-
-  z = [x for x in y['environments'] if x['name'] == envir][0]
-  params['environment'] = z.pop('name')
-  params.update(z)
-  del params['sources']
-
-  key = 'service_account_key'
-  if github_ref_name is None:
-    with open(Path('secrets', params[key])) as f:
-      params[key] = json.load(f)
-    with open(Path('secrets', 'slack_token.txt')) as f:
-      params['slack_token'] = f.read().strip('\n')
-    with open(Path('secrets', f'{source_name}.yaml')) as f:
-      params['source_params'] = yaml.safe_load(f)
-  else:
-    params[key] = json.loads(os.getenv('SERVICE_ACCOUNT_KEY'))
-    params['slack_token'] = os.getenv('SLACK_TOKEN')
-    params['source_params'] = yaml.safe_load(os.getenv('SOURCE_PARAMS'))
-
-  params['credentials'] = Credentials.from_service_account_info(params[key])
-  del params[key]
-  return params
-
-
-def json_dumps_list(x):
-  return json.dumps(x.to_list())
-
-
-def run_bigquery(query, credentials, num_tries = 5, wait_secs = 5):
-  client = bigquery.Client(credentials = credentials)
-  gtg = False
-  idx_try = 1
-  while not gtg and idx_try <= num_tries:
-    try:
-      query_job = client.query(query)
-      result = query_job.result()
-      gtg = True
-    except google.api_core.exceptions.BadRequest as e:
-      if 'due to concurrent update' in str(e) and (idx_try < num_tries):
-        time.sleep(wait_secs)
-        idx_try += 1
-      else:
-        raise e
-  return result
-
-
-def read_bigquery(query, credentials):
-  with warnings.catch_warnings(action = 'ignore'):
-    rows = run_bigquery(query, credentials).to_arrow()
-    df = pl.from_arrow(rows)
-  return df
-
-
-def read_bigquery_exists(table_name, params):
-  q = f"select * from `{params['dataset']}.__TABLES__` where table_id = '{table_name}'"
-  df = read_bigquery(q, params['credentials'])
-  return df.shape[0] > 0
-
-
-def get_bigquery_schema(df):
-  n = 'NUMERIC'
-  i = 'INT64'
-  s = 'STRING'
-  dtype_mappings = {
-    pl.Decimal: n, pl.Float32: n, pl.Float64: n,
-    pl.Int8: i, pl.Int16: i, pl.Int32: i, pl.Int64: i,
-    pl.UInt8: i, pl.UInt16: i, pl.UInt32: i, pl.UInt64: i,
-    pl.String: s, pl.Categorical: s, pl.Enum: s, pl.Utf8: s,
-    pl.Binary: 'BOOLEAN', pl.Boolean: 'BOOLEAN', pl.Date: 'DATE',
-    pl.Datetime('us', time_zone = 'UTC'): 'TIMESTAMP',
-    pl.Datetime('us', time_zone = None): 'DATETIME',  # won't work for other time zones
-    pl.Time: 'TIME', pl.Duration: 'INTERVAL', pl.Null: 'STRING'
-  }
-  schema = []
-  for j in range(df.shape[1]):
-    dtype = dtype_mappings[df.dtypes[j]]
-    schema.append(bigquery.SchemaField(df.columns[j], dtype))
-  return schema
-
-
-def write_bigquery(df, table_name, params, write_disposition = 'WRITE_EMPTY'):
-  schema = get_bigquery_schema(df)
-  client = bigquery.Client(credentials = params['credentials'])
-  with io.BytesIO() as stream:
-    df.write_parquet(stream)
-    stream.seek(0)
-    job_config = bigquery.LoadJobConfig(
-      source_format = 'PARQUET', schema = schema, write_disposition = write_disposition)
-    job = client.load_table_from_file(
-      stream, destination = f"{params['dataset']}.{table_name}",
-      project = params['project'], job_config = job_config)
-  job.result()
-  return df.shape[0]
-
-
-def add_extracted_columns(df, extracted_at = None):
-  if extracted_at is not None:
-    df = df.with_columns(_extracted_at = pl.lit(extracted_at))
-  uuids = [str(uuid.uuid4()) for _ in range(df.shape[0])]
-  df = df.with_columns(pl.Series('_extracted_uuid', uuids))
-  return df
+def get_params(params_path = 'params.yaml', envir = None):
+    """
+    Load global settings and environment-specific bucket_name from params.yaml.
+    Returns a dict with credentials, GCS project IDs, bucket_name, turn headers, and Slack config.
+    """
+    # Load params file
+    with open(params_path) as f:
+        params = yaml.safe_load(f)
+
+    # Determine environment (dev vs prod) from GitHub Actions or override
+    github_ref_name = os.getenv('GITHUB_REF_NAME')
+    if envir is None:
+        envir = 'prod' if github_ref_name == 'main' else 'dev'
+
+    # Extract bucket_name for this environment
+    env_cfg = params['environments'][envir]
+    params['bucket_name'] = env_cfg['bucket_name']
+    # Clean up
+    del params['environments']
+
+    # Load service account credentials and turn headers
+    if github_ref_name is None:
+        # Local dev: read files from 'secrets/'
+        raw_key_path = Path('secrets', params['service_account_key_raw'])
+        analytics_key_path = Path('secrets', params['service_account_key_analytics'])
+        params['credentials_raw'] = Credentials.from_service_account_file(raw_key_path)
+        params['credentials_analytics'] = Credentials.from_service_account_file(analytics_key_path)
+
+        turn_headers_path = Path('secrets', params['turn_headers'])
+        params['turn_headers'] = json.load(open(turn_headers_path))
+
+        # Slack token from local file
+        params['slack_token'] = Path('secrets', 'slack_token.txt').read_text().strip()
+    else:
+        # CI: read JSON strings from environment variables
+        params['credentials_raw'] = Credentials.from_service_account_info(
+            json.loads(os.getenv('SERVICE_ACCOUNT_KEY_RAW', '{}'))
+        )
+        params['credentials_analytics'] = Credentials.from_service_account_info(
+            json.loads(os.getenv('SERVICE_ACCOUNT_KEY_ANALYTICS', '{}'))
+        )
+        params['turn_headers'] = json.loads(os.getenv('TURN_HEADERS', '{}'))
+        params['slack_token'] = os.getenv('SLACK_TOKEN')
+
+    # Slack channel ID (same for dev/prod)
+    params['slack_channel_id'] = params['slack_channel_id']
+
+    return params
+
+
+def get_storage_client(project, credentials):
+    return storage.Client(project=project, credentials=credentials)
+
+
+def get_bigquery_client(project, credentials):
+    return bigquery.Client(project=project, credentials=credentials)
+
+def derive_filename(uri, mime_type):
+    """
+    Extract the basename from a URI; if missing an extension, guess from the MIME type.
+    """
+    path = urlparse(uri).path
+    filename = os.path.basename(path)
+    name, ext = os.path.splitext(filename)
+    if not ext and mime_type:
path: src/utils.py
user: login: AmmarAhmedAlvi
body: essentially ext will be an empty string `''` if the filename has no extension. 
`not '' = True` 
So if extension is empty and mime type is non-empty (is not None) then it will try to guess the extension.

I have made this more explicit in the condition now.
created_at: 2025-05-26T14:56:12Z
author_association: COLLABORATOR
diff_hunk: @@ -1,130 +1,109 @@
 import google
-import io
 import json
 import os
+import time
+import pandas as pd
 import oyaml as yaml
-import polars as pl
 import slack_sdk
-import time
-import uuid
-import warnings
-from google.cloud import bigquery
-from google.oauth2.service_account import Credentials
+import mimetypes
+from urllib.parse import urlparse
 from pathlib import Path
+from google.cloud import bigquery, bigquery_storage, storage
+from google.oauth2.service_account import Credentials
 
 
-def get_params(source_name, params_path = 'params.yaml', envir = None):
-  with open(params_path) as f:
-    params = yaml.safe_load(f)
-
-  github_ref_name = os.getenv('GITHUB_REF_NAME')
-  params.update({'github_ref_name': github_ref_name})
-  if envir is None:
-    envir = 'prod' if github_ref_name == 'main' else 'dev'
-
-  y = [x for x in params['sources'] if x['name'] == source_name][0]
-  params['source_name'] = y['name']
-
-  z = [x for x in y['environments'] if x['name'] == envir][0]
-  params['environment'] = z.pop('name')
-  params.update(z)
-  del params['sources']
-
-  key = 'service_account_key'
-  if github_ref_name is None:
-    with open(Path('secrets', params[key])) as f:
-      params[key] = json.load(f)
-    with open(Path('secrets', 'slack_token.txt')) as f:
-      params['slack_token'] = f.read().strip('\n')
-    with open(Path('secrets', f'{source_name}.yaml')) as f:
-      params['source_params'] = yaml.safe_load(f)
-  else:
-    params[key] = json.loads(os.getenv('SERVICE_ACCOUNT_KEY'))
-    params['slack_token'] = os.getenv('SLACK_TOKEN')
-    params['source_params'] = yaml.safe_load(os.getenv('SOURCE_PARAMS'))
-
-  params['credentials'] = Credentials.from_service_account_info(params[key])
-  del params[key]
-  return params
-
-
-def json_dumps_list(x):
-  return json.dumps(x.to_list())
-
-
-def run_bigquery(query, credentials, num_tries = 5, wait_secs = 5):
-  client = bigquery.Client(credentials = credentials)
-  gtg = False
-  idx_try = 1
-  while not gtg and idx_try <= num_tries:
-    try:
-      query_job = client.query(query)
-      result = query_job.result()
-      gtg = True
-    except google.api_core.exceptions.BadRequest as e:
-      if 'due to concurrent update' in str(e) and (idx_try < num_tries):
-        time.sleep(wait_secs)
-        idx_try += 1
-      else:
-        raise e
-  return result
-
-
-def read_bigquery(query, credentials):
-  with warnings.catch_warnings(action = 'ignore'):
-    rows = run_bigquery(query, credentials).to_arrow()
-    df = pl.from_arrow(rows)
-  return df
-
-
-def read_bigquery_exists(table_name, params):
-  q = f"select * from `{params['dataset']}.__TABLES__` where table_id = '{table_name}'"
-  df = read_bigquery(q, params['credentials'])
-  return df.shape[0] > 0
-
-
-def get_bigquery_schema(df):
-  n = 'NUMERIC'
-  i = 'INT64'
-  s = 'STRING'
-  dtype_mappings = {
-    pl.Decimal: n, pl.Float32: n, pl.Float64: n,
-    pl.Int8: i, pl.Int16: i, pl.Int32: i, pl.Int64: i,
-    pl.UInt8: i, pl.UInt16: i, pl.UInt32: i, pl.UInt64: i,
-    pl.String: s, pl.Categorical: s, pl.Enum: s, pl.Utf8: s,
-    pl.Binary: 'BOOLEAN', pl.Boolean: 'BOOLEAN', pl.Date: 'DATE',
-    pl.Datetime('us', time_zone = 'UTC'): 'TIMESTAMP',
-    pl.Datetime('us', time_zone = None): 'DATETIME',  # won't work for other time zones
-    pl.Time: 'TIME', pl.Duration: 'INTERVAL', pl.Null: 'STRING'
-  }
-  schema = []
-  for j in range(df.shape[1]):
-    dtype = dtype_mappings[df.dtypes[j]]
-    schema.append(bigquery.SchemaField(df.columns[j], dtype))
-  return schema
-
-
-def write_bigquery(df, table_name, params, write_disposition = 'WRITE_EMPTY'):
-  schema = get_bigquery_schema(df)
-  client = bigquery.Client(credentials = params['credentials'])
-  with io.BytesIO() as stream:
-    df.write_parquet(stream)
-    stream.seek(0)
-    job_config = bigquery.LoadJobConfig(
-      source_format = 'PARQUET', schema = schema, write_disposition = write_disposition)
-    job = client.load_table_from_file(
-      stream, destination = f"{params['dataset']}.{table_name}",
-      project = params['project'], job_config = job_config)
-  job.result()
-  return df.shape[0]
-
-
-def add_extracted_columns(df, extracted_at = None):
-  if extracted_at is not None:
-    df = df.with_columns(_extracted_at = pl.lit(extracted_at))
-  uuids = [str(uuid.uuid4()) for _ in range(df.shape[0])]
-  df = df.with_columns(pl.Series('_extracted_uuid', uuids))
-  return df
+def get_params(params_path = 'params.yaml', envir = None):
+    """
+    Load global settings and environment-specific bucket_name from params.yaml.
+    Returns a dict with credentials, GCS project IDs, bucket_name, turn headers, and Slack config.
+    """
+    # Load params file
+    with open(params_path) as f:
+        params = yaml.safe_load(f)
+
+    # Determine environment (dev vs prod) from GitHub Actions or override
+    github_ref_name = os.getenv('GITHUB_REF_NAME')
+    if envir is None:
+        envir = 'prod' if github_ref_name == 'main' else 'dev'
+
+    # Extract bucket_name for this environment
+    env_cfg = params['environments'][envir]
+    params['bucket_name'] = env_cfg['bucket_name']
+    # Clean up
+    del params['environments']
+
+    # Load service account credentials and turn headers
+    if github_ref_name is None:
+        # Local dev: read files from 'secrets/'
+        raw_key_path = Path('secrets', params['service_account_key_raw'])
+        analytics_key_path = Path('secrets', params['service_account_key_analytics'])
+        params['credentials_raw'] = Credentials.from_service_account_file(raw_key_path)
+        params['credentials_analytics'] = Credentials.from_service_account_file(analytics_key_path)
+
+        turn_headers_path = Path('secrets', params['turn_headers'])
+        params['turn_headers'] = json.load(open(turn_headers_path))
+
+        # Slack token from local file
+        params['slack_token'] = Path('secrets', 'slack_token.txt').read_text().strip()
+    else:
+        # CI: read JSON strings from environment variables
+        params['credentials_raw'] = Credentials.from_service_account_info(
+            json.loads(os.getenv('SERVICE_ACCOUNT_KEY_RAW', '{}'))
+        )
+        params['credentials_analytics'] = Credentials.from_service_account_info(
+            json.loads(os.getenv('SERVICE_ACCOUNT_KEY_ANALYTICS', '{}'))
+        )
+        params['turn_headers'] = json.loads(os.getenv('TURN_HEADERS', '{}'))
+        params['slack_token'] = os.getenv('SLACK_TOKEN')
+    return params
+
+
+def get_storage_client(project, credentials):
+    return storage.Client(project=project, credentials=credentials)
+
+
+def get_bigquery_client(project, credentials):
+    return bigquery.Client(project=project, credentials=credentials)
+
+def derive_filename(uri, mime_type):
+    """
+    Extract the basename from a URI; if missing an extension, guess from the MIME type.
+    """
+    path = urlparse(uri).path
+    filename = os.path.basename(path)
+    name, ext = os.path.splitext(filename)
+    if ext == '' and mime_type is not None:
+        guessed = mimetypes.guess_extension(mime_type)
+        ext = guessed or ''
+        filename = f"{name}{ext}"
+    return filename
+
+def run_read_bigquery(query, credentials, num_tries = 5, wait_secs = 5):
+    """
+    Execute a BigQuery query, retrying on concurrent-update errors.
+    """
+    bq_client = bigquery.Client(credentials = credentials)
+    for attempt in range(1, num_tries + 1):
+      try:
+          df = (bq_client.query(query)  # executes the query
+             .result()                  # waits for completion
+             .to_dataframe())
+          return df
+      except google.api_core.exceptions.BadRequest as e:
+          if 'due to concurrent update' in str(e) and attempt < num_tries:
+              time.sleep(wait_secs)
+          else:
+              raise e
+
+def get_slack_message_text(error: Exception) -> str:
+    text = (
+        f":warning: Sync failed with the following error:"
+        f"\n\n`{str(error)}`"
+    )
+    run_url = os.getenv('RUN_URL')
+    if run_url:
+        text += f"\n\nSee the GitHub Actions <{run_url}|workflow log>."
+    return text
 
 
 def get_slack_message_text(e, source_name):
path: src/utils.py
user: login: jakejh
body: looks like you have two functions with the same name.
created_at: 2025-05-26T14:58:36Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,78 @@
+import requests
+import mimetypes
+import argparse
+import utils
+from pathlib import Path
+
+def parse_args():
+    parser = argparse.ArgumentParser(description="Sync WhatsApp attachments to GCS and notify on errors.")
+    parser.add_argument(
+        '--params-path',
+        type=Path,
+        default=Path('params.yaml'),
+        help='Path to the params.yaml file'
+    )
+    return parser.parse_args()
+
+
+def main():
+    args = parse_args()
+    params = utils.get_params(args.params_path)
+
+    try:
+        # Fetch recent attachments from BigQuery
+        project_analytics = params['project_analytics']
+        creds_analytics = params['credentials_analytics']
+        query = f"""
+          SELECT *
+          FROM `{project_analytics}.prod.res_message_attachments`
+          WHERE inserted_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 28 HOUR)
+            AND media_type NOT IN ('location', 'sticker')
+          ORDER BY channel_phone
+          """
+        df = utils.run_read_bigquery(query, creds_analytics)
+
+        # Prepare GCS bucket
+        storage_client = utils.get_storage_client(params['project_raw'], params['credentials_raw'])
+        bucket = storage_client.bucket(params['bucket_name'])
+
+        # Download each attachment and upload to GCS
+        for _, row in df.iterrows():
+            uri = row['uri']
+            media_type = row['media_type']
+            mime_type = row['mime_type']
+            phone = row['channel_phone']
+            #headers = params['turn_headers'].get(phone)
+            headers = params['turn_headers'][phone]
+
+            if headers is None:
+                #print(f"No headers configured for {phone!r}, skipping {uri}")
+                continue
+
+            response = requests.get(uri, headers=headers)
+            if response.status_code == 200:
+                filename = utils.derive_filename(uri, mime_type)
+                destination = f"{media_type}/{filename}"
+                blob = bucket.blob(destination)
+                blob.upload_from_string(
+                    response.content,
+                    content_type=mimetypes.guess_type(filename)[0] or "application/octet-stream"
+                )
+                print(f"Saved {filename}")
+            else:
path: src/turn_attachments.py
user: login: AmmarAhmedAlvi
body: Probably not but for my mental peace - yes 

Also makes it explicit to skip if the condition fails
created_at: 2025-05-26T15:02:11Z
author_association: COLLABORATOR
diff_hunk: @@ -0,0 +1,78 @@
+import requests
+import mimetypes
+import argparse
+import utils
+from pathlib import Path
+
+def parse_args():
+    parser = argparse.ArgumentParser(description="Sync WhatsApp attachments to GCS and notify on errors.")
+    parser.add_argument(
+        '--params-path',
+        type=Path,
+        default=Path('params.yaml'),
+        help='Path to the params.yaml file'
+    )
+    return parser.parse_args()
+
+
+def main():
+    args = parse_args()
+    params = utils.get_params(args.params_path)
+
+    try:
+        # Fetch recent attachments from BigQuery
+        project_analytics = params['project_analytics']
+        creds_analytics = params['credentials_analytics']
+        query = f"""
+          SELECT *
+          FROM `{project_analytics}.prod.res_message_attachments`
+          WHERE inserted_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 28 HOUR)
+            AND media_type NOT IN ('location', 'sticker')
+          ORDER BY channel_phone
+          """
+        df = utils.run_read_bigquery(query, creds_analytics)
+
+        # Prepare GCS bucket
+        storage_client = utils.get_storage_client(params['project_raw'], params['credentials_raw'])
+        bucket = storage_client.bucket(params['bucket_name'])
+
+        # Download each attachment and upload to GCS
+        for _, row in df.iterrows():
path: src/turn_attachments.py
user: login: jakejh
body: so this probably works, but for clarity I might suggest making a function to do the work for each row. there's a lot happening in `main()` right now.

so instead of the for loop, the code would look something like `df.apply(transfer_file, axis=1)`, then the `transfer_file` function would be defined in utils.py.
created_at: 2025-05-26T15:12:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,78 @@
+import requests
+import mimetypes
+import argparse
+import utils
+from pathlib import Path
+
+def parse_args():
+    parser = argparse.ArgumentParser(description="Sync WhatsApp attachments to GCS and notify on errors.")
+    parser.add_argument(
+        '--params-path',
+        type=Path,
+        default=Path('params.yaml'),
+        help='Path to the params.yaml file'
+    )
+    return parser.parse_args()
+
+
+def main():
+    args = parse_args()
+    params = utils.get_params(args.params_path)
+
+    try:
+        # Fetch recent attachments from BigQuery
+        project_analytics = params['project_analytics']
+        creds_analytics = params['credentials_analytics']
+        query = f"""
+          SELECT *
+          FROM `{project_analytics}.prod.res_message_attachments`
+          WHERE inserted_at >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 28 HOUR)
+            AND media_type NOT IN ('location', 'sticker')
+          ORDER BY channel_phone
+          """
+        df = utils.run_read_bigquery(query, creds_analytics)
+
+        # Prepare GCS bucket
+        storage_client = utils.get_storage_client(params['project_raw'], params['credentials_raw'])
+        bucket = storage_client.bucket(params['bucket_name'])
+
+        # Download each attachment and upload to GCS
+        for _, row in df.iterrows():
path: src/turn_attachments.py
user: login: AmmarAhmedAlvi
body: Done!
created_at: 2025-05-26T15:51:54Z
author_association: COLLABORATOR

PR comments:
user: login: AmmarAhmedAlvi
created_at: 2025-05-16T02:35:56Z
body: Need to add secrets to GH (Passbolt not working so can't add slackbot)

@jakejh Where do I get gh_pat.txt info from?
author_association: COLLABORATOR
user: login: jakejh
created_at: 2025-05-16T07:48:53Z
body: > Need to add secrets to GH (Passbolt not working so can't add slackbot)
> 
> @jakejh Where do I get gh_pat.txt info from?

you shouldn't need that here. if you look in the original repo, it's only needed to trigger subsequent workflow runs for mlhp, when a single run would time out.

just the basic "GITHUB_PAT: ${{ secrets.GITHUB_TOKEN }}" should work fine in this case.
author_association: CONTRIBUTOR
user: login: jakejh
created_at: 2025-05-16T17:02:23Z
body: if the code is not going to check explicitly which attachments have not yet been downloaded, it's probably a good idea to give it some margin for error, such as downloading attachments from the last 30 hours.

on the other hand, can't you get a list of files already saved in the bucket, and only download the ones that don't yet exist? attachments are never updated, right?
author_association: CONTRIBUTOR
user: login: AmmarAhmedAlvi
created_at: 2025-05-24T02:29:11Z
body: > if the code is not going to check explicitly which attachments have not yet been downloaded, it's probably a good idea to give it some margin for error, such as downloading attachments from the last 30 hours.
> 
> on the other hand, can't you get a list of files already saved in the bucket, and only download the ones that don't yet exist? attachments are never updated, right?

Yes, I will be downloading for the last 28/30 hours so make sure there is an overlap. 

Would avoid doing the latter because the size of the bucket will only increase and matching for every single file will just start taking a lot of time. Don't think its needed atm but can add that logic later.
author_association: COLLABORATOR
user: login: AmmarAhmedAlvi
created_at: 2025-05-24T02:35:50Z
body: @jakejh I don't think I have the slack token, and it is not on passbolt. Could you share it please? 

I tried checking if its there on the slack dev portal but I don't seem to have relevant access for it.
author_association: COLLABORATOR
user: login: jakejh
created_at: 2025-05-24T08:22:35Z
body: > @jakejh I don't think I have the slack token, and it is not on passbolt. Could you share it please?
> 
> I tried checking if its there on the slack dev portal but I don't seem to have relevant access for it.

the slack bot token is in passbolt, but it's owned by udit, so you can ask him to make it visible to the entire group.
author_association: CONTRIBUTOR
user: login: jakejh
created_at: 2025-05-24T08:23:38Z
body: > > if the code is not going to check explicitly which attachments have not yet been downloaded, it's probably a good idea to give it some margin for error, such as downloading attachments from the last 30 hours.
> > on the other hand, can't you get a list of files already saved in the bucket, and only download the ones that don't yet exist? attachments are never updated, right?
> 
> Yes, I will be downloading for the last 28/30 hours so make sure there is an overlap.
> 
> Would avoid doing the latter because the size of the bucket will only increase and matching for every single file will just start taking a lot of time. Don't think its needed atm but can add that logic later.

so any existing files will just get smoothly overwritten, right? as long as it won't error or append any suffixes like "(1)".
author_association: CONTRIBUTOR
user: login: AmmarAhmedAlvi
created_at: 2025-05-26T14:03:28Z
body: > > > if the code is not going to check explicitly which attachments have not yet been downloaded, it's probably a good idea to give it some margin for error, such as downloading attachments from the last 30 hours.
> > > on the other hand, can't you get a list of files already saved in the bucket, and only download the ones that don't yet exist? attachments are never updated, right?
> > 
> > 
> > Yes, I will be downloading for the last 28/30 hours so make sure there is an overlap.
> > Would avoid doing the latter because the size of the bucket will only increase and matching for every single file will just start taking a lot of time. Don't think its needed atm but can add that logic later.
> 
> so any existing files will just get smoothly overwritten, right? as long as it won't error or append any suffixes like "(1)".

Yes, I tested it with a few files too. It safely overwrites the file without an error or any suffixes.
author_association: COLLABORATOR
user: login: AmmarAhmedAlvi
created_at: 2025-05-26T15:05:34Z
body: I think its gtg now
author_association: COLLABORATOR
user: login: AmmarAhmedAlvi
created_at: 2025-05-26T15:06:03Z
body: Waiting on Udit to share the slack token - can merge after that
author_association: COLLABORATOR
user: login: jakejh
created_at: 2025-05-26T16:17:13Z
body: cool, once you've added the slack bot token to the repo secrets, let's test it on github actions using workflow dispatch.
author_association: CONTRIBUTOR
user: login: AmmarAhmedAlvi
created_at: 2025-05-27T11:15:07Z
body: > cool, once you've added the slack bot token to the repo secrets, let's test it on github actions using workflow dispatch.

Slack token added.

Not seeing a workflow in Actions. I thought we would need to merge for GitHub to read the actions trigger. Is there another way to test it? 

I was reading this: https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/manually-running-a-workflow
author_association: COLLABORATOR
user: login: jakejh
created_at: 2025-05-27T12:39:36Z
body: oh right, forgot it had to be on the main branch. well let a rip then.
author_association: CONTRIBUTOR