Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e7bb0b9c69 | |||
| 6b0fca0100 | |||
| 91dc7729f9 | |||
| 21eb9539d4 | |||
| fdd82e4c6f | |||
| ba59235c3e | |||
| 2c60b83d8a | |||
| 4da624bfcf | |||
| 51bc491cc8 | |||
| e4dae9ec35 |
@@ -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, and download the service account's JSON key.
|
||||
# Then create a blank Google Sheet, share it with the service account's
|
||||
# client_email (found in the JSON key) as Editor, and set its id as
|
||||
# `spreadsheet_id` in the saved masterplan.json (see README) - leave it unset
|
||||
# there to skip sheet export entirely.
|
||||
GOOGLE_SHEETS_CREDENTIALS_FILE=service-account.json
|
||||
|
||||
+4
-1
@@ -175,4 +175,7 @@ cython_debug/
|
||||
|
||||
|
||||
# csv containing personal information
|
||||
test-config.csv
|
||||
test-config.csv
|
||||
|
||||
# Google Sheets service-account key
|
||||
service-account*.json
|
||||
@@ -0,0 +1,59 @@
|
||||
# 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` in `.env` + `spreadsheet_id` in the saved plan to also export to a shared Google Sheet — see `.env.example`). The first run computes a plan and saves it to `masterplan.json` (override via `PLAN_FILE`); later runs load that file instead of recomputing, so it's the place to hand-edit groups/courses/contacts/`spreadsheet_id` between runs.
|
||||
- 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)
|
||||
- Test: `uv run pytest` (offline suite, HTTP mocked); `uv run pytest -m e2e` for the opt-in live-API tests
|
||||
- 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.
|
||||
|
||||
## 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. The rotation is a resolvable "Latin-square"/transversal design (`_rotation_hosts`): slots form a `k x 3` grid (`k = n // 3` groups per course), each course is a parallel class partitioning all groups into transversal tables of three (one starter/main/dessert each), so for any `n >= 9` no two groups ever meet more than once. `n = 3`/`6` are combinatorially impossible and fall back to a degenerate same-row rotation. (This replaced an earlier fixed `+1`/`-4` cyclic offset that produced repeat meetings for group counts like 9.)
|
||||
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 `Plan`/sheet export step needs (`.hosts`, `.get_guests(...)`).
|
||||
5. **Wrap in a `Plan` and save** (`src/tatami/plan.py`) — `__main__` bundles the computed `groups` + `after_party_group` with the event config (`course_times`, `organizer_contacts`, `info_text`, `spreadsheet_id`) into a `Plan` and calls `plan.save(PLAN_FILE)`. On the next invocation, if that file exists, `Plan.load()` reads it back instead of recomputing — this is the save/reload/edit path: hand-edit `masterplan.json` (move a member between groups, change a course, fill in `spreadsheet_id`, ...) and rerun to pick up the edit without hitting the Routes API again.
|
||||
6. **Export to Google Sheets (optional)** — if the loaded/built `Plan.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`, `src/tatami/plan.py`)
|
||||
|
||||
`Participant` and `Group` are pydantic `BaseModel`s (so they support `model_dump()`/`model_dump_json()`/`model_validate_json()` directly); `Plan` wraps the whole thing for persistence.
|
||||
|
||||
- `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` (a property resolved from `main_member_uuid` against `members` — 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` property (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).
|
||||
- `hosts` references *other* `Group`s and these references are genuinely cyclic (a group's hosts can host it back), so they can't be embedded directly in JSON. The live `hosts` list is a private, non-persisted attribute set via `set_hosts()`/`add_host()`; the persisted field is `host_uuids` (kept in sync automatically). After `Plan.load()`, `Group.resolve_hosts()` re-links `hosts` from `host_uuids` against the sibling groups in the same `Plan` — call it yourself if you ever construct `Group`s outside of a `Plan` and need `.hosts` populated from `host_uuids`.
|
||||
- `Plan` (`plan.py`): bundles `groups`, `after_party_group`, and the event-wide config (`course_times`, `organizer_contacts`, `info_text`, `spreadsheet_id`). `Plan.save(path)` / `Plan.load(path)` round-trip the whole thing to/from JSON; `plan.participants` is a derived property (flattened, deduplicated `group.members` across all groups + the after party), not a separately stored field, so editing a participant's data in a group's `members` is the single source of truth.
|
||||
- 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` in `.env` and a
|
||||
`spreadsheet_id` in the saved `masterplan.json` 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
|
||||
@@ -107,18 +111,26 @@ Charlie Hermann-Hesse-Str 50, 76189 Karlsruhe 555-8765 9 none
|
||||
uv run python -m tatami.tatami_masterplan
|
||||
```
|
||||
|
||||
This reads `test-config.csv` from the current directory, calls the Routes API,
|
||||
and prints the generated masterplan. The after‑party address and travel mode are
|
||||
currently set in the `__main__` block of `tatami_masterplan.py` (see
|
||||
[What you can tweak](#what-you-can-tweak)).
|
||||
The first run reads `test-config.csv` from the current directory, calls the
|
||||
Routes API, computes the masterplan, and **saves it** to `masterplan.json`
|
||||
(override the path with the `PLAN_FILE` env var). The after‑party address and
|
||||
travel mode are currently set in the `__main__` block of
|
||||
`tatami_masterplan.py` (see [What you can tweak](#what-you-can-tweak)).
|
||||
|
||||
Every subsequent run **loads `masterplan.json` instead of recomputing**, so
|
||||
you can hand-edit that file — move a participant between groups, change a
|
||||
course, fix an address, set `spreadsheet_id` — and rerun to pick up the edit
|
||||
without calling the Routes API again. Delete (or move) the file to force a
|
||||
fresh computation.
|
||||
|
||||
To use Tatami from your own code:
|
||||
|
||||
```python
|
||||
from tatami.tatami_masterplan import (
|
||||
get_after_party_group, get_masterplan, load_csv_to_participants,
|
||||
get_after_party_group, compute_masterplan_groups, load_csv_to_participants,
|
||||
)
|
||||
from tatami.traveltimes import get_participant_distance_matrix
|
||||
from tatami.plan import Plan
|
||||
|
||||
participants = load_csv_to_participants("my-participants.csv")
|
||||
after_party = get_after_party_group("Some Street 1, 12345 City")
|
||||
@@ -126,14 +138,22 @@ after_party = get_after_party_group("Some Street 1, 12345 City")
|
||||
distance_matrix = get_participant_distance_matrix(
|
||||
[*participants, after_party.main_member], mode="BICYCLE"
|
||||
)
|
||||
groups, participants_out = get_masterplan(participants, distance_matrix, after_party)
|
||||
groups, participants_out = compute_masterplan_groups(
|
||||
participants, distance_matrix, after_party
|
||||
)
|
||||
plan = Plan(groups=groups, after_party_group=after_party)
|
||||
plan.save("masterplan.json")
|
||||
```
|
||||
|
||||
## Output format
|
||||
|
||||
`get_masterplan` returns a tuple `(group_dicts, participant_dicts)`.
|
||||
The domain model (`Participant`, `Group`, `Plan` in `src/tatami/`) is built on
|
||||
[pydantic](https://docs.pydantic.dev/), so everything supports
|
||||
`model_dump()`/`model_dump_json()` plus `Plan.save(path)`/`Plan.load(path)`
|
||||
for the full plan.
|
||||
|
||||
Each **group** dict:
|
||||
`get_masterplan` (a thin convenience wrapper around `compute_masterplan_groups`)
|
||||
returns a tuple `(group_dicts, participant_dicts)`. Each **group** dict:
|
||||
|
||||
```python
|
||||
{
|
||||
@@ -153,19 +173,81 @@ 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.
|
||||
|
||||
A `Group`'s `hosts` are *other* `Group`s, and these references can form
|
||||
cycles (a group's hosts can also host that group back), so a `Plan`'s JSON
|
||||
persists them as plain `host_uuids` id lists rather than embedding the full
|
||||
objects — `Plan.load()` re-links the live `group.hosts` list from those ids
|
||||
after loading, so `group.hosts[i].course` etc. works exactly as it does right
|
||||
after computing the plan.
|
||||
|
||||
## Sharing the plan with participants
|
||||
|
||||
`Plan` (`src/tatami/plan.py`) bundles the computed `groups`, the
|
||||
`after_party_group`, and the event-wide configuration below into one
|
||||
object that 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**, copy its sheet ID, and
|
||||
set `spreadsheet_id` to that ID **in the saved `masterplan.json`** (not
|
||||
`.env` — the spreadsheet to export to is part of the plan itself, so it
|
||||
round-trips with everything else).
|
||||
|
||||
With `GOOGLE_SHEETS_CREDENTIALS_FILE` set and `spreadsheet_id` filled in,
|
||||
rerunning `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, carried on the
|
||||
`Plan` and passed straight through to the sheet with no logic in between —
|
||||
set them when first building the plan, 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)
|
||||
```
|
||||
|
||||
or by editing `course_times` / `organizer_contacts` / `info_text` directly in
|
||||
the saved `masterplan.json` afterwards. `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 `spreadsheet_id` is unset, this step is skipped entirely and Tatami just
|
||||
prints a reminder to fill it in.
|
||||
|
||||
## 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`. |
|
||||
| **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. |
|
||||
| **Course names** | `tatami_masterplan.py:31` | `["starter", "main", "dessert"]` | The three courses. The 3‑course rotation is baked into the topology — changing the *count* needs more work (see below). |
|
||||
| **Rotation topology** | `tatami_masterplan.py:172–173` (offsets `+1`, `-4`) | — | Defines who hosts whom. Changing these changes who meets whom; keep the invariant that each group's three hosts cover all three courses. |
|
||||
| **Saved plan path** | `PLAN_FILE` env var | `masterplan.json` | Where the computed/edited `Plan` is saved to and (on the next run) loaded from. |
|
||||
| **After‑party address** | `tatami_masterplan.py` (`__main__`) | a Karlsruhe address | Where everyone ends the night; also influences host ranking. Only used the first time a plan is computed. |
|
||||
| **Travel mode** | `tatami_masterplan.py` (`mode="BICYCLE"`) | `BICYCLE` | Any Routes API `travelMode`: `BICYCLE`, `DRIVE`, `WALK`, `TWO_WHEELER`, `TRANSIT`. |
|
||||
| **Course start times** | `tatami_masterplan.py` (`COURSE_TIMES`), or `course_times` in the saved plan | `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:54` (`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` (`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` (`run_simulated_annealing` call) | `T=1000`, `cooling=0.99`, `iters=10000` | Optimization quality vs. runtime. More iterations / slower cooling → better routes, slower. |
|
||||
| **Course names** | `tatami_masterplan.py` (`courses = [...]`) | `["starter", "main", "dessert"]` | The three courses. The 3‑course rotation is baked into the topology — changing the *count* needs more work (see below). |
|
||||
| **Rotation topology** | `tatami_masterplan.py` `get_courses` (offsets `+1`, `-4`) | — | Defines who hosts whom. Changing these changes who meets whom; keep the invariant that each group's three hosts cover all three courses. |
|
||||
| **Distance vs. duration** | `traveltimes.py` `get_distance_matrix(value=…)` | `"duration"` | Optimize on travel **time** (`"duration"`) or **distance** (`"distanceMeters"`). |
|
||||
|
||||
After changing routing‑relevant knobs, run the test suite (`uv run pytest`) — the
|
||||
@@ -238,8 +320,10 @@ uv run pytest -m e2e # opt-in live test that calls the real Routes API
|
||||
|
||||
```
|
||||
src/tatami/
|
||||
classes.py # Participant and Group domain model
|
||||
classes.py # Participant and Group domain model (pydantic)
|
||||
plan.py # Plan: bundles groups + config, save()/load() to/from JSON
|
||||
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,9 +5,11 @@ 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",
|
||||
"pydantic>=2.13.4",
|
||||
"python-dotenv>=1.2.2",
|
||||
"requests>=2.32.3",
|
||||
"tqdm>=4.67.1",
|
||||
|
||||
+78
-38
@@ -1,19 +1,21 @@
|
||||
import datetime as dt
|
||||
import pandas as pd
|
||||
from typing import cast
|
||||
from typing import Any, Literal, cast
|
||||
from uuid import uuid4
|
||||
|
||||
import pandas as pd
|
||||
from pydantic import BaseModel, Field, PrivateAttr, model_validator
|
||||
|
||||
class Participant:
|
||||
def __init__(
|
||||
self, name: str, address: str, phone: str, kitchen_size: float, allergies: str
|
||||
):
|
||||
self.uuid = str(uuid4())
|
||||
self.name = name
|
||||
self.address = address
|
||||
self.phone = phone
|
||||
self.kitchen_size = kitchen_size # bigger is better; range 0-10
|
||||
self.allergies = allergies
|
||||
Course = Literal["starter", "main", "dessert"]
|
||||
_COURSE_ORDER: dict[str, int] = {"starter": 0, "main": 1, "dessert": 2}
|
||||
|
||||
|
||||
class Participant(BaseModel):
|
||||
uuid: str = Field(default_factory=lambda: str(uuid4()))
|
||||
name: str
|
||||
address: str
|
||||
phone: str
|
||||
kitchen_size: float
|
||||
allergies: str
|
||||
|
||||
def get_penalty(self) -> dt.timedelta:
|
||||
return dt.timedelta(minutes=3 * (10 - self.kitchen_size))
|
||||
@@ -37,7 +39,7 @@ class Participant:
|
||||
def __str__(self) -> str:
|
||||
return self.__repr__()
|
||||
|
||||
def dict(self) -> dict:
|
||||
def dict(self) -> dict: # type: ignore[override]
|
||||
return {
|
||||
"uuid": self.uuid,
|
||||
"name": self.name,
|
||||
@@ -48,39 +50,77 @@ class Participant:
|
||||
}
|
||||
|
||||
|
||||
class Group:
|
||||
def __init__(self, members: list[Participant], main_member: int = 0):
|
||||
self.uuid = "Group_" + str(uuid4())
|
||||
self.members = members
|
||||
self.main_member = members[main_member]
|
||||
self.course: str | None = (
|
||||
None # For ordering the groups allowed values: "starter", "main", "dessert"
|
||||
)
|
||||
self.hosts: list[Group] | None = None
|
||||
class Group(BaseModel):
|
||||
uuid: str = Field(default_factory=lambda: "Group_" + str(uuid4()))
|
||||
members: list[Participant]
|
||||
# The persisted reference to one of `members`. `hosts`/`host_uuids` reference
|
||||
# *other* Groups, which can form cycles (a group's hosts can host it back) -
|
||||
# those are kept as plain uuid references and resolved on demand via
|
||||
# `set_hosts`/`resolve_hosts`, rather than embedded directly, so a Plan
|
||||
# containing many Groups can still be serialized to JSON.
|
||||
main_member_uuid: str = ""
|
||||
course: Course | None = (
|
||||
None # For ordering the groups allowed values: "starter", "main", "dessert"
|
||||
)
|
||||
host_uuids: list[str] | None = None
|
||||
|
||||
def set_course(self, course: str):
|
||||
_hosts: list["Group"] | None = PrivateAttr(default=None)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _default_main_member_uuid(cls, data: Any) -> Any:
|
||||
"""Support the legacy ``Group(members=[...], main_member=<index>)`` call style."""
|
||||
if isinstance(data, dict) and not data.get("main_member_uuid"):
|
||||
members = data.get("members") or []
|
||||
index = data.pop("main_member", 0)
|
||||
if members:
|
||||
member = members[index]
|
||||
data["main_member_uuid"] = (
|
||||
member.uuid if isinstance(member, Participant) else member["uuid"]
|
||||
)
|
||||
return data
|
||||
|
||||
@property
|
||||
def main_member(self) -> Participant:
|
||||
return next(m for m in self.members if m.uuid == self.main_member_uuid)
|
||||
|
||||
@property
|
||||
def hosts(self) -> list["Group"] | None:
|
||||
return self._hosts
|
||||
|
||||
def set_course(self, course: Course) -> None:
|
||||
self.course = course
|
||||
|
||||
def set_hosts(self, hosts: list["Group"]):
|
||||
self.hosts = hosts
|
||||
def set_hosts(self, hosts: list["Group"]) -> None:
|
||||
self._hosts = hosts
|
||||
self.sort_hosts()
|
||||
|
||||
def sort_hosts(self):
|
||||
if self.hosts is None:
|
||||
def sort_hosts(self) -> None:
|
||||
if self._hosts is None:
|
||||
return
|
||||
|
||||
courses = {"starter": 0, "main": 1, "dessert": 2}
|
||||
self.hosts.sort(key=lambda x: courses[x.course] if x.course in courses else 3)
|
||||
self._hosts.sort(
|
||||
key=lambda x: _COURSE_ORDER[x.course] if x.course in _COURSE_ORDER else 3
|
||||
)
|
||||
self.host_uuids = [h.uuid for h in self._hosts]
|
||||
|
||||
def add_host(self, host: "Group"):
|
||||
if self.hosts is None:
|
||||
self.hosts = []
|
||||
self.hosts.append(host)
|
||||
def add_host(self, host: "Group") -> None:
|
||||
if self._hosts is None:
|
||||
self._hosts = []
|
||||
self._hosts.append(host)
|
||||
self.sort_hosts()
|
||||
|
||||
def add_member(self, member: Participant, main_member: bool = False):
|
||||
def resolve_hosts(self, groups_by_uuid: dict[str, "Group"]) -> None:
|
||||
"""Re-link the live ``hosts`` list from ``host_uuids`` (e.g. after a JSON reload)."""
|
||||
self._hosts = (
|
||||
None
|
||||
if self.host_uuids is None
|
||||
else [groups_by_uuid[uuid] for uuid in self.host_uuids]
|
||||
)
|
||||
|
||||
def add_member(self, member: Participant, main_member: bool = False) -> None:
|
||||
if main_member:
|
||||
self.main_member = member
|
||||
self.main_member_uuid = member.uuid
|
||||
self.members.append(member)
|
||||
|
||||
def get_total_time(
|
||||
@@ -106,13 +146,13 @@ class Group:
|
||||
|
||||
return total_time
|
||||
|
||||
def dict(self) -> dict:
|
||||
def dict(self) -> dict: # type: ignore[override]
|
||||
return {
|
||||
"uuid": self.uuid,
|
||||
"members": [member.uuid for member in self.members],
|
||||
"main_member": self.main_member.uuid,
|
||||
"main_member": self.main_member_uuid,
|
||||
"course": self.course,
|
||||
"hosts": [host.uuid for host in self.hosts] if self.hosts else None,
|
||||
"hosts": list(self.host_uuids) if self.host_uuids else None,
|
||||
}
|
||||
|
||||
def get_guests(self, groups: list["Group"]) -> list[Participant]:
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from tatami.classes import Group, Participant
|
||||
|
||||
|
||||
class Plan(BaseModel):
|
||||
"""The full, persistable state of one running dinner.
|
||||
|
||||
Bundles the computed groups together with the event-wide configuration
|
||||
(course times, organizer contacts, info text, the Sheets spreadsheet id)
|
||||
so the whole thing can be saved to a JSON file, hand-edited (swap a
|
||||
member between groups, change a course, fill in `spreadsheet_id`, ...),
|
||||
and reloaded without recomputing routes.
|
||||
"""
|
||||
|
||||
groups: list[Group]
|
||||
after_party_group: Group
|
||||
course_times: dict[str, str] = Field(default_factory=dict)
|
||||
organizer_contacts: list[tuple[str, str]] = Field(default_factory=list)
|
||||
info_text: str | None = None
|
||||
spreadsheet_id: str | None = None
|
||||
|
||||
def model_post_init(self, __context: object) -> None:
|
||||
groups_by_uuid = {g.uuid: g for g in [*self.groups, self.after_party_group]}
|
||||
for group in self.groups:
|
||||
group.resolve_hosts(groups_by_uuid)
|
||||
|
||||
@property
|
||||
def participants(self) -> list[Participant]:
|
||||
"""Every participant across all groups (including the after party), deduplicated."""
|
||||
by_uuid: dict[str, Participant] = {}
|
||||
for group in [*self.groups, self.after_party_group]:
|
||||
for member in group.members:
|
||||
by_uuid[member.uuid] = member
|
||||
return list(by_uuid.values())
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: str | Path) -> "Plan":
|
||||
return cls.model_validate_json(Path(path).read_text())
|
||||
|
||||
def save(self, path: str | Path) -> None:
|
||||
Path(path).write_text(self.model_dump_json(indent=2))
|
||||
@@ -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)
|
||||
+156
-25
@@ -1,10 +1,41 @@
|
||||
from tatami.classes import Participant, Group
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
|
||||
from tatami.classes import Participant, Group, Course
|
||||
from tatami.plan import Plan
|
||||
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
|
||||
|
||||
# Where the computed/edited Plan (groups, course times, contacts, spreadsheet id, ...)
|
||||
# is saved to and loaded from. Override with the PLAN_FILE env var.
|
||||
PLAN_FILE = os.getenv("PLAN_FILE", "masterplan.json")
|
||||
|
||||
# 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:
|
||||
"""
|
||||
@@ -13,14 +44,14 @@ def get_after_party_group(address: str) -> Group:
|
||||
participant = Participant(
|
||||
name="After Party", address=address, phone="", kitchen_size=10, allergies=""
|
||||
)
|
||||
return Group(members=[participant], main_member=0)
|
||||
return Group(members=[participant], main_member_uuid=participant.uuid)
|
||||
|
||||
|
||||
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,
|
||||
@@ -28,7 +59,7 @@ def get_masterplan(
|
||||
)
|
||||
hosts = participants[: 3 * groups_per_course]
|
||||
semi_hosts = participants[3 * groups_per_course :]
|
||||
courses = ["starter", "main", "dessert"] * groups_per_course
|
||||
courses: list[Course] = ["starter", "main", "dessert"] * groups_per_course
|
||||
|
||||
groups = []
|
||||
for host in hosts:
|
||||
@@ -51,12 +82,23 @@ 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
|
||||
|
||||
|
||||
def assign_courses(groups: list[Group], courses: list[str]) -> None:
|
||||
def assign_courses(groups: list[Group], courses: list[Course]) -> None:
|
||||
"""
|
||||
Assign courses to groups.
|
||||
|
||||
@@ -164,15 +206,68 @@ def simulated_annealing(
|
||||
return best
|
||||
|
||||
|
||||
# The rotation is a resolvable "Latin-square" / transversal design rather than a
|
||||
# fixed cyclic offset. Slots are laid out as a k x 3 grid: slot ``i`` cooks course
|
||||
# ``i % 3`` (starter/main/dessert) and sits in row ``i // 3``, where ``k = n // 3``
|
||||
# is the number of groups per course. Each course is one "parallel class" that
|
||||
# partitions all n groups into k dinner tables of three; every table is a
|
||||
# transversal (exactly one starter, one main, one dessert group), so no two groups
|
||||
# that cook the same course ever share a table.
|
||||
#
|
||||
# Table ``a`` of the class for course ``c`` is
|
||||
# {S_a, M_{a + p[c]}, D_{a + q[c]}} (row indices mod k)
|
||||
# where S/M/D are the starter/main/dessert groups. Two groups meet at most once iff
|
||||
# the three ``p`` values are distinct, the three ``q`` values are distinct, and the
|
||||
# three ``q - p`` values are distinct (mod k) -- these guard S-M, S-D and M-D
|
||||
# repeats respectively. ``p = (0, 1, 2)``, ``q = (0, 2, 1)`` satisfies all three for
|
||||
# every ``k >= 3`` (the values 0, 1, k-1 are distinct there), which covers every
|
||||
# real event size. ``k < 3`` (n = 3 or 6) cannot be made collision-free at all -- a
|
||||
# group would have to meet more distinct groups than exist -- so we fall back to a
|
||||
# degenerate same-row rotation (``p = q = 0``) that still satisfies every structural
|
||||
# invariant (see ``tests/test_routing.py``) even though tables then repeat.
|
||||
_COURSE_OFFSETS_P = (0, 1, 2)
|
||||
_COURSE_OFFSETS_Q = (0, 2, 1)
|
||||
|
||||
|
||||
@lru_cache(maxsize=None)
|
||||
def _rotation_hosts(total_meetings: int) -> tuple[tuple[int, int, int], ...]:
|
||||
"""Precompute, per slot, the (starter, main, dessert) host slots it dines at.
|
||||
|
||||
Returns a tuple indexed by slot; entry ``i`` is the three host slots the group
|
||||
in slot ``i`` visits, ordered starter -> main -> dessert. The group is always
|
||||
its own host for the course it cooks, so ``i`` appears in its own entry.
|
||||
"""
|
||||
k = total_meetings // 3
|
||||
if k < 3:
|
||||
p = q = (0, 0, 0)
|
||||
else:
|
||||
p, q = _COURSE_OFFSETS_P, _COURSE_OFFSETS_Q
|
||||
|
||||
def slot(course: int, row: int) -> int:
|
||||
return 3 * (row % k) + course
|
||||
|
||||
hosts: list[list[int]] = [[-1, -1, -1] for _ in range(total_meetings)]
|
||||
for course in range(3): # each course is one parallel class
|
||||
for table in range(k):
|
||||
members = (
|
||||
slot(0, table),
|
||||
slot(1, table + p[course]),
|
||||
slot(2, table + q[course]),
|
||||
)
|
||||
host = members[
|
||||
course
|
||||
] # the class for course `course` is hosted by its course-`course` member
|
||||
for member in members:
|
||||
hosts[member][course] = host
|
||||
return tuple((h[0], h[1], h[2]) for h in hosts)
|
||||
|
||||
|
||||
def get_courses(
|
||||
group_index: int,
|
||||
total_meetings: int,
|
||||
):
|
||||
a = group_index
|
||||
b = (group_index + 1) % total_meetings
|
||||
c = (group_index - 4) % total_meetings
|
||||
order = sorted((a, b, c), key=lambda x: x % 3)
|
||||
return order
|
||||
) -> tuple[int, int, int]:
|
||||
"""The three host slots (starter, main, dessert order) that ``group_index`` visits."""
|
||||
return _rotation_hosts(total_meetings)[group_index]
|
||||
|
||||
|
||||
def fast_total_time(distance_matrix: np.ndarray, solution: list[int]) -> float:
|
||||
@@ -209,7 +304,7 @@ def load_csv_to_participants(file_path: str) -> list[Participant]:
|
||||
"""
|
||||
Load participants from a CSV file.
|
||||
"""
|
||||
df = pd.read_csv(file_path, sep="\t")
|
||||
df = pd.read_csv(file_path, sep="\t", dtype={"phone": str, "allergies": str})
|
||||
participants = []
|
||||
for _, row in df.iterrows():
|
||||
participant = Participant(
|
||||
@@ -224,14 +319,50 @@ def load_csv_to_participants(file_path: str) -> list[Participant]:
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example usage
|
||||
participants = load_csv_to_participants("test-config.csv")
|
||||
after_party_group = get_after_party_group(
|
||||
"Sebastian-Kneipp-Straße 6, 76131 Karlsruhe"
|
||||
)
|
||||
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)
|
||||
print("Masterplan generated successfully.")
|
||||
plan_path = Path(PLAN_FILE)
|
||||
|
||||
if plan_path.exists():
|
||||
# A previously computed (and possibly hand-edited) plan exists - reuse it
|
||||
# as-is instead of recomputing routes. This is how you edit group
|
||||
# assignments, course times, or fill in `spreadsheet_id`: edit the file,
|
||||
# then rerun.
|
||||
plan = Plan.load(plan_path)
|
||||
print(f"Loaded existing masterplan from {plan_path}.")
|
||||
else:
|
||||
participants = load_csv_to_participants("test-config.csv")
|
||||
after_party_group = get_after_party_group(
|
||||
"Sebastian-Kneipp-Straße 6, 76131 Karlsruhe"
|
||||
)
|
||||
distance_matrix = get_participant_distance_matrix(
|
||||
[*participants, after_party_group.main_member], mode="BICYCLE"
|
||||
)
|
||||
groups, participants = compute_masterplan_groups(
|
||||
participants, distance_matrix, after_party_group
|
||||
)
|
||||
plan = Plan(
|
||||
groups=groups,
|
||||
after_party_group=after_party_group,
|
||||
course_times=COURSE_TIMES,
|
||||
organizer_contacts=ORGANIZER_CONTACTS,
|
||||
info_text=INFO_TEXT,
|
||||
)
|
||||
plan.save(plan_path)
|
||||
print(f"Masterplan generated and saved to {plan_path}.")
|
||||
|
||||
if plan.spreadsheet_id:
|
||||
client = load_sheets_client()
|
||||
spreadsheet = client.open_by_key(plan.spreadsheet_id)
|
||||
export_masterplan_to_sheet(
|
||||
spreadsheet,
|
||||
plan.groups,
|
||||
plan.after_party_group,
|
||||
plan.course_times,
|
||||
organizer_contacts=plan.organizer_contacts,
|
||||
info_text=plan.info_text,
|
||||
)
|
||||
print(f"Masterplan exported to Google Sheet: {spreadsheet.url}")
|
||||
else:
|
||||
print(
|
||||
f"No spreadsheet_id set in {plan_path}; skipping Sheets export. "
|
||||
"Edit the file to add one and rerun."
|
||||
)
|
||||
|
||||
@@ -96,12 +96,26 @@ def reduce_distance_matrix(
|
||||
if __name__ == "__main__":
|
||||
# Example usage
|
||||
participants = [
|
||||
Participant("Alice", "Römerstr. 12 76189 Karlsruhe", "555-1234", 8.0, "None"),
|
||||
Participant(
|
||||
"Bob", "Gottesauerstr. 30 76131 Karlsruhe", "555-5678", 7.5, "Peanuts"
|
||||
name="Alice",
|
||||
address="Römerstr. 12 76189 Karlsruhe",
|
||||
phone="555-1234",
|
||||
kitchen_size=8.0,
|
||||
allergies="None",
|
||||
),
|
||||
Participant(
|
||||
"Charlie", "Hermann-Hesse-Str 50 76189 Karlsruhe", "555-8765", 9.0, "None"
|
||||
name="Bob",
|
||||
address="Gottesauerstr. 30 76131 Karlsruhe",
|
||||
phone="555-5678",
|
||||
kitchen_size=7.5,
|
||||
allergies="Peanuts",
|
||||
),
|
||||
Participant(
|
||||
name="Charlie",
|
||||
address="Hermann-Hesse-Str 50 76189 Karlsruhe",
|
||||
phone="555-8765",
|
||||
kitchen_size=9.0,
|
||||
allergies="None",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
+16
-4
@@ -11,8 +11,12 @@ from conftest import make_participants, make_timedelta_matrix
|
||||
|
||||
class TestParticipant:
|
||||
def test_uuid_is_unique(self):
|
||||
a = Participant("A", "addr", "", 5, "")
|
||||
b = Participant("A", "addr", "", 5, "")
|
||||
a = Participant(
|
||||
name="A", address="addr", phone="", kitchen_size=5, allergies=""
|
||||
)
|
||||
b = Participant(
|
||||
name="A", address="addr", phone="", kitchen_size=5, allergies=""
|
||||
)
|
||||
assert a.uuid != b.uuid
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -20,7 +24,9 @@ class TestParticipant:
|
||||
[(10, 0), (7, 9), (0, 30), (5, 15)],
|
||||
)
|
||||
def test_penalty_scales_with_kitchen_size(self, kitchen_size, minutes):
|
||||
p = Participant("A", "addr", "", kitchen_size, "")
|
||||
p = Participant(
|
||||
name="A", address="addr", phone="", kitchen_size=kitchen_size, allergies=""
|
||||
)
|
||||
assert p.get_penalty() == dt.timedelta(minutes=minutes)
|
||||
|
||||
def test_after_party_time_combines_penalty_and_travel(self):
|
||||
@@ -32,7 +38,13 @@ class TestParticipant:
|
||||
assert result == dt.timedelta(minutes=9) + dt.timedelta(seconds=600)
|
||||
|
||||
def test_dict_roundtrip_fields(self):
|
||||
p = Participant("Alice", "addr", "555", 8, "peanuts")
|
||||
p = Participant(
|
||||
name="Alice",
|
||||
address="addr",
|
||||
phone="555",
|
||||
kitchen_size=8,
|
||||
allergies="peanuts",
|
||||
)
|
||||
d = p.dict()
|
||||
assert d == {
|
||||
"uuid": p.uuid,
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"""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(
|
||||
name="Anna Wagner",
|
||||
address="Kaiserstraße 12, 76131 Karlsruhe",
|
||||
phone="0721-1000001",
|
||||
kitchen_size=9,
|
||||
allergies="none",
|
||||
),
|
||||
Participant(
|
||||
name="Jonas Becker",
|
||||
address="Waldstraße 5, 76133 Karlsruhe",
|
||||
phone="0721-1000002",
|
||||
kitchen_size=7,
|
||||
allergies="lactose",
|
||||
),
|
||||
Participant(
|
||||
name="Mira Hofmann",
|
||||
address="Yorckstraße 22, 76185 Karlsruhe",
|
||||
phone="0721-1000003",
|
||||
kitchen_size=8,
|
||||
allergies="none",
|
||||
),
|
||||
]
|
||||
semi_hosts = [
|
||||
Participant(
|
||||
name="Lukas Schreiber",
|
||||
address="Sophienstraße 40, 76135 Karlsruhe",
|
||||
phone="0721-1000004",
|
||||
kitchen_size=5,
|
||||
allergies="nuts",
|
||||
),
|
||||
Participant(
|
||||
name="Sophie Lindner",
|
||||
address="Beiertheimer Allee 18, 76137 Karlsruhe",
|
||||
phone="0721-1000005",
|
||||
kitchen_size=6,
|
||||
allergies="none",
|
||||
),
|
||||
Participant(
|
||||
name="Tom Vogel",
|
||||
address="Durlacher Allee 75, 76131 Karlsruhe",
|
||||
phone="0721-1000006",
|
||||
kitchen_size=4,
|
||||
allergies="none",
|
||||
),
|
||||
Participant(
|
||||
name="Lea Brandt",
|
||||
address="Moltkestraße 30, 76133 Karlsruhe",
|
||||
phone="0721-1000007",
|
||||
kitchen_size=3,
|
||||
allergies="gluten",
|
||||
),
|
||||
Participant(
|
||||
name="Felix Krause",
|
||||
address="Adlerstraße 14, 76133 Karlsruhe",
|
||||
phone="0721-1000008",
|
||||
kitchen_size=8,
|
||||
allergies="none",
|
||||
),
|
||||
Participant(
|
||||
name="Nora Fink",
|
||||
address="Rüppurrer Straße 60, 76137 Karlsruhe",
|
||||
phone="0721-1000009",
|
||||
kitchen_size=5,
|
||||
allergies="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,103 @@
|
||||
"""Tests for the Plan model: saving, reloading, and editing a masterplan."""
|
||||
|
||||
import json
|
||||
|
||||
from tatami.classes import Group
|
||||
from tatami.plan import Plan
|
||||
from tatami.tatami_masterplan import assign_courses, get_after_party_group
|
||||
from conftest import make_participants
|
||||
|
||||
|
||||
def make_plan(n: int = 6) -> Plan:
|
||||
groups = [Group(members=[p]) for p in make_participants(n)]
|
||||
courses = ["starter", "main", "dessert"] * (n // 3)
|
||||
assign_courses(groups, courses)
|
||||
after_party = get_after_party_group("party street")
|
||||
return Plan(
|
||||
groups=groups,
|
||||
after_party_group=after_party,
|
||||
course_times={"starter": "18:30", "main": "20:00", "dessert": "22:00"},
|
||||
organizer_contacts=[("Lars (Organizer)", "0151-23456789")],
|
||||
info_text="Welcome!",
|
||||
)
|
||||
|
||||
|
||||
class TestPlanRoundTrip:
|
||||
def test_save_and_load_preserves_groups_and_hosts(self, tmp_path):
|
||||
plan = make_plan()
|
||||
path = tmp_path / "masterplan.json"
|
||||
plan.save(path)
|
||||
|
||||
loaded = Plan.load(path)
|
||||
|
||||
assert [g.uuid for g in loaded.groups] == [g.uuid for g in plan.groups]
|
||||
for original, reloaded in zip(plan.groups, loaded.groups):
|
||||
assert reloaded.course == original.course
|
||||
assert [h.uuid for h in reloaded.hosts] == [h.uuid for h in original.hosts]
|
||||
|
||||
def test_load_resolves_live_host_objects_not_just_uuids(self, tmp_path):
|
||||
plan = make_plan()
|
||||
path = tmp_path / "masterplan.json"
|
||||
plan.save(path)
|
||||
|
||||
loaded = Plan.load(path)
|
||||
|
||||
group = loaded.groups[0]
|
||||
assert group.hosts is not None
|
||||
# Hosts are live Group objects (so .course etc. is accessible), not just ids.
|
||||
assert all(isinstance(h, Group) for h in group.hosts)
|
||||
assert {h.course for h in group.hosts} == {"starter", "main", "dessert"}
|
||||
|
||||
def test_round_trip_is_idempotent(self, tmp_path):
|
||||
plan = make_plan()
|
||||
path = tmp_path / "masterplan.json"
|
||||
plan.save(path)
|
||||
first = json.loads(path.read_text())
|
||||
|
||||
Plan.load(path).save(path)
|
||||
second = json.loads(path.read_text())
|
||||
|
||||
assert first == second
|
||||
|
||||
def test_carries_course_times_contacts_info_and_spreadsheet_id(self, tmp_path):
|
||||
plan = make_plan()
|
||||
plan.spreadsheet_id = "abc123"
|
||||
path = tmp_path / "masterplan.json"
|
||||
plan.save(path)
|
||||
|
||||
loaded = Plan.load(path)
|
||||
|
||||
assert loaded.course_times == plan.course_times
|
||||
assert loaded.organizer_contacts == plan.organizer_contacts
|
||||
assert loaded.info_text == plan.info_text
|
||||
assert loaded.spreadsheet_id == "abc123"
|
||||
|
||||
def test_spreadsheet_id_defaults_to_none(self):
|
||||
assert make_plan().spreadsheet_id is None
|
||||
|
||||
|
||||
class TestPlanEditing:
|
||||
def test_editing_saved_json_changes_reloaded_plan(self, tmp_path):
|
||||
plan = make_plan()
|
||||
path = tmp_path / "masterplan.json"
|
||||
plan.save(path)
|
||||
|
||||
data = json.loads(path.read_text())
|
||||
data["spreadsheet_id"] = "edited-by-hand"
|
||||
data["groups"][0]["members"][0]["address"] = "New Address 1"
|
||||
path.write_text(json.dumps(data))
|
||||
|
||||
loaded = Plan.load(path)
|
||||
|
||||
assert loaded.spreadsheet_id == "edited-by-hand"
|
||||
assert loaded.groups[0].members[0].address == "New Address 1"
|
||||
|
||||
|
||||
class TestPlanParticipants:
|
||||
def test_participants_property_collects_everyone_once(self):
|
||||
plan = make_plan()
|
||||
uuids = [p.uuid for p in plan.participants]
|
||||
assert len(uuids) == len(set(uuids))
|
||||
member_uuids = {m.uuid for g in plan.groups for m in g.members}
|
||||
member_uuids.add(plan.after_party_group.main_member.uuid)
|
||||
assert set(uuids) == member_uuids
|
||||
@@ -73,6 +73,20 @@ class TestGetCourses:
|
||||
assert len(guests) == 3
|
||||
assert {g % 3 for g in guests} == {0, 1, 2}
|
||||
|
||||
@pytest.mark.parametrize("n", [n for n in GROUP_COUNTS if n >= 9])
|
||||
def test_no_two_groups_meet_more_than_once(self, n):
|
||||
# The whole point of the Latin-square rotation: for n >= 9 (>= 3 groups per
|
||||
# course) every pair of groups shares a table at most once across the evening.
|
||||
# (n = 3 and 6 are combinatorially impossible and deliberately excluded.)
|
||||
hosts_of = {i: set(get_courses(i, n)) for i in range(n)}
|
||||
meetings = Counter()
|
||||
for host in range(n):
|
||||
guests = sorted(g for g in range(n) if host in hosts_of[g])
|
||||
for a, b in itertools.combinations(guests, 2):
|
||||
meetings[(a, b)] += 1
|
||||
repeats = {pair: c for pair, c in meetings.items() if c > 1}
|
||||
assert repeats == {}, f"pairs meeting more than once: {repeats}"
|
||||
|
||||
|
||||
class TestFastTotalTime:
|
||||
def test_matches_hand_computed_value_n3(self):
|
||||
|
||||
@@ -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
|
||||
@@ -2,6 +2,15 @@ version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.13"
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "certifi"
|
||||
version = "2025.1.31"
|
||||
@@ -11,6 +20,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 +96,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 +259,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 +326,107 @@ 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 = "pydantic"
|
||||
version = "2.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-core"
|
||||
version = "2.46.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.20.0"
|
||||
@@ -244,6 +497,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,9 +549,11 @@ name = "tatami"
|
||||
version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "gspread" },
|
||||
{ name = "numpy" },
|
||||
{ name = "pandas" },
|
||||
{ name = "pandas-stubs" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "requests" },
|
||||
{ name = "tqdm" },
|
||||
@@ -302,9 +570,11 @@ 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" },
|
||||
{ name = "pydantic", specifier = ">=2.13.4" },
|
||||
{ name = "python-dotenv", specifier = ">=1.2.2" },
|
||||
{ name = "requests", specifier = ">=2.32.3" },
|
||||
{ name = "tqdm", specifier = ">=4.67.1" },
|
||||
@@ -366,11 +636,23 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.13.2"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967, upload-time = "2025-04-10T14:19:05.416Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806, upload-time = "2025-04-10T14:19:03.967Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user