diff --git a/.env.example b/.env.example index 12263d3..eaf54c9 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 33c3018..7f3529f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 (0–10, used as a "willingness/suitability to host" proxy via `get_penalty`, which adds travel-time-equivalent minutes for smaller kitchens), and allergies. -- `Group`: a hosting unit with a `main_member` (used as the group's representative location for all distance lookups — other members' addresses are not used for travel calculations), a `course`, and a `hosts` list (the groups that host *this* group across the evening, kept sorted starter→main→dessert via `sort_hosts`). `get_total_time` sums travel + penalty across this group's full route (its hosts, then the after-party). +- `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`) diff --git a/README.md b/README.md index cbe3c19..eb857ca 100644 --- a/README.md +++ b/README.md @@ -79,9 +79,9 @@ gitignored, so your secret never gets committed. An already‑exported > The package raises at import time if no key is found, so `GOOGLE_MAPS_API_KEY` > must be set (even to a dummy value) just to import `tatami.traveltimes`. -Optionally, also set `GOOGLE_SHEETS_CREDENTIALS_FILE` and -`GOOGLE_SHEETS_SPREADSHEET_ID` in `.env` to export the plan to a shared -Google Sheet — see [Sharing the plan with participants](#sharing-the-plan-with-participants). +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 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") @@ -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 | |------|-------|---------|--------| -| **After‑party address** | `tatami_masterplan.py:251` (`__main__`) | a Karlsruhe address | Where everyone ends the night; also influences host ranking. | -| **Travel mode** | `tatami_masterplan.py:254` (`mode="BICYCLE"`) | `BICYCLE` | Any Routes API `travelMode`: `BICYCLE`, `DRIVE`, `WALK`, `TWO_WHEELER`, `TRANSIT`. | -| **Course start times** | `tatami_masterplan.py` (`COURSE_TIMES`) | `18:30` / `20:00` / `22:00` / `23:30` | Fixed slot times written into the Google Sheet export; the dinner runs on a synchronized schedule, not travel-derived timing. | -| **Group sizing** | `tatami_masterplan.py:24` (`len(participants) / 6`) | 1 group per ~6 people | The divisor sets how many participants form one "course‑triple". Larger → fewer, bigger groups. | -| **Kitchen‑size penalty** | `classes.py:19` (`minutes=3 * (10 - kitchen_size)`) | 3 min per point | Travel‑time‑equivalent penalty for small kitchens. Raise the `3` to push hosting toward big kitchens. | -| **Annealing schedule** | `tatami_masterplan.py:48–50` | `T=1000`, `cooling=0.99`, `iters=10000` | Optimization quality vs. runtime. More iterations / slower cooling → better routes, slower. | -| **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 @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 6357f05..a9ee0f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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", diff --git a/src/tatami/classes.py b/src/tatami/classes.py index f4dd26a..edccb4d 100644 --- a/src/tatami/classes.py +++ b/src/tatami/classes.py @@ -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=)`` 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]: diff --git a/src/tatami/plan.py b/src/tatami/plan.py new file mode 100644 index 0000000..e4ffcac --- /dev/null +++ b/src/tatami/plan.py @@ -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)) diff --git a/src/tatami/tatami_masterplan.py b/src/tatami/tatami_masterplan.py index 80a56c5..7f00c17 100644 --- a/src/tatami/tatami_masterplan.py +++ b/src/tatami/tatami_masterplan.py @@ -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,30 +265,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" - ) - 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.") + plan_path = Path(PLAN_FILE) - 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, + 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." + ) diff --git a/src/tatami/traveltimes.py b/src/tatami/traveltimes.py index 91526b0..791fbf6 100644 --- a/src/tatami/traveltimes.py +++ b/src/tatami/traveltimes.py @@ -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", ), ] diff --git a/tests/test_classes.py b/tests/test_classes.py index 2048b73..59f6088 100644 --- a/tests/test_classes.py +++ b/tests/test_classes.py @@ -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, diff --git a/tests/test_e2e_sheets.py b/tests/test_e2e_sheets.py index 98ca619..edb78dd 100644 --- a/tests/test_e2e_sheets.py +++ b/tests/test_e2e_sheets.py @@ -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", ), ] diff --git a/tests/test_plan.py b/tests/test_plan.py new file mode 100644 index 0000000..931ce51 --- /dev/null +++ b/tests/test_plan.py @@ -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 diff --git a/uv.lock b/uv.lock index 1437487..b407319 100644 --- a/uv.lock +++ b/uv.lock @@ -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]]