diff --git a/.env.example b/.env.example index 89cce7d..12263d3 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index e4b14a2..f003b21 100644 --- a/.gitignore +++ b/.gitignore @@ -175,4 +175,7 @@ cython_debug/ # csv containing personal information -test-config.csv \ No newline at end of file +test-config.csv + +# Google Sheets service-account key +service-account*.json \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..33c3018 --- /dev/null +++ b/CLAUDE.md @@ -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 (0–10, 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. diff --git a/README.md b/README.md index 29f1db5..cbe3c19 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,10 @@ gitignored, so your secret never gets committed. An already‑exported > 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 **tab‑separated** 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 | |------|-------|---------|--------| -| **After‑party 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`. | +| **After‑party 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 "course‑triple". Larger → fewer, bigger groups. | | **Kitchen‑size penalty** | `classes.py:19` (`minutes=3 * (10 - kitchen_size)`) | 3 min per point | Travel‑time‑equivalent penalty for small kitchens. Raise the `3` to push hosting toward big kitchens. | | **Annealing schedule** | `tatami_masterplan.py:48–50` | `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 diff --git a/pyproject.toml b/pyproject.toml index 6137045..6357f05 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/tatami/sheets_export.py b/src/tatami/sheets_export.py new file mode 100644 index 0000000..431c3de --- /dev/null +++ b/src/tatami/sheets_export.py @@ -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) diff --git a/src/tatami/tatami_masterplan.py b/src/tatami/tatami_masterplan.py index 34f303f..80a56c5 100644 --- a/src/tatami/tatami_masterplan.py +++ b/src/tatami/tatami_masterplan.py @@ -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}") diff --git a/tests/test_e2e_sheets.py b/tests/test_e2e_sheets.py new file mode 100644 index 0000000..98ca619 --- /dev/null +++ b/tests/test_e2e_sheets.py @@ -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 diff --git a/tests/test_sheets_export.py b/tests/test_sheets_export.py new file mode 100644 index 0000000..fef79a5 --- /dev/null +++ b/tests/test_sheets_export.py @@ -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 diff --git a/uv.lock b/uv.lock index 2316a3c..1437487 100644 --- a/uv.lock +++ b/uv.lock @@ -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" },