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:
@@ -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
|
||||
|
||||
@@ -176,3 +176,6 @@ cython_debug/
|
||||
|
||||
# csv containing personal information
|
||||
test-config.csv
|
||||
|
||||
# Google Sheets service-account key
|
||||
service-account*.json
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user