Add optional Google Sheets export for the masterplan

Lets organizers automatically populate a shared Google Sheet (the same kind
they previously built by hand) with an Overview tab and one tab per group,
instead of printing dicts. Group tabs are named after all their members
(not a single host) and show each group's route, course times, and guests
with allergies. The Overview tab also gets configurable Meal Times, Support
Contacts, and Info sections, passed through as plain data from
tatami_masterplan.py. Export is fully opt-in via GOOGLE_SHEETS_CREDENTIALS_FILE
and GOOGLE_SHEETS_SPREADSHEET_ID; without them, behavior is unchanged.
This commit is contained in:
2026-06-19 15:17:37 +02:00
parent 21eb9539d4
commit 91dc7729f9
10 changed files with 1013 additions and 8 deletions
+9
View File
@@ -1,3 +1,12 @@
# Copy this file to `.env` and fill in your real key. `.env` is gitignored.
# Required: Google Maps API key with the Routes API enabled.
GOOGLE_MAPS_API_KEY=your-api-key-here
# Optional: export the masterplan to a shared Google Sheet for participants.
# Set up once: create a Google Cloud service account, enable the Google
# Sheets API for its project, download the service account's JSON key, then
# create a blank Google Sheet and share it with the service account's
# client_email (found in the JSON key) as Editor. Leave both unset to skip
# sheet export entirely.
GOOGLE_SHEETS_CREDENTIALS_FILE=service-account.json
GOOGLE_SHEETS_SPREADSHEET_ID=your-spreadsheet-id-here
+4 -1
View File
@@ -175,4 +175,7 @@ cython_debug/
# csv containing personal information
test-config.csv
test-config.csv
# Google Sheets service-account key
service-account*.json
+55
View File
@@ -0,0 +1,55 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## What this is
Tatami ("Tool for Arranging Tasty Appointments, Meetings & Invitations") generates a "running dinner" masterplan: given a list of participants (with home addresses), it groups them into hosting groups for starter/main/dessert courses, assigns which groups visit which hosts for each course, and orders things to minimize travel time (by bike, via the Google Maps Routes API), finishing at a shared after-party location.
The actively developed package is `src/tatami/`. `running_dinner/running_dinner.py` is a legacy, pre-package standalone script (German-language field names, brute-force search over team pairings) kept for reference — it is not wired into the `tatami` package and uses a different/older Google Maps API (the legacy Distance Matrix API vs. the new Routes API used in `traveltimes.py`). Don't assume code or conventions from `running_dinner.py` apply to `src/tatami/`.
## Commands
This project uses `uv` for dependency management (Python >=3.13).
- Install deps: `uv sync`
- Run the masterplan script: `uv run python -m tatami.tatami_masterplan` (requires `test-config.csv` in the working directory and `GOOGLE_MAPS_API_KEY` set; optionally set `GOOGLE_SHEETS_CREDENTIALS_FILE` + `GOOGLE_SHEETS_SPREADSHEET_ID` to also export to a shared Google Sheet — see `.env.example`)
- Lint: `uv run ruff check`
- Format: `uv run ruff format`
- Type check: `uv run mypy --allow-redefinition src/` (mypy is configured to treat untyped imports as errors except where ignored)
- Pre-commit runs ruff check, ruff format, and mypy automatically (see `.pre-commit-config.yaml`); install hooks with `uv run pre-commit install` if working interactively.
There is currently no test suite in the repo.
## Architecture
The pipeline (see `src/tatami/tatami_masterplan.py` `__main__` block) is:
1. **Load participants**`load_csv_to_participants` reads a tab-separated CSV (`name`, `address`, `phone`, `kitchen_size`, `allergies`) into `Participant` objects (`src/tatami/classes.py`).
2. **Fetch travel times**`traveltimes.get_participant_distance_matrix` calls the Google Routes API (`GOOGLE_MAPS_API_KEY` env var required) to build a full pairwise duration matrix between all participant addresses plus the after-party address, indexed by participant UUID.
3. **Build masterplan**`compute_masterplan_groups` (the live-object core; `get_masterplan` is a thin wrapper around it that returns plain dicts instead):
- Splits participants into `hosts` (one per group, `len(participants)//6` groups of 3 courses each) and `semi_hosts` (non-hosting members assigned round-robin into existing groups), ranked by each participant's `get_after_party_time` (kitchen size penalty + distance to after-party).
- Reduces the full distance matrix to just host-to-host distances (`reduce_distance_matrix`), adding each host's kitchen-size penalty into their row.
- Runs `run_simulated_annealing` / `simulated_annealing` (Boltzmann-style annealing over `itertools.permutations` of group order — note this is brute-force over all permutations per iteration, so it only scales to a small number of groups) to find a low-travel-time ordering of groups.
- `assign_courses` assigns each group a course (starter/main/dessert cycling) and, via `get_courses`, determines which other groups host it for each course (offsets of `+1` and `-4` mod total groups — this fixed relationship is what defines the dinner-rotation topology).
4. `get_masterplan` returns two lists of plain dicts (`group.dict()`, `participant.dict()`) suitable for serialization; `compute_masterplan_groups` returns the live `Group`/`Participant` objects, which is what the sheet export step needs (`.hosts`, `.get_guests(...)`).
5. **Export to Google Sheets (optional)** — if `GOOGLE_SHEETS_SPREADSHEET_ID` is set, `__main__` calls `sheets_export.export_masterplan_to_sheet` to populate a pre-existing, pre-shared spreadsheet with an Overview tab and one tab per group. This is opt-in and never sends anything directly to participants — the organizer still shares the sheet link manually.
### Core domain model (`src/tatami/classes.py`)
- `Participant`: a person with an address, phone, kitchen size (010, used as a "willingness/suitability to host" proxy via `get_penalty`, which adds travel-time-equivalent minutes for smaller kitchens), and allergies.
- `Group`: a hosting unit with a `main_member` (used as the group's representative location for all distance lookups — other members' addresses are not used for travel calculations), a `course`, and a `hosts` list (the groups that host *this* group across the evening, kept sorted starter→main→dessert via `sort_hosts`). `get_total_time` sums travel + penalty across this group's full route (its hosts, then the after-party).
- Distance/time lookups throughout the codebase are keyed by `Participant.uuid` (host groups are addressed via `main_member.uuid`), not by name — when adding new matrix operations, index by uuid for consistency with `traveltimes.py` and `classes.py`.
### Travel times (`src/tatami/traveltimes.py`)
- Wraps the Google Routes API `computeRouteMatrix` endpoint. Raises at import time if `GOOGLE_MAPS_API_KEY` is unset.
- `get_distance_matrix` returns a square `DataFrame` of `pd.Timedelta` (or float meters) indexed/columned by integer position; `get_participant_distance_matrix` relabels both axes to participant UUIDs.
- `reduce_distance_matrix` is the bridge between the full participant-level matrix and the group-level matrix used by the annealing step.
### Sheet export (`src/tatami/sheets_export.py`)
- Wraps `gspread` (Google Sheets API). Unlike `traveltimes.py`, validation of `GOOGLE_SHEETS_CREDENTIALS_FILE` happens lazily inside `load_sheets_client`, not at import time — this feature is optional/opt-in, so importing the module must not require the env var.
- `export_masterplan_to_sheet` expects an existing `gspread.Spreadsheet` (organizer pre-creates it and shares Editor access with the service account's `client_email` once) and rewrites it idempotently: an "Overview" tab listing every participant, plus one tab per group named after all of that group's members joined with `&` (deduplicated; see `_group_titles` — deliberately not a single member's name, since a group can have several members) showing that group's course, route (their `hosts`, sorted starter→main→dessert, plus the after-party), and their guests for the course they host (`Group.get_guests`). Course start times are a fixed dict passed in by the caller (`COURSE_TIMES` in `tatami_masterplan.py`) — the dinner runs on a synchronized schedule, not on travel-time-derived timing.
- The Overview tab also gets three caller-supplied sections below the group assignments, each rendered as-is (no computation) and each skippable: a "Meal Times" table built straight from `course_times`, a "Support Contacts" table from the optional `organizer_contacts: list[tuple[str, str]]` argument, and a free-text "Info" block from the optional `info_text: str` argument (split into one row per line). All three are configured in `tatami_masterplan.py` (`COURSE_TIMES`, `ORGANIZER_CONTACTS`, `INFO_TEXT`) and just passed through — `sheets_export.py` has no event-specific content baked in.
- Tabs left over from a previous run with a different group count are deleted so reruns don't accumulate stale tabs.
+56 -2
View File
@@ -79,6 +79,10 @@ gitignored, so your secret never gets committed. An alreadyexported
> The package raises at import time if no key is found, so `GOOGLE_MAPS_API_KEY`
> must be set (even to a dummy value) just to import `tatami.traveltimes`.
Optionally, also set `GOOGLE_SHEETS_CREDENTIALS_FILE` and
`GOOGLE_SHEETS_SPREADSHEET_ID` in `.env` to export the plan to a shared
Google Sheet — see [Sharing the plan with participants](#sharing-the-plan-with-participants).
## Input: the participant CSV
A **tabseparated** file (the default working file is `test-config.csv` in the
@@ -153,14 +157,63 @@ keyed by UUID, so resolve names/addresses by looking participants up by `uuid`.
> Note: only a group's `main_member` address is used for all travel calculations;
> other members are assumed to join at the main member's home.
## Sharing the plan with participants
`get_masterplan`'s dicts are great for code, but participants need something
readable. `compute_masterplan_groups` (the same computation, returning live
`Group`/`Participant` objects instead of dicts) feeds an optional Google
Sheets export — the same kind of shared spreadsheet organizers have used in
previous years, just generated automatically instead of by hand.
Set up once:
1. Create a Google Cloud service account and enable the **Google Sheets API**
for its project.
2. Download the service account's JSON key and point
`GOOGLE_SHEETS_CREDENTIALS_FILE` at it (in `.env`).
3. Create a blank Google Sheet, share it with the service account's
`client_email` (from the JSON key) as **Editor**, and set
`GOOGLE_SHEETS_SPREADSHEET_ID` to that sheet's ID (in `.env`).
With both set, running `uv run python -m tatami.tatami_masterplan` populates
that spreadsheet with an **Overview** tab (every participant, their group,
course, address, phone, allergies — followed by a Meal Times table, a Support
Contacts table, and a free-text Info block, see below) and one tab per group
(their own course, route with addresses and fixed course times, and the
guest list — with allergies — for the course they host). Reruns are
idempotent: tabs are cleared and rewritten, and stale tabs from a previous
run are deleted.
Tatami never contacts participants directly — sharing the sheet's link is
still up to the organizer, exactly as before.
The three extra Overview sections are plain configuration, passed straight
through to the sheet with no logic in between — edit these in
`tatami_masterplan.py`:
```python
COURSE_TIMES = {
"starter": "18:30", "main": "20:00", "dessert": "22:00", "after_party": "23:30",
} # -> "Meal Times" table
ORGANIZER_CONTACTS = [("Lars (Organizer)", "0151-23456789")] # -> "Support Contacts" table
INFO_TEXT = "Welcome to the running dinner! ..." # -> "Info" block (one row per line)
```
`organizer_contacts` and `info_text` are optional (`None`/empty skips that
section); `course_times` is also reused for each group's own route table.
If neither variable is set, this step is skipped entirely and Tatami just
prints the plan, as before.
## What you can tweak
All knobs currently live in the source. The most useful ones:
| What | Where | Default | Effect |
|------|-------|---------|--------|
| **Afterparty address** | `tatami_masterplan.py:230` (`__main__`) | a Karlsruhe address | Where everyone ends the night; also influences host ranking. |
| **Travel mode** | `tatami_masterplan.py:233` (`mode="BICYCLE"`) | `BICYCLE` | Any Routes API `travelMode`: `BICYCLE`, `DRIVE`, `WALK`, `TWO_WHEELER`, `TRANSIT`. |
| **Afterparty address** | `tatami_masterplan.py:251` (`__main__`) | a Karlsruhe address | Where everyone ends the night; also influences host ranking. |
| **Travel mode** | `tatami_masterplan.py:254` (`mode="BICYCLE"`) | `BICYCLE` | Any Routes API `travelMode`: `BICYCLE`, `DRIVE`, `WALK`, `TWO_WHEELER`, `TRANSIT`. |
| **Course start times** | `tatami_masterplan.py` (`COURSE_TIMES`) | `18:30` / `20:00` / `22:00` / `23:30` | Fixed slot times written into the Google Sheet export; the dinner runs on a synchronized schedule, not travel-derived timing. |
| **Group sizing** | `tatami_masterplan.py:24` (`len(participants) / 6`) | 1 group per ~6 people | The divisor sets how many participants form one "coursetriple". Larger → fewer, bigger groups. |
| **Kitchensize penalty** | `classes.py:19` (`minutes=3 * (10 - kitchen_size)`) | 3 min per point | Traveltimeequivalent penalty for small kitchens. Raise the `3` to push hosting toward big kitchens. |
| **Annealing schedule** | `tatami_masterplan.py:4850` | `T=1000`, `cooling=0.99`, `iters=10000` | Optimization quality vs. runtime. More iterations / slower cooling → better routes, slower. |
@@ -240,6 +293,7 @@ uv run pytest -m e2e # opt-in live test that calls the real Routes API
src/tatami/
classes.py # Participant and Group domain model
traveltimes.py # Google Routes API wrapper + matrix helpers
sheets_export.py # optional Google Sheets export for participants
tatami_masterplan.py # pipeline: load → fetch → group → optimize → assign
tests/ # pytest suite (offline + opt-in live e2e)
running_dinner/ # legacy standalone prototype — NOT used by the package
+1
View File
@@ -5,6 +5,7 @@ description = "Tool for Arranging Tasty Appointments, Meetings & Invitations"
readme = "README.md"
requires-python = ">=3.13"
dependencies = [
"gspread>=6.2.1",
"numpy>=2.2.4",
"pandas>=2.2.3",
"pandas-stubs>=2.2.3.250308",
+184
View File
@@ -0,0 +1,184 @@
import os
import gspread
from dotenv import load_dotenv
from tatami.classes import Group
# Load secrets from a local .env file if present. Existing environment variables
# take precedence (override=False), so an exported GOOGLE_SHEETS_CREDENTIALS_FILE
# still wins.
load_dotenv()
OVERVIEW_TITLE = "Overview"
MAX_WORKSHEET_TITLE_LENGTH = 100
def load_sheets_client(credentials_file: str | None = None) -> gspread.Client:
"""
Build an authenticated Sheets client from a service-account key file.
Unlike the Maps API key, this is validated lazily (here, not at import
time) since sheet export is an optional, opt-in feature.
"""
credentials_file = credentials_file or os.getenv("GOOGLE_SHEETS_CREDENTIALS_FILE")
if not credentials_file:
raise ValueError(
"GOOGLE_SHEETS_CREDENTIALS_FILE environment variable is not set."
)
# google-auth opens the path with io.open(), which does not expand "~".
return gspread.service_account(filename=os.path.expanduser(credentials_file))
def _unique_titles(names: list[str]) -> list[str]:
"""De-duplicate worksheet titles (e.g. two groups with identically-named members)."""
seen: dict[str, int] = {}
titles = []
for name in names:
count = seen.get(name, 0) + 1
seen[name] = count
title = name if count == 1 else f"{name} ({count})"
titles.append(title[:MAX_WORKSHEET_TITLE_LENGTH])
return titles
def _group_titles(groups: list[Group]) -> list[str]:
"""Worksheet/Overview titles identifying each group, not any single member."""
names = [" & ".join(member.name for member in group.members) for group in groups]
return _unique_titles(names)
def _get_or_create_worksheet(
spreadsheet: gspread.Spreadsheet, title: str
) -> gspread.Worksheet:
try:
worksheet = spreadsheet.worksheet(title)
worksheet.clear()
except gspread.WorksheetNotFound:
worksheet = spreadsheet.add_worksheet(title=title, rows=200, cols=10)
return worksheet
def _write_overview(
spreadsheet: gspread.Spreadsheet,
groups: list[Group],
course_times: dict[str, str],
organizer_contacts: list[tuple[str, str]] | None,
info_text: str | None,
) -> None:
worksheet = _get_or_create_worksheet(spreadsheet, OVERVIEW_TITLE)
rows = [["Group", "Course", "Name", "Phone", "Address", "Allergies"]]
for group, title in zip(groups, _group_titles(groups)):
for member in group.members:
rows.append(
[
title,
group.course or "",
member.name,
member.phone,
member.address,
member.allergies,
]
)
if course_times:
rows.append([])
rows.append(["Meal Times"])
rows.append(["Course", "Time"])
for course, time in course_times.items():
rows.append([course, time])
if organizer_contacts:
rows.append([])
rows.append(["Support Contacts"])
rows.append(["Name", "Contact"])
for name, contact in organizer_contacts:
rows.append([name, contact])
if info_text:
rows.append([])
rows.append(["Info"])
for line in info_text.splitlines():
rows.append([line])
worksheet.update(rows)
def _write_group_sheet(
spreadsheet: gspread.Spreadsheet,
group: Group,
title: str,
groups: list[Group],
after_party_group: Group,
course_times: dict[str, str],
) -> None:
worksheet = _get_or_create_worksheet(spreadsheet, title)
rows: list[list[str]] = [
[title],
[],
["Members", ", ".join(member.name for member in group.members)],
["Phone", ", ".join(member.phone for member in group.members)],
["Your course", group.course or ""],
["Your address", group.main_member.address],
[],
["Route", "Host", "Address", "Time"],
]
hosts = group.hosts or []
if after_party_group.uuid not in [h.uuid for h in hosts]:
hosts = [*hosts, after_party_group]
for stop in hosts:
is_after_party = stop.uuid == after_party_group.uuid
label = "After Party" if is_after_party else (stop.course or "")
time_key = "after_party" if is_after_party else stop.course
rows.append(
[
label,
stop.main_member.name,
stop.main_member.address,
course_times.get(time_key, "") if time_key else "",
]
)
rows.append([])
rows.append(["Guests for your course", "Allergies"])
for guest in group.get_guests(groups):
rows.append([guest.name, guest.allergies])
worksheet.update(rows)
def export_masterplan_to_sheet(
spreadsheet: gspread.Spreadsheet,
groups: list[Group],
after_party_group: Group,
course_times: dict[str, str],
organizer_contacts: list[tuple[str, str]] | None = None,
info_text: str | None = None,
) -> None:
"""
Populate ``spreadsheet`` with an Overview tab and one tab per group.
``organizer_contacts`` (a list of ``(name, contact)`` pairs) and
``info_text`` are caller-supplied and rendered as-is below the group
assignments on the Overview tab, alongside a table built from
``course_times``; pass ``None``/an empty value to omit either section.
Re-running against the same spreadsheet is idempotent: existing tabs are
cleared and rewritten, and any tab left over from a previous run with a
different group count is deleted.
"""
titles = _group_titles(groups)
desired_titles = {OVERVIEW_TITLE, *titles}
_write_overview(spreadsheet, groups, course_times, organizer_contacts, info_text)
for group, title in zip(groups, titles):
_write_group_sheet(
spreadsheet, group, title, groups, after_party_group, course_times
)
for worksheet in spreadsheet.worksheets():
if worksheet.title not in desired_titles:
spreadsheet.del_worksheet(worksheet)
+55 -5
View File
@@ -1,10 +1,33 @@
from tatami.classes import Participant, Group
from tatami.traveltimes import reduce_distance_matrix, get_participant_distance_matrix
from tatami.sheets_export import load_sheets_client, export_masterplan_to_sheet
import pandas as pd
import numpy as np
import random
import os
from tqdm import tqdm
# Courses run on a synchronized schedule shared by every group, so start times
# are fixed slots rather than derived from travel-time optimization.
COURSE_TIMES = {
"starter": "18:30",
"main": "20:00",
"dessert": "22:00",
"after_party": "23:30",
}
# Shown on the Overview tab's Support Contacts table.
ORGANIZER_CONTACTS = [
("Lars (Organizer)", "0151-23456789"),
]
# Shown on the Overview tab below the group assignments and tables.
INFO_TEXT = (
"Welcome to the running dinner! Please be on time for each course and "
"bring a small gift for your hosts. If anything comes up, reach out to "
"one of the support contacts above."
)
def get_after_party_group(address: str) -> Group:
"""
@@ -16,11 +39,11 @@ def get_after_party_group(address: str) -> Group:
return Group(members=[participant], main_member=0)
def get_masterplan(
def compute_masterplan_groups(
participants: list[Participant],
distance_matrix: pd.DataFrame,
after_party_group: Group,
) -> tuple[list[dict], list[dict]]:
) -> tuple[list[Group], list[Participant]]:
groups_per_course = np.floor(len(participants) / 6).astype(int)
participants = sorted(
participants,
@@ -51,7 +74,18 @@ def get_masterplan(
multiprocessing=1,
)
assign_courses(best_order, courses)
group_dicts = [group.dict() for group in best_order]
return best_order, participants
def get_masterplan(
participants: list[Participant],
distance_matrix: pd.DataFrame,
after_party_group: Group,
) -> tuple[list[dict], list[dict]]:
groups, participants = compute_masterplan_groups(
participants, distance_matrix, after_party_group
)
group_dicts = [group.dict() for group in groups]
participant_dicts = [participant.dict() for participant in participants]
return group_dicts, participant_dicts
@@ -232,6 +266,22 @@ if __name__ == "__main__":
distance_matrix = get_participant_distance_matrix(
[*participants, after_party_group.main_member], mode="BICYCLE"
)
masterplan = get_masterplan(participants, distance_matrix, after_party_group)
print(masterplan)
groups, participants = compute_masterplan_groups(
participants, distance_matrix, after_party_group
)
print([group.dict() for group in groups], [p.dict() for p in participants])
print("Masterplan generated successfully.")
spreadsheet_id = os.getenv("GOOGLE_SHEETS_SPREADSHEET_ID")
if spreadsheet_id:
client = load_sheets_client()
spreadsheet = client.open_by_key(spreadsheet_id)
export_masterplan_to_sheet(
spreadsheet,
groups,
after_party_group,
COURSE_TIMES,
organizer_contacts=ORGANIZER_CONTACTS,
info_text=INFO_TEXT,
)
print(f"Masterplan exported to Google Sheet: {spreadsheet.url}")
+216
View File
@@ -0,0 +1,216 @@
"""Live end-to-end test against the real Google Sheets API.
This is intentionally excluded from the default test run (it is marked ``e2e``
and ``addopts`` in pyproject.toml deselects that marker). It writes a small
mock masterplan (fictional Karlsruhe participants) into the spreadsheet
configured via ``GOOGLE_SHEETS_SPREADSHEET_ID``/``GOOGLE_SHEETS_CREDENTIALS_FILE``,
then reads the cells back via the live API to validate the export. Note this
overwrites/deletes tabs in that spreadsheet (the same idempotent rewrite
``export_masterplan_to_sheet`` always does) — point it at a scratch/test sheet.
Run it explicitly:
uv run pytest -m e2e -k sheets
It requires a real service-account key file and a spreadsheet ID, with that
spreadsheet shared (Editor) with the service account's ``client_email``; the
test skips itself if either is only a placeholder/missing.
"""
import os
import pytest
from dotenv import load_dotenv
from tatami.classes import Group, Participant
from tatami.sheets_export import (
OVERVIEW_TITLE,
_group_titles,
export_masterplan_to_sheet,
load_sheets_client,
)
from tatami.tatami_masterplan import assign_courses, get_after_party_group
pytestmark = pytest.mark.e2e
_PLACEHOLDER_SPREADSHEET_IDS = {"", "your-spreadsheet-id-here"}
_PLACEHOLDER_CREDENTIALS_FILES = {"", "service-account.json"}
COURSE_TIMES = {
"starter": "18:30",
"main": "20:00",
"dessert": "22:00",
"after_party": "23:30",
}
ORGANIZER_CONTACTS = [("Lars (Organizer)", "0151-1234567")]
INFO_TEXT = "Be on time.\nBring a small gift for your hosts."
@pytest.fixture
def live_spreadsheet():
# Prefer real values from the environment / .env over conftest's dummies.
load_dotenv(override=True)
credentials_file = os.environ.get("GOOGLE_SHEETS_CREDENTIALS_FILE", "")
spreadsheet_id = os.environ.get("GOOGLE_SHEETS_SPREADSHEET_ID", "")
if (
credentials_file in _PLACEHOLDER_CREDENTIALS_FILES
or not os.path.isfile(os.path.expanduser(credentials_file))
or spreadsheet_id in _PLACEHOLDER_SPREADSHEET_IDS
):
pytest.skip(
"No real GOOGLE_SHEETS_CREDENTIALS_FILE/GOOGLE_SHEETS_SPREADSHEET_ID "
"available; skipping live Sheets test."
)
client = load_sheets_client(credentials_file)
return client.open_by_key(spreadsheet_id)
def _make_mock_groups() -> tuple[list[Group], Group]:
"""3 groups of 3 fictional participants with addresses around Karlsruhe."""
hosts = [
Participant(
"Anna Wagner", "Kaiserstraße 12, 76131 Karlsruhe", "0721-1000001", 9, "none"
),
Participant(
"Jonas Becker",
"Waldstraße 5, 76133 Karlsruhe",
"0721-1000002",
7,
"lactose",
),
Participant(
"Mira Hofmann", "Yorckstraße 22, 76185 Karlsruhe", "0721-1000003", 8, "none"
),
]
semi_hosts = [
Participant(
"Lukas Schreiber",
"Sophienstraße 40, 76135 Karlsruhe",
"0721-1000004",
5,
"nuts",
),
Participant(
"Sophie Lindner",
"Beiertheimer Allee 18, 76137 Karlsruhe",
"0721-1000005",
6,
"none",
),
Participant(
"Tom Vogel",
"Durlacher Allee 75, 76131 Karlsruhe",
"0721-1000006",
4,
"none",
),
Participant(
"Lea Brandt",
"Moltkestraße 30, 76133 Karlsruhe",
"0721-1000007",
3,
"gluten",
),
Participant(
"Felix Krause", "Adlerstraße 14, 76133 Karlsruhe", "0721-1000008", 8, "none"
),
Participant(
"Nora Fink",
"Rüppurrer Straße 60, 76137 Karlsruhe",
"0721-1000009",
5,
"none",
),
]
groups = [Group(members=[host]) for host in hosts]
for i, member in enumerate(semi_hosts):
groups[i % len(groups)].add_member(member)
assign_courses(groups, ["starter", "main", "dessert"])
after_party = get_after_party_group("Sebastian-Kneipp-Straße 6, 76131 Karlsruhe")
return groups, after_party
def test_export_masterplan_to_live_sheet(live_spreadsheet):
groups, after_party = _make_mock_groups()
export_masterplan_to_sheet(
live_spreadsheet,
groups,
after_party,
COURSE_TIMES,
organizer_contacts=ORGANIZER_CONTACTS,
info_text=INFO_TEXT,
)
all_members = [member for group in groups for member in group.members]
group_titles = _group_titles(groups)
expected_titles = {OVERVIEW_TITLE, *group_titles}
# Only the export's own tabs survive — stale/default tabs are cleaned up.
assert {w.title for w in live_spreadsheet.worksheets()} == expected_titles
# A group's tab is named after all its members, not just the host.
starter_title = next(
title for group, title in zip(groups, group_titles) if group.course == "starter"
)
starter_group = next(g for g in groups if g.course == "starter")
assert starter_title != starter_group.main_member.name
overview_rows = live_spreadsheet.worksheet(OVERVIEW_TITLE).get_all_values()
assert overview_rows[0] == [
"Group",
"Course",
"Name",
"Phone",
"Address",
"Allergies",
]
member_rows = overview_rows[1 : 1 + len(all_members)]
assert {row[2] for row in member_rows} == {m.name for m in all_members}
def _find_overview_row(prefix: list[str]) -> int:
return next(
i for i, row in enumerate(overview_rows) if row[: len(prefix)] == prefix
)
times_idx = _find_overview_row(["Meal Times"])
assert overview_rows[times_idx + 1][:2] == ["Course", "Time"]
times_rows = overview_rows[times_idx + 2 : times_idx + 2 + len(COURSE_TIMES)]
assert [row[:2] for row in times_rows] == [
[course, time] for course, time in COURSE_TIMES.items()
]
contacts_idx = _find_overview_row(["Support Contacts"])
assert overview_rows[contacts_idx + 1][:2] == ["Name", "Contact"]
assert overview_rows[contacts_idx + 2][:2] == list(ORGANIZER_CONTACTS[0])
info_idx = _find_overview_row(["Info"])
info_lines = [row[0] for row in overview_rows[info_idx + 1 :]]
assert info_lines == INFO_TEXT.splitlines()
tab_rows = live_spreadsheet.worksheet(starter_title).get_all_values()
# The live API pads every row to the widest row in the tab, so match on a
# row's leading cells rather than exact-length equality.
def _find_row(prefix: list[str]) -> int:
return next(i for i, row in enumerate(tab_rows) if row[: len(prefix)] == prefix)
route_idx = _find_row(["Route", "Host", "Address", "Time"])
route_rows = tab_rows[route_idx + 1 : route_idx + 5]
assert [row[0] for row in route_rows] == [
"starter",
"main",
"dessert",
"After Party",
]
assert [row[3] for row in route_rows] == ["18:30", "20:00", "22:00", "23:30"]
guest_idx = _find_row(["Guests for your course", "Allergies"])
guest_rows = [row for row in tab_rows[guest_idx + 1 :] if row and row[0]]
expected_guests = {(p.name, p.allergies) for p in starter_group.get_guests(groups)}
assert {(row[0], row[1]) for row in guest_rows} == expected_guests
+245
View File
@@ -0,0 +1,245 @@
"""Unit tests for the optional Google Sheets export (no real network/creds)."""
import gspread
from tatami.classes import Group
from tatami.tatami_masterplan import assign_courses, get_after_party_group
from tatami.sheets_export import (
OVERVIEW_TITLE,
_group_titles,
_unique_titles,
export_masterplan_to_sheet,
)
from conftest import make_participants
COURSE_TIMES = {
"starter": "18:30",
"main": "20:00",
"dessert": "22:00",
"after_party": "23:30",
}
class FakeWorksheet:
def __init__(self, title: str):
self.title = title
self.rows: list[list[str]] | None = None
def clear(self) -> None:
self.rows = None
def update(self, rows: list[list[str]]) -> None:
self.rows = rows
class FakeSpreadsheet:
def __init__(self):
self._worksheets: dict[str, FakeWorksheet] = {}
def worksheet(self, title: str) -> FakeWorksheet:
if title not in self._worksheets:
raise gspread.WorksheetNotFound(title)
return self._worksheets[title]
def add_worksheet(
self, title: str, rows: int = 200, cols: int = 10
) -> FakeWorksheet:
worksheet = FakeWorksheet(title)
self._worksheets[title] = worksheet
return worksheet
def worksheets(self) -> list[FakeWorksheet]:
return list(self._worksheets.values())
def del_worksheet(self, worksheet: FakeWorksheet) -> None:
del self._worksheets[worksheet.title]
def make_groups(n: int) -> list[Group]:
"""n one-member groups with starter/main/dessert cycling and hosts wired up."""
groups = [Group(members=[p]) for p in make_participants(n)]
courses = ["starter", "main", "dessert"] * (n // 3)
assign_courses(groups, courses)
return groups
class TestUniqueTitles:
def test_dedups_repeated_names(self):
assert _unique_titles(["Alice", "Bob", "Alice"]) == [
"Alice",
"Bob",
"Alice (2)",
]
def test_leaves_distinct_names_untouched(self):
assert _unique_titles(["Alice", "Bob"]) == ["Alice", "Bob"]
class TestGroupTitles:
def test_joins_all_member_names(self):
participants = make_participants(2)
group = Group(members=participants)
assert _group_titles([group]) == [
f"{participants[0].name} & {participants[1].name}"
]
def test_is_not_a_single_members_name(self):
participants = make_participants(3)
group = Group(members=participants)
title = _group_titles([group])[0]
assert all(member.name != title for member in participants)
class TestExportMasterplanToSheet:
def test_writes_overview_with_one_row_per_participant(self):
groups = make_groups(3)
after_party = get_after_party_group("party street")
spreadsheet = FakeSpreadsheet()
export_masterplan_to_sheet(spreadsheet, groups, after_party, COURSE_TIMES)
overview = spreadsheet.worksheet(OVERVIEW_TITLE)
assert overview.rows is not None
rows = overview.rows
assert rows[0] == ["Group", "Course", "Name", "Phone", "Address", "Allergies"]
member_rows = rows[1:4]
assert len(member_rows) == 3
names = {row[2] for row in member_rows}
assert names == {g.main_member.name for g in groups}
def test_writes_meal_times_table_below_the_group_assignments(self):
groups = make_groups(3)
after_party = get_after_party_group("party street")
spreadsheet = FakeSpreadsheet()
export_masterplan_to_sheet(spreadsheet, groups, after_party, COURSE_TIMES)
rows = spreadsheet.worksheet(OVERVIEW_TITLE).rows
assert rows is not None
times_idx = rows.index(["Meal Times"])
assert rows[times_idx + 1] == ["Course", "Time"]
assert rows[times_idx + 2 :] == [
[course, time] for course, time in COURSE_TIMES.items()
]
def test_writes_support_contacts_and_info_text_when_given(self):
groups = make_groups(3)
after_party = get_after_party_group("party street")
spreadsheet = FakeSpreadsheet()
contacts = [
("Lars (Organizer)", "0151-1234567"),
("Anna (Backup)", "0151-7654321"),
]
info_text = "Be on time.\nBring a small gift for your hosts."
export_masterplan_to_sheet(
spreadsheet,
groups,
after_party,
COURSE_TIMES,
organizer_contacts=contacts,
info_text=info_text,
)
rows = spreadsheet.worksheet(OVERVIEW_TITLE).rows
assert rows is not None
contacts_idx = rows.index(["Support Contacts"])
assert rows[contacts_idx + 1] == ["Name", "Contact"]
assert rows[contacts_idx + 2 : contacts_idx + 4] == [list(c) for c in contacts]
info_idx = rows.index(["Info"])
assert rows[info_idx + 1 :] == [
["Be on time."],
["Bring a small gift for your hosts."],
]
def test_omits_support_contacts_and_info_sections_when_not_given(self):
groups = make_groups(3)
after_party = get_after_party_group("party street")
spreadsheet = FakeSpreadsheet()
export_masterplan_to_sheet(spreadsheet, groups, after_party, COURSE_TIMES)
rows = spreadsheet.worksheet(OVERVIEW_TITLE).rows
assert rows is not None
assert ["Support Contacts"] not in rows
assert ["Info"] not in rows
def test_writes_one_tab_per_group_with_route_and_guests(self):
groups = make_groups(3)
after_party = get_after_party_group("party street")
spreadsheet = FakeSpreadsheet()
export_masterplan_to_sheet(spreadsheet, groups, after_party, COURSE_TIMES)
starter_group = next(g for g in groups if g.course == "starter")
tab = spreadsheet.worksheet(starter_group.main_member.name)
assert tab.rows is not None
route_header_idx = tab.rows.index(["Route", "Host", "Address", "Time"])
route_rows = tab.rows[route_header_idx + 1 : route_header_idx + 1 + 4]
# 3 hosts (starter, main, dessert) plus the after party.
labels = [row[0] for row in route_rows]
assert labels == ["starter", "main", "dessert", "After Party"]
times = [row[3] for row in route_rows]
assert times == ["18:30", "20:00", "22:00", "23:30"]
guest_header_idx = tab.rows.index(["Guests for your course", "Allergies"])
guest_rows = tab.rows[guest_header_idx + 1 :]
expected_guests = {p.name for p in starter_group.get_guests(groups)}
assert {row[0] for row in guest_rows} == expected_guests
def test_group_tab_is_titled_after_all_members_not_just_the_host(self):
participants = make_participants(6)
groups = [Group(members=[participants[0], participants[1]])]
groups[0].add_member(participants[2])
groups += [Group(members=[participants[3]]), Group(members=[participants[4]])]
groups[1].add_member(participants[5])
assign_courses(groups, ["starter", "main", "dessert"])
after_party = get_after_party_group("party street")
spreadsheet = FakeSpreadsheet()
export_masterplan_to_sheet(spreadsheet, groups, after_party, COURSE_TIMES)
host_group = groups[0]
expected_title = " & ".join(m.name for m in host_group.members)
assert expected_title != host_group.main_member.name
tab = spreadsheet.worksheet(expected_title)
assert tab.rows is not None
assert tab.rows[0] == [expected_title]
def test_deletes_stale_tabs_from_a_previous_run(self):
groups = make_groups(3)
after_party = get_after_party_group("party street")
spreadsheet = FakeSpreadsheet()
spreadsheet.add_worksheet("Leftover Tab")
export_masterplan_to_sheet(spreadsheet, groups, after_party, COURSE_TIMES)
assert "Leftover Tab" not in {w.title for w in spreadsheet.worksheets()}
def test_rerun_is_idempotent(self):
groups = make_groups(3)
after_party = get_after_party_group("party street")
spreadsheet = FakeSpreadsheet()
export_masterplan_to_sheet(spreadsheet, groups, after_party, COURSE_TIMES)
export_masterplan_to_sheet(spreadsheet, groups, after_party, COURSE_TIMES)
expected_titles = {OVERVIEW_TITLE, *(g.main_member.name for g in groups)}
assert {w.title for w in spreadsheet.worksheets()} == expected_titles
def test_dedups_tab_names_for_same_named_hosts(self):
participants = make_participants(3)
participants[1].name = participants[0].name
groups = [Group(members=[p]) for p in participants]
assign_courses(groups, ["starter", "main", "dessert"])
after_party = get_after_party_group("party street")
spreadsheet = FakeSpreadsheet()
export_masterplan_to_sheet(spreadsheet, groups, after_party, COURSE_TIMES)
titles = {w.title for w in spreadsheet.worksheets()}
assert participants[0].name in titles
assert f"{participants[0].name} (2)" in titles
Generated
+188
View File
@@ -11,6 +11,51 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393, upload-time = "2025-01-31T02:16:45.015Z" },
]
[[package]]
name = "cffi"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pycparser", marker = "implementation_name != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
{ url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
{ url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
{ url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
{ url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
{ url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
{ url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
{ url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
{ url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
{ url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
{ url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
{ url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
{ url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
{ url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
{ url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
{ url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
{ url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
{ url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
{ url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
{ url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
{ url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
{ url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
{ url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
{ url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
{ url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
{ url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
{ url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
{ url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
{ url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
{ url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
{ url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
{ url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
]
[[package]]
name = "charset-normalizer"
version = "3.4.1"
@@ -42,6 +87,95 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "cryptography"
version = "49.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1f/99/d1c90d6041656cc6ee229dc99cd67fd0cd5aec3c5f7d72fffc27cc750054/cryptography-49.0.0.tar.gz", hash = "sha256:f89660a348f4f78a92366240a61404e337586ef7f5909a2fef59ca88ef505493", size = 854345, upload-time = "2026-06-12T20:02:30.512Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/22/adf66990e63584a68dfb50c24f48a125c07b1699899381c8151e63ed458c/cryptography-49.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:966fe0e9c67490071f14c0d2b1cb2dfb3023c5ce39457343931415f08382f2db", size = 4032100, upload-time = "2026-06-12T20:02:32.143Z" },
{ url = "https://files.pythonhosted.org/packages/09/41/3797cfaf69cae04a13ee78ebd83f0678d9c02b4779d21ce24445326f1a69/cryptography-49.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:36d1709f992593689b45bda411498d62c6e365f2ca00b84657d4dadd24de16db", size = 4692978, upload-time = "2026-06-12T20:01:21.305Z" },
{ url = "https://files.pythonhosted.org/packages/e6/8b/43011f7ebe515a8aa20d61f290a326cd890c2e738e16e59eaff8d9c3a412/cryptography-49.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0e959b578856a3924bc0cbb710fc12c387b9412a951389f3ca61704a9e25f325", size = 4716422, upload-time = "2026-06-12T20:01:48.566Z" },
{ url = "https://files.pythonhosted.org/packages/4a/91/01ce7303a4579e6d3a6abef01bd322848e9ea7a219adcabc5048b9033571/cryptography-49.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:53ecee2e23f7169b6117e99fc8a944e5e50f79e69758a83b52a00cb98ab2b2d2", size = 4700503, upload-time = "2026-06-12T20:02:47.091Z" },
{ url = "https://files.pythonhosted.org/packages/62/99/a2c95cf8293f07491e9e27c20cc4dcd18176d944e674679adeb1d0173fd6/cryptography-49.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2eda353d8a27bcbcaa4cbed18994a74ab4d19a2ca897db188ea269ab9b71419b", size = 5309779, upload-time = "2026-06-12T20:02:08.987Z" },
{ url = "https://files.pythonhosted.org/packages/20/2c/0622f20ff02b2ef32558733443805dc82fd4c275be01b2d19d14676f3a1b/cryptography-49.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2afe9051da7ae7bd5905da5a949280c7d2bb75682e188f650a9d0f2756b834c6", size = 4749683, upload-time = "2026-06-12T20:02:03.335Z" },
{ url = "https://files.pythonhosted.org/packages/a3/5b/c5246635d5fd3b64e0d45ae10e99fd32fe9676a79915ccfe5a61ba9af1a5/cryptography-49.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:0b82e28ee398a386f0807bba7884d30f25218855690f45115831bcce5d90822c", size = 4337874, upload-time = "2026-06-12T20:02:54.323Z" },
{ url = "https://files.pythonhosted.org/packages/6d/88/05563c7fe2e914e87d1a536d06fe83e66b4e1d95cb593e05aea375531da8/cryptography-49.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:ccac2bfebc306b862133e3bb71f3f6ee8bb525240089b2d952e4144b3a6d5da7", size = 4700283, upload-time = "2026-06-12T20:01:34.822Z" },
{ url = "https://files.pythonhosted.org/packages/c4/b6/d7696e4e890d6ae1469935164c9e5215c557671cb78d6e3f458ccceaa632/cryptography-49.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:d0527ce944105f257f605a827d6ebead966c752038b6e8656abb9c5edee6fc68", size = 5265844, upload-time = "2026-06-12T20:01:24.09Z" },
{ url = "https://files.pythonhosted.org/packages/a9/3c/f3ad17eecc1a57b0ba236dc01f90e783c51f4a2f35f64777cc4f47a184b2/cryptography-49.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:cbc77da8c523d5abd028635ba850a6966fcee2c82e2bf65a41d1d8afe0f98be9", size = 4749290, upload-time = "2026-06-12T20:01:30.848Z" },
{ url = "https://files.pythonhosted.org/packages/4f/01/339573cf1023163a400b0b5d16f6d507de413b9f60be6fd1b77feeaf6737/cryptography-49.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:b87e65d263b3e5d3bb92a57e2a6638e2f31110fa7aa890c7b2dbba42248d0a3f", size = 4834612, upload-time = "2026-06-12T20:01:29.246Z" },
{ url = "https://files.pythonhosted.org/packages/71/fd/577302e213a1be9468f92d1afef66fcf1ef83d516819d9992ca547f592bd/cryptography-49.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:66ec79c3904820572d7e987abdf304281f141d37ad9a489b8e97066e7b9b6459", size = 4980804, upload-time = "2026-06-12T20:01:42.853Z" },
{ url = "https://files.pythonhosted.org/packages/1f/09/f42b1d190c5ba75f72062a387f8030d1d75f6ab035788f1d9c4b01de6525/cryptography-49.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:e5dfc1e64de5677cec922ffa8da89c546d0415bf6efdf081842e5d44c84e1f0e", size = 3810026, upload-time = "2026-06-12T20:02:39.262Z" },
{ url = "https://files.pythonhosted.org/packages/ec/9e/db72b3ae7fc9cfad53e630e56c6ae83b9b6ff0bf3718ffb8012d20b3aabf/cryptography-49.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:73a205dce83953d131a4aa1e0fd917a2fd1c5b1eef251e9d7152efefcbf5caf7", size = 4013892, upload-time = "2026-06-12T20:02:10.735Z" },
{ url = "https://files.pythonhosted.org/packages/86/12/c48a424f38db03027be9f7ed5c7dc5de9933dbee992865f98b13727a009d/cryptography-49.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:196ecd6a36e4e9aa10270393bb98d8df88fccee0bf1e5128b91ae4eb4375896d", size = 4678835, upload-time = "2026-06-12T20:02:48.743Z" },
{ url = "https://files.pythonhosted.org/packages/68/28/8a3ad4653662c93fc44dc4e5d8fd374c25c42e07b34bbfbadf49cf57a5a8/cryptography-49.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7abcee80084cda3f7691f3eb1ce480d8df49cec637b429aa35986c1de71738aa", size = 4697239, upload-time = "2026-06-12T20:02:56.03Z" },
{ url = "https://files.pythonhosted.org/packages/a8/b2/2193fc74f81aee4f9b62733133b73b5176718932ed8f2e4b03fa040480a6/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:4ae387c9cb68ea569ca17e490d66d8142b81c3cc814bf179974b7d146e490bbb", size = 4685593, upload-time = "2026-06-12T20:02:50.666Z" },
{ url = "https://files.pythonhosted.org/packages/47/f1/1d3eaa243bfc5de4a187b22aa8c048b3e4980bfbe830ac46e6bac2e66947/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:f37d847238971164fdbc68ade6f6574aecc9c0af714190e2083429ff68f4ce9d", size = 5289961, upload-time = "2026-06-12T20:01:46.468Z" },
{ url = "https://files.pythonhosted.org/packages/58/39/2d51306721330c486495853eda1c567880ff036de15a14c4b74f399934af/cryptography-49.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:c2bc30226390d60ea19d9f82b19db005fe0452154a23c1c410c12ea801e43561", size = 4731145, upload-time = "2026-06-12T20:02:16.832Z" },
{ url = "https://files.pythonhosted.org/packages/17/50/983e838c7fd0d87fd8c969bcdd328edaf5f756e38df5281637424c155873/cryptography-49.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:07cab27cc7b7e0fd28e5e26bb9eeedde5c135c868b46de4a27845abe94af6122", size = 4321719, upload-time = "2026-06-12T20:02:52.611Z" },
{ url = "https://files.pythonhosted.org/packages/a7/f5/8f571d7e27c55bce9f76f026143bcb1e040a4233149ecca0bea5fa5dd5f7/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:b20133d204d2bb56ba047642199603876c872026ca53e79c35b83772ab2cc505", size = 4685209, upload-time = "2026-06-12T20:02:07.282Z" },
{ url = "https://files.pythonhosted.org/packages/e7/84/0e27016a6fc5a0886f797018b26aa42f40c09a82332bff77822a451deaaa/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:b970c6da94d5bb18629db453d14f2a1300f6bf59b61e9b82377931ef95504866", size = 5246285, upload-time = "2026-06-12T20:01:32.439Z" },
{ url = "https://files.pythonhosted.org/packages/11/2d/5e1fb307cb5931881516b464c98774b3f2c36b5d4bb9a2830253cf553cad/cryptography-49.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d8ecde755e2e91bf773fc94e8c9d730cd7f2007004cb492263a794ec3899a1c8", size = 4730441, upload-time = "2026-06-12T20:02:01.469Z" },
{ url = "https://files.pythonhosted.org/packages/e4/c0/bff5a02ee731d207d6a1ed51732549d8c53d2bc8da1d10ec6f2844201d68/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e3fb64c420688e5319ae25113a354015abbd8dffbfbc41781a1ea66fc7622ac3", size = 4815869, upload-time = "2026-06-12T20:01:36.574Z" },
{ url = "https://files.pythonhosted.org/packages/b9/26/814681d14248d95d73d5c3eea0c39a94eb8302df966f670a2c60de90974b/cryptography-49.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32703d93296f5c1f4b53349ad3a250c2cae0fdecd3a3dd5d47e616d8d616af27", size = 4960948, upload-time = "2026-06-12T20:02:18.688Z" },
{ url = "https://files.pythonhosted.org/packages/4c/fe/93ecac273d3738939d023612ad12cca9a3740a5345d69fda04134c43fd96/cryptography-49.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:33cd0565932807baddb67b96dbee92f2c374b5c89dee09fd74079aeb8c8dba61", size = 3799153, upload-time = "2026-06-12T20:01:39.059Z" },
{ url = "https://files.pythonhosted.org/packages/19/2a/5bb823f5bedcf80718cea7fbc95ec5515cca3769633c4b01a32be7f30e7c/cryptography-49.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ec5e529fb80935c94fe7b729f9972b50e351a0e6b50aa294fd5cabb109fcc29a", size = 4025947, upload-time = "2026-06-12T20:01:25.745Z" },
{ url = "https://files.pythonhosted.org/packages/3d/df/40577043ca124e17012f408ddddaeb213b856336ac82ddb3bc915f39e29f/cryptography-49.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f78ff2c9ed8dc2d036b0f4d640e22522213d047c1b14e61205a7e55c80a494d4", size = 4692429, upload-time = "2026-06-12T20:01:53.628Z" },
{ url = "https://files.pythonhosted.org/packages/2c/99/2d13299eb3dd27b02dcfaafcc91d6b5cb3329f7cbd6d8f51921acd566c1a/cryptography-49.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:35b151772baff2c74cba7fa290ceaff4c3b11c0c881eb93eb5dbc05a7cfbba18", size = 4700968, upload-time = "2026-06-12T20:02:45.383Z" },
{ url = "https://files.pythonhosted.org/packages/a5/4d/9c0cd02f95e2602dd5e563da149ee0830abef3537be8b34dc56281ebe27a/cryptography-49.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:0f21641cf4b30fca7aee061ced0ec7ad7b073518088b7c9969a297c0ae796c69", size = 4697758, upload-time = "2026-06-12T20:01:41.13Z" },
{ url = "https://files.pythonhosted.org/packages/24/01/186c825898477d77e2324d5360fefe622ff1d8d1963ec0554e2cada8ec77/cryptography-49.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9e82dcc8e56052715fb18b2429e3bca4823b1629136a2084fc45a9a5cecb9b64", size = 5298863, upload-time = "2026-06-12T20:02:24.579Z" },
{ url = "https://files.pythonhosted.org/packages/b8/7b/62cbbab75d0659865bf0273790031544a0b16c8072d258f9428dcd8190dc/cryptography-49.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:6f2debedf9ca60cf1d5bd466475638af5130f89965605cd818484d19987d3a21", size = 4735983, upload-time = "2026-06-12T20:01:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/6c/72/3e798c064bc39e471008075d0f9bc9daf77a80879c092e4a8e170c585ed4/cryptography-49.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:8c25ceb16df5b9435f3f6a9829204985b0e0cbee3b48aacd432c7d2c850b44d9", size = 4334173, upload-time = "2026-06-12T20:01:44.743Z" },
{ url = "https://files.pythonhosted.org/packages/f0/ee/6fca21d1ac73e06f8bef71940abfd4d2f6472b4bca284d770f32bd4086f6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:28d8b15e6275f12c8a207dc309dfa957903c927d08d0cc937ee3f63f200693cc", size = 4697298, upload-time = "2026-06-12T20:02:20.918Z" },
{ url = "https://files.pythonhosted.org/packages/67/d0/a5fcd3515f0bae49a7b6d0413cc1bdccdcc1fc0047037a0d480642cdc5d6/cryptography-49.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6fc361c34fb6aac015ce19435876635e5c6d21db31998b0920f675f131e043b8", size = 5254338, upload-time = "2026-06-12T20:02:22.737Z" },
{ url = "https://files.pythonhosted.org/packages/a0/84/84fe36f19caf857d61cb7fc9c63035a47ffabd84ea12d1d393148efa3615/cryptography-49.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2400ef9c9e2299a25614eb1dea3db54a69b1349efd043bfac9c67630d136df36", size = 4735650, upload-time = "2026-06-12T20:02:41.389Z" },
{ url = "https://files.pythonhosted.org/packages/6c/a0/db537264e234f7273a73ec020873d6d6b39dfd8a53db78b550ca8320440e/cryptography-49.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:67e1d20ad9ef3a563c59ef22e7a8a0b8210bd26604369ea4a30a7c66aefe504e", size = 4834820, upload-time = "2026-06-12T20:01:51.847Z" },
{ url = "https://files.pythonhosted.org/packages/93/77/8df9eb486495979bccecd1062e2eaf435250e84437040295b57d09048b0b/cryptography-49.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:42b0684e0e40cf26122427802486f6d93aea593612603a94fbf260c7eb1e9c1b", size = 4967968, upload-time = "2026-06-12T20:02:12.524Z" },
{ url = "https://files.pythonhosted.org/packages/c2/e6/f60198ea8d9dfa15fff9ed4ca02ce362f6eadd9ba757dcc50634c4257b63/cryptography-49.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:026ac7423e6fa66872d3bf889be5974507da3944f866f704fa200eadacd00001", size = 3785547, upload-time = "2026-06-12T20:02:26.847Z" },
]
[[package]]
name = "google-auth"
version = "2.55.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
{ name = "pyasn1-modules" },
]
sdist = { url = "https://files.pythonhosted.org/packages/81/1c/70b23fc52b2bb3c70b379f3bd05c4a60ab3a873e30c6bd21c57e0154848a/google_auth-2.55.0.tar.gz", hash = "sha256:fcd3a130f575fa36403d38774af1c64a4fbfbca09215f0589d2372b5119697cb", size = 349379, upload-time = "2026-06-15T22:33:16.466Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/44/71/c0321dc6d63d99946da45f7c06299b934e4f7f7da5c4f14d101bcb39adf1/google_auth-2.55.0-py3-none-any.whl", hash = "sha256:a17cef9dedf98c4ebae2fb0c48c8f75952c877cbc2efe09f329ef16c2783d88a", size = 252400, upload-time = "2026-06-15T22:33:14.992Z" },
]
[[package]]
name = "google-auth-oauthlib"
version = "1.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-auth" },
{ name = "requests-oauthlib" },
]
sdist = { url = "https://files.pythonhosted.org/packages/70/18/90c7fac516e63cf2058166fce0c88c353647c677b51cc036c09c49bb5cbb/google_auth_oauthlib-1.4.0.tar.gz", hash = "sha256:18b5e28880eb8eba9065c436becdc0ee8e4b59117a73a510679c82f70cd363d2", size = 21675, upload-time = "2026-05-07T08:03:47.816Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/d3/d7dff0d58a9e9244b48044bfb6a898bfcc8ecc42e0031d1bebc695344725/google_auth_oauthlib-1.4.0-py3-none-any.whl", hash = "sha256:251314f213a9ee46a5ae73988e84fd7cca8bb68e7ecf4bfd45940f9e7f51d070", size = 19261, upload-time = "2026-05-07T08:02:13.798Z" },
]
[[package]]
name = "gspread"
version = "6.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-auth" },
{ name = "google-auth-oauthlib" },
]
sdist = { url = "https://files.pythonhosted.org/packages/91/83/42d1d813822ed016d77aabadc99b09de3b5bd68532fd6bae23fd62347c41/gspread-6.2.1.tar.gz", hash = "sha256:2c7c99f7c32ebea6ec0d36f2d5cbe8a2be5e8f2a48bde87ad1ea203eff32bd03", size = 82590, upload-time = "2025-05-14T15:56:25.254Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/27/76/563fb20dedd0e12794d9a12cfe0198458cc0501fdc7b034eee2166d035d5/gspread-6.2.1-py3-none-any.whl", hash = "sha256:6d4ec9f1c23ae3c704a9219026dac01f2b328ac70b96f1495055d453c4c184db", size = 59977, upload-time = "2025-05-14T15:56:24.014Z" },
]
[[package]]
name = "idna"
version = "3.10"
@@ -116,6 +250,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/05/eb7eec66b95cf697f08c754ef26c3549d03ebd682819f794cb039574a0a6/numpy-2.2.4-cp313-cp313t-win_amd64.whl", hash = "sha256:188dcbca89834cc2e14eb2f106c96d6d46f200fe0200310fc29089657379c58d", size = 12739119, upload-time = "2025-03-16T18:20:03.94Z" },
]
[[package]]
name = "oauthlib"
version = "3.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0b/5f/19930f824ffeb0ad4372da4812c50edbd1434f678c90c2733e1188edfc63/oauthlib-3.3.1.tar.gz", hash = "sha256:0f0f8aa759826a193cf66c12ea1af1637f87b9b4622d46e866952bb022e538c9", size = 185918, upload-time = "2025-06-19T22:48:08.269Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/9c/92789c596b8df838baa98fa71844d84283302f7604ed565dafe5a6b5041a/oauthlib-3.3.1-py3-none-any.whl", hash = "sha256:88119c938d2b8fb88561af5f6ee0eec8cc8d552b7bb1f712743136eb7523b7a1", size = 160065, upload-time = "2025-06-19T22:48:06.508Z" },
]
[[package]]
name = "packaging"
version = "26.2"
@@ -174,6 +317,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
]
[[package]]
name = "pyasn1"
version = "0.6.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5c/5f/6583902b6f79b399c9c40674ac384fd9cd77805f9e6205075f828ef11fb2/pyasn1-0.6.3.tar.gz", hash = "sha256:697a8ecd6d98891189184ca1fa05d1bb00e2f84b5977c481452050549c8a72cf", size = 148685, upload-time = "2026-03-17T01:06:53.382Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5d/a0/7d793dce3fa811fe047d6ae2431c672364b462850c6235ae306c0efd025f/pyasn1-0.6.3-py3-none-any.whl", hash = "sha256:a80184d120f0864a52a073acc6fc642847d0be408e7c7252f31390c0f4eadcde", size = 83997, upload-time = "2026-03-17T01:06:52.036Z" },
]
[[package]]
name = "pyasn1-modules"
version = "0.4.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e9/e6/78ebbb10a8c8e4b61a59249394a4a594c1a7af95593dc933a349c8d00964/pyasn1_modules-0.4.2.tar.gz", hash = "sha256:677091de870a80aae844b1ca6134f54652fa2c8c5a52aa396440ac3106e941e6", size = 307892, upload-time = "2025-03-28T02:41:22.17Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/47/8d/d529b5d697919ba8c11ad626e835d4039be708a35b0d22de83a269a6682c/pyasn1_modules-0.4.2-py3-none-any.whl", hash = "sha256:29253a9207ce32b64c3ac6600edc75368f98473906e8fd1043bd6b5b1de2c14a", size = 181259, upload-time = "2025-03-28T02:41:19.028Z" },
]
[[package]]
name = "pycparser"
version = "3.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
]
[[package]]
name = "pygments"
version = "2.20.0"
@@ -244,6 +417,19 @@ wheels = [
{ 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]]
name = "requests-oauthlib"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "oauthlib" },
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/42/f2/05f29bc3913aea15eb670be136045bf5c5bbf4b99ecb839da9b422bb2c85/requests-oauthlib-2.0.0.tar.gz", hash = "sha256:b3dffaebd884d8cd778494369603a9e7b58d29111bf6b41bdc2dcd87203af4e9", size = 55650, upload-time = "2024-03-22T20:32:29.939Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3b/5d/63d4ae3b9daea098d5d6f5da83984853c1bbacd5dc826764b249fe119d24/requests_oauthlib-2.0.0-py2.py3-none-any.whl", hash = "sha256:7dd8a5c40426b779b0868c404bdef9768deccf22749cde15852df527e6269b36", size = 24179, upload-time = "2024-03-22T20:32:28.055Z" },
]
[[package]]
name = "ruff"
version = "0.11.5"
@@ -283,6 +469,7 @@ name = "tatami"
version = "0.1.0"
source = { editable = "." }
dependencies = [
{ name = "gspread" },
{ name = "numpy" },
{ name = "pandas" },
{ name = "pandas-stubs" },
@@ -302,6 +489,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "gspread", specifier = ">=6.2.1" },
{ name = "numpy", specifier = ">=2.2.4" },
{ name = "pandas", specifier = ">=2.2.3" },
{ name = "pandas-stubs", specifier = ">=2.2.3.250308" },