Move Participant/Group to pydantic and add a saveable/editable Plan model

Participant and Group are now pydantic BaseModels. Group.hosts can form
cycles between groups, so it's kept as a private, non-persisted live list
(set via set_hosts()/add_host()) backed by a serializable host_uuids field,
re-linked via resolve_hosts() after a reload.

A new Plan model (src/tatami/plan.py) bundles groups, the after-party group,
and the event config (course_times, organizer_contacts, info_text,
spreadsheet_id) and supports save()/load() to/from JSON. tatami_masterplan's
__main__ now saves to masterplan.json (PLAN_FILE env var to override) on
first run and loads it on later runs instead of recomputing, so the plan can
be hand-edited (move a member between groups, change a course, fill in
spreadsheet_id) and picked up on rerun without hitting the Routes API again.
spreadsheet_id moves out of .env (GOOGLE_SHEETS_SPREADSHEET_ID) onto the plan
itself, since it's part of the plan rather than a secret.
This commit is contained in:
2026-06-19 16:07:47 +02:00
parent 91dc7729f9
commit 6b0fca0100
12 changed files with 538 additions and 157 deletions
+5 -5
View File
@@ -4,9 +4,9 @@ GOOGLE_MAPS_API_KEY=your-api-key-here
# Optional: export the masterplan to a shared Google Sheet for participants.
# Set up once: create a Google Cloud service account, enable the Google
# Sheets API for its project, download the service account's JSON key, then
# create a blank Google Sheet and share it with the service account's
# client_email (found in the JSON key) as Editor. Leave both unset to skip
# sheet export entirely.
# 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
GOOGLE_SHEETS_SPREADSHEET_ID=your-spreadsheet-id-here
+11 -7
View File
@@ -13,14 +13,13 @@ The actively developed package is `src/tatami/`. `running_dinner/running_dinner.
This project uses `uv` for dependency management (Python >=3.13).
- Install deps: `uv sync`
- Run the masterplan script: `uv run python -m tatami.tatami_masterplan` (requires `test-config.csv` in the working directory and `GOOGLE_MAPS_API_KEY` set; optionally set `GOOGLE_SHEETS_CREDENTIALS_FILE` + `GOOGLE_SHEETS_SPREADSHEET_ID` to also export to a shared Google Sheet — see `.env.example`)
- 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.
There is currently no test suite in the repo.
## Architecture
The pipeline (see `src/tatami/tatami_masterplan.py` `__main__` block) is:
@@ -32,13 +31,18 @@ The pipeline (see `src/tatami/tatami_masterplan.py` `__main__` block) is:
- Reduces the full distance matrix to just host-to-host distances (`reduce_distance_matrix`), adding each host's kitchen-size penalty into their row.
- Runs `run_simulated_annealing` / `simulated_annealing` (Boltzmann-style annealing over `itertools.permutations` of group order — note this is brute-force over all permutations per iteration, so it only scales to a small number of groups) to find a low-travel-time ordering of groups.
- `assign_courses` assigns each group a course (starter/main/dessert cycling) and, via `get_courses`, determines which other groups host it for each course (offsets of `+1` and `-4` mod total groups — this fixed relationship is what defines the dinner-rotation topology).
4. `get_masterplan` returns two lists of plain dicts (`group.dict()`, `participant.dict()`) suitable for serialization; `compute_masterplan_groups` returns the live `Group`/`Participant` objects, which is what the sheet export step needs (`.hosts`, `.get_guests(...)`).
5. **Export to Google Sheets (optional)** — if `GOOGLE_SHEETS_SPREADSHEET_ID` is set, `__main__` calls `sheets_export.export_masterplan_to_sheet` to populate a pre-existing, pre-shared spreadsheet with an Overview tab and one tab per group. This is opt-in and never sends anything directly to participants — the organizer still shares the sheet link manually.
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`)
### 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 (010, used as a "willingness/suitability to host" proxy via `get_penalty`, which adds travel-time-equivalent minutes for smaller kitchens), and allergies.
- `Group`: a hosting unit with a `main_member` (used as the group's representative location for all distance lookups other members' addresses are not used for travel calculations), a `course`, and a `hosts` list (the groups that host *this* group across the evening, kept sorted starter→main→dessert via `sort_hosts`). `get_total_time` sums travel + penalty across this group's full route (its hosts, then the after-party).
- `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`)
+67 -37
View File
@@ -79,9 +79,9 @@ gitignored, so your secret never gets committed. An alreadyexported
> The package raises at import time if no key is found, so `GOOGLE_MAPS_API_KEY`
> must be set (even to a dummy value) just to import `tatami.traveltimes`.
Optionally, also set `GOOGLE_SHEETS_CREDENTIALS_FILE` and
`GOOGLE_SHEETS_SPREADSHEET_ID` in `.env` to export the plan to a shared
Google Sheet — see [Sharing the plan with participants](#sharing-the-plan-with-participants).
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
@@ -111,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 afterparty 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 afterparty 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")
@@ -130,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
{
@@ -157,13 +173,20 @@ 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
`get_masterplan`'s dicts are great for code, but participants need something
readable. `compute_masterplan_groups` (the same computation, returning live
`Group`/`Participant` objects instead of dicts) feeds an optional Google
Sheets export — the same kind of shared spreadsheet organizers have used in
previous years, just generated automatically instead of by hand.
`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:
@@ -172,12 +195,15 @@ Set up once:
2. Download the service account's JSON key and point
`GOOGLE_SHEETS_CREDENTIALS_FILE` at it (in `.env`).
3. Create a blank Google Sheet, share it with the service account's
`client_email` (from the JSON key) as **Editor**, and set
`GOOGLE_SHEETS_SPREADSHEET_ID` to that sheet's ID (in `.env`).
`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 both set, running `uv run python -m tatami.tatami_masterplan` populates
that spreadsheet with an **Overview** tab (every participant, their group,
course, address, phone, allergies — followed by a Meal Times table, a Support
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
@@ -187,9 +213,9 @@ run are deleted.
Tatami never contacts participants directly — sharing the sheet's link is
still up to the organizer, exactly as before.
The three extra Overview sections are plain configuration, passed straight
through to the sheet with no logic in between — edit these in
`tatami_masterplan.py`:
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 = {
@@ -199,11 +225,13 @@ ORGANIZER_CONTACTS = [("Lars (Organizer)", "0151-23456789")] # -> "Support Cont
INFO_TEXT = "Welcome to the running dinner! ..." # -> "Info" block (one row per line)
```
`organizer_contacts` and `info_text` are optional (`None`/empty skips that
section); `course_times` is also reused for each group's own route table.
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 neither variable is set, this step is skipped entirely and Tatami just
prints the plan, as before.
If `spreadsheet_id` is unset, this step is skipped entirely and Tatami just
prints a reminder to fill it in.
## What you can tweak
@@ -211,14 +239,15 @@ All knobs currently live in the source. The most useful ones:
| What | Where | Default | Effect |
|------|-------|---------|--------|
| **Afterparty address** | `tatami_masterplan.py:251` (`__main__`) | a Karlsruhe address | Where everyone ends the night; also influences host ranking. |
| **Travel mode** | `tatami_masterplan.py:254` (`mode="BICYCLE"`) | `BICYCLE` | Any Routes API `travelMode`: `BICYCLE`, `DRIVE`, `WALK`, `TWO_WHEELER`, `TRANSIT`. |
| **Course start times** | `tatami_masterplan.py` (`COURSE_TIMES`) | `18:30` / `20:00` / `22:00` / `23:30` | Fixed slot times written into the Google Sheet export; the dinner runs on a synchronized schedule, not travel-derived timing. |
| **Group sizing** | `tatami_masterplan.py:24` (`len(participants) / 6`) | 1 group per ~6 people | The divisor sets how many participants form one "coursetriple". Larger → fewer, bigger groups. |
| **Kitchensize penalty** | `classes.py:19` (`minutes=3 * (10 - kitchen_size)`) | 3 min per point | Traveltimeequivalent penalty for small kitchens. Raise the `3` to push hosting toward big kitchens. |
| **Annealing schedule** | `tatami_masterplan.py:4850` | `T=1000`, `cooling=0.99`, `iters=10000` | Optimization quality vs. runtime. More iterations / slower cooling → better routes, slower. |
| **Course names** | `tatami_masterplan.py:31` | `["starter", "main", "dessert"]` | The three courses. The 3course rotation is baked into the topology — changing the *count* needs more work (see below). |
| **Rotation topology** | `tatami_masterplan.py:172173` (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. |
| **Afterparty 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 "coursetriple". Larger → fewer, bigger groups. |
| **Kitchensize penalty** | `classes.py` (`minutes=3 * (10 - kitchen_size)`) | 3 min per point | Traveltimeequivalent penalty for small kitchens. Raise the `3` to push hosting toward big kitchens. |
| **Annealing schedule** | `tatami_masterplan.py` (`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 3course 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 routingrelevant knobs, run the test suite (`uv run pytest`) — the
@@ -291,7 +320,8 @@ 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
+1
View File
@@ -9,6 +9,7 @@ dependencies = [
"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",
+76 -36
View File
@@ -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 = (
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"
)
self.hosts: list[Group] | None = None
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]:
+44
View File
@@ -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))
+45 -18
View File
@@ -1,4 +1,7 @@
from tatami.classes import Participant, Group
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
@@ -7,6 +10,10 @@ 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 = {
@@ -36,7 +43,7 @@ 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 compute_masterplan_groups(
@@ -51,7 +58,7 @@ def compute_masterplan_groups(
)
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:
@@ -90,7 +97,7 @@ def get_masterplan(
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.
@@ -243,7 +250,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(
@@ -258,7 +265,16 @@ def load_csv_to_participants(file_path: str) -> list[Participant]:
if __name__ == "__main__":
# Example usage
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"
@@ -269,19 +285,30 @@ if __name__ == "__main__":
groups, participants = compute_masterplan_groups(
participants, distance_matrix, after_party_group
)
print([group.dict() for group in groups], [p.dict() for p in participants])
print("Masterplan generated successfully.")
spreadsheet_id = os.getenv("GOOGLE_SHEETS_SPREADSHEET_ID")
if spreadsheet_id:
client = load_sheets_client()
spreadsheet = client.open_by_key(spreadsheet_id)
export_masterplan_to_sheet(
spreadsheet,
groups,
after_party_group,
COURSE_TIMES,
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."
)
+17 -3
View File
@@ -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
View File
@@ -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,
+45 -33
View File
@@ -71,57 +71,69 @@ def _make_mock_groups() -> tuple[list[Group], Group]:
"""3 groups of 3 fictional participants with addresses around Karlsruhe."""
hosts = [
Participant(
"Anna Wagner", "Kaiserstraße 12, 76131 Karlsruhe", "0721-1000001", 9, "none"
name="Anna Wagner",
address="Kaiserstraße 12, 76131 Karlsruhe",
phone="0721-1000001",
kitchen_size=9,
allergies="none",
),
Participant(
"Jonas Becker",
"Waldstraße 5, 76133 Karlsruhe",
"0721-1000002",
7,
"lactose",
name="Jonas Becker",
address="Waldstraße 5, 76133 Karlsruhe",
phone="0721-1000002",
kitchen_size=7,
allergies="lactose",
),
Participant(
"Mira Hofmann", "Yorckstraße 22, 76185 Karlsruhe", "0721-1000003", 8, "none"
name="Mira Hofmann",
address="Yorckstraße 22, 76185 Karlsruhe",
phone="0721-1000003",
kitchen_size=8,
allergies="none",
),
]
semi_hosts = [
Participant(
"Lukas Schreiber",
"Sophienstraße 40, 76135 Karlsruhe",
"0721-1000004",
5,
"nuts",
name="Lukas Schreiber",
address="Sophienstraße 40, 76135 Karlsruhe",
phone="0721-1000004",
kitchen_size=5,
allergies="nuts",
),
Participant(
"Sophie Lindner",
"Beiertheimer Allee 18, 76137 Karlsruhe",
"0721-1000005",
6,
"none",
name="Sophie Lindner",
address="Beiertheimer Allee 18, 76137 Karlsruhe",
phone="0721-1000005",
kitchen_size=6,
allergies="none",
),
Participant(
"Tom Vogel",
"Durlacher Allee 75, 76131 Karlsruhe",
"0721-1000006",
4,
"none",
name="Tom Vogel",
address="Durlacher Allee 75, 76131 Karlsruhe",
phone="0721-1000006",
kitchen_size=4,
allergies="none",
),
Participant(
"Lea Brandt",
"Moltkestraße 30, 76133 Karlsruhe",
"0721-1000007",
3,
"gluten",
name="Lea Brandt",
address="Moltkestraße 30, 76133 Karlsruhe",
phone="0721-1000007",
kitchen_size=3,
allergies="gluten",
),
Participant(
"Felix Krause", "Adlerstraße 14, 76133 Karlsruhe", "0721-1000008", 8, "none"
name="Felix Krause",
address="Adlerstraße 14, 76133 Karlsruhe",
phone="0721-1000008",
kitchen_size=8,
allergies="none",
),
Participant(
"Nora Fink",
"Rüppurrer Straße 60, 76137 Karlsruhe",
"0721-1000009",
5,
"none",
name="Nora Fink",
address="Rüppurrer Straße 60, 76137 Karlsruhe",
phone="0721-1000009",
kitchen_size=5,
allergies="none",
),
]
+103
View File
@@ -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
Generated
+97 -3
View File
@@ -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"
@@ -347,6 +356,77 @@ 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"
@@ -473,6 +553,7 @@ dependencies = [
{ name = "numpy" },
{ name = "pandas" },
{ name = "pandas-stubs" },
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "requests" },
{ name = "tqdm" },
@@ -493,6 +574,7 @@ requires-dist = [
{ 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" },
@@ -554,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]]