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:
+5
-5
@@ -4,9 +4,9 @@ GOOGLE_MAPS_API_KEY=your-api-key-here
|
|||||||
|
|
||||||
# Optional: export the masterplan to a shared Google Sheet for participants.
|
# Optional: export the masterplan to a shared Google Sheet for participants.
|
||||||
# Set up once: create a Google Cloud service account, enable the Google
|
# 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
|
# Sheets API for its project, and download the service account's JSON key.
|
||||||
# create a blank Google Sheet and share it with the service account's
|
# Then create a blank Google Sheet, share it with the service account's
|
||||||
# client_email (found in the JSON key) as Editor. Leave both unset to skip
|
# client_email (found in the JSON key) as Editor, and set its id as
|
||||||
# sheet export entirely.
|
# `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_CREDENTIALS_FILE=service-account.json
|
||||||
GOOGLE_SHEETS_SPREADSHEET_ID=your-spreadsheet-id-here
|
|
||||||
|
|||||||
@@ -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).
|
This project uses `uv` for dependency management (Python >=3.13).
|
||||||
|
|
||||||
- Install deps: `uv sync`
|
- 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`
|
- Lint: `uv run ruff check`
|
||||||
- Format: `uv run ruff format`
|
- 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)
|
- 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.
|
- 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
|
## Architecture
|
||||||
|
|
||||||
The pipeline (see `src/tatami/tatami_masterplan.py` `__main__` block) is:
|
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.
|
- 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.
|
- 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).
|
- `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(...)`).
|
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. **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.
|
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.
|
- `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`.
|
- 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`)
|
### Travel times (`src/tatami/traveltimes.py`)
|
||||||
|
|||||||
@@ -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`
|
> 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`.
|
> must be set (even to a dummy value) just to import `tatami.traveltimes`.
|
||||||
|
|
||||||
Optionally, also set `GOOGLE_SHEETS_CREDENTIALS_FILE` and
|
Optionally, also set `GOOGLE_SHEETS_CREDENTIALS_FILE` in `.env` and a
|
||||||
`GOOGLE_SHEETS_SPREADSHEET_ID` in `.env` to export the plan to a shared
|
`spreadsheet_id` in the saved `masterplan.json` to export the plan to a
|
||||||
Google Sheet — see [Sharing the plan with participants](#sharing-the-plan-with-participants).
|
shared Google Sheet — see [Sharing the plan with participants](#sharing-the-plan-with-participants).
|
||||||
|
|
||||||
## Input: the participant CSV
|
## 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
|
uv run python -m tatami.tatami_masterplan
|
||||||
```
|
```
|
||||||
|
|
||||||
This reads `test-config.csv` from the current directory, calls the Routes API,
|
The first run reads `test-config.csv` from the current directory, calls the
|
||||||
and prints the generated masterplan. The after‑party address and travel mode are
|
Routes API, computes the masterplan, and **saves it** to `masterplan.json`
|
||||||
currently set in the `__main__` block of `tatami_masterplan.py` (see
|
(override the path with the `PLAN_FILE` env var). The after‑party address and
|
||||||
[What you can tweak](#what-you-can-tweak)).
|
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:
|
To use Tatami from your own code:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from tatami.tatami_masterplan import (
|
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.traveltimes import get_participant_distance_matrix
|
||||||
|
from tatami.plan import Plan
|
||||||
|
|
||||||
participants = load_csv_to_participants("my-participants.csv")
|
participants = load_csv_to_participants("my-participants.csv")
|
||||||
after_party = get_after_party_group("Some Street 1, 12345 City")
|
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(
|
distance_matrix = get_participant_distance_matrix(
|
||||||
[*participants, after_party.main_member], mode="BICYCLE"
|
[*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
|
## 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
|
```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;
|
> 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.
|
> 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
|
## Sharing the plan with participants
|
||||||
|
|
||||||
`get_masterplan`'s dicts are great for code, but participants need something
|
`Plan` (`src/tatami/plan.py`) bundles the computed `groups`, the
|
||||||
readable. `compute_masterplan_groups` (the same computation, returning live
|
`after_party_group`, and the event-wide configuration below into one
|
||||||
`Group`/`Participant` objects instead of dicts) feeds an optional Google
|
object that feeds an optional Google Sheets export — the same kind of shared
|
||||||
Sheets export — the same kind of shared spreadsheet organizers have used in
|
spreadsheet organizers have used in previous years, just generated
|
||||||
previous years, just generated automatically instead of by hand.
|
automatically instead of by hand.
|
||||||
|
|
||||||
Set up once:
|
Set up once:
|
||||||
|
|
||||||
@@ -172,12 +195,15 @@ Set up once:
|
|||||||
2. Download the service account's JSON key and point
|
2. Download the service account's JSON key and point
|
||||||
`GOOGLE_SHEETS_CREDENTIALS_FILE` at it (in `.env`).
|
`GOOGLE_SHEETS_CREDENTIALS_FILE` at it (in `.env`).
|
||||||
3. Create a blank Google Sheet, share it with the service account's
|
3. Create a blank Google Sheet, share it with the service account's
|
||||||
`client_email` (from the JSON key) as **Editor**, and set
|
`client_email` (from the JSON key) as **Editor**, copy its sheet ID, and
|
||||||
`GOOGLE_SHEETS_SPREADSHEET_ID` to that sheet's ID (in `.env`).
|
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
|
With `GOOGLE_SHEETS_CREDENTIALS_FILE` set and `spreadsheet_id` filled in,
|
||||||
that spreadsheet with an **Overview** tab (every participant, their group,
|
rerunning `uv run python -m tatami.tatami_masterplan` populates that
|
||||||
course, address, phone, allergies — followed by a Meal Times table, a Support
|
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
|
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
|
(their own course, route with addresses and fixed course times, and the
|
||||||
guest list — with allergies — for the course they host). Reruns are
|
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
|
Tatami never contacts participants directly — sharing the sheet's link is
|
||||||
still up to the organizer, exactly as before.
|
still up to the organizer, exactly as before.
|
||||||
|
|
||||||
The three extra Overview sections are plain configuration, passed straight
|
The three extra Overview sections are plain configuration, carried on the
|
||||||
through to the sheet with no logic in between — edit these in
|
`Plan` and passed straight through to the sheet with no logic in between —
|
||||||
`tatami_masterplan.py`:
|
set them when first building the plan, in `tatami_masterplan.py`:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
COURSE_TIMES = {
|
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)
|
INFO_TEXT = "Welcome to the running dinner! ..." # -> "Info" block (one row per line)
|
||||||
```
|
```
|
||||||
|
|
||||||
`organizer_contacts` and `info_text` are optional (`None`/empty skips that
|
or by editing `course_times` / `organizer_contacts` / `info_text` directly in
|
||||||
section); `course_times` is also reused for each group's own route table.
|
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
|
If `spreadsheet_id` is unset, this step is skipped entirely and Tatami just
|
||||||
prints the plan, as before.
|
prints a reminder to fill it in.
|
||||||
|
|
||||||
## What you can tweak
|
## What you can tweak
|
||||||
|
|
||||||
@@ -211,14 +239,15 @@ All knobs currently live in the source. The most useful ones:
|
|||||||
|
|
||||||
| What | Where | Default | Effect |
|
| What | Where | Default | Effect |
|
||||||
|------|-------|---------|--------|
|
|------|-------|---------|--------|
|
||||||
| **After‑party address** | `tatami_masterplan.py:251` (`__main__`) | a Karlsruhe address | Where everyone ends the night; also influences host ranking. |
|
| **Saved plan path** | `PLAN_FILE` env var | `masterplan.json` | Where the computed/edited `Plan` is saved to and (on the next run) loaded from. |
|
||||||
| **Travel mode** | `tatami_masterplan.py:254` (`mode="BICYCLE"`) | `BICYCLE` | Any Routes API `travelMode`: `BICYCLE`, `DRIVE`, `WALK`, `TWO_WHEELER`, `TRANSIT`. |
|
| **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. |
|
||||||
| **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. |
|
| **Travel mode** | `tatami_masterplan.py` (`mode="BICYCLE"`) | `BICYCLE` | Any Routes API `travelMode`: `BICYCLE`, `DRIVE`, `WALK`, `TWO_WHEELER`, `TRANSIT`. |
|
||||||
| **Group sizing** | `tatami_masterplan.py:24` (`len(participants) / 6`) | 1 group per ~6 people | The divisor sets how many participants form one "course‑triple". Larger → fewer, bigger groups. |
|
| **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. |
|
||||||
| **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. |
|
| **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. |
|
||||||
| **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. |
|
| **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. |
|
||||||
| **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). |
|
| **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. |
|
||||||
| **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. |
|
| **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"`). |
|
| **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
|
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/
|
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
|
traveltimes.py # Google Routes API wrapper + matrix helpers
|
||||||
sheets_export.py # optional Google Sheets export for participants
|
sheets_export.py # optional Google Sheets export for participants
|
||||||
tatami_masterplan.py # pipeline: load → fetch → group → optimize → assign
|
tatami_masterplan.py # pipeline: load → fetch → group → optimize → assign
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ dependencies = [
|
|||||||
"numpy>=2.2.4",
|
"numpy>=2.2.4",
|
||||||
"pandas>=2.2.3",
|
"pandas>=2.2.3",
|
||||||
"pandas-stubs>=2.2.3.250308",
|
"pandas-stubs>=2.2.3.250308",
|
||||||
|
"pydantic>=2.13.4",
|
||||||
"python-dotenv>=1.2.2",
|
"python-dotenv>=1.2.2",
|
||||||
"requests>=2.32.3",
|
"requests>=2.32.3",
|
||||||
"tqdm>=4.67.1",
|
"tqdm>=4.67.1",
|
||||||
|
|||||||
+76
-36
@@ -1,19 +1,21 @@
|
|||||||
import datetime as dt
|
import datetime as dt
|
||||||
import pandas as pd
|
from typing import Any, Literal, cast
|
||||||
from typing import cast
|
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
from pydantic import BaseModel, Field, PrivateAttr, model_validator
|
||||||
|
|
||||||
class Participant:
|
Course = Literal["starter", "main", "dessert"]
|
||||||
def __init__(
|
_COURSE_ORDER: dict[str, int] = {"starter": 0, "main": 1, "dessert": 2}
|
||||||
self, name: str, address: str, phone: str, kitchen_size: float, allergies: str
|
|
||||||
):
|
|
||||||
self.uuid = str(uuid4())
|
class Participant(BaseModel):
|
||||||
self.name = name
|
uuid: str = Field(default_factory=lambda: str(uuid4()))
|
||||||
self.address = address
|
name: str
|
||||||
self.phone = phone
|
address: str
|
||||||
self.kitchen_size = kitchen_size # bigger is better; range 0-10
|
phone: str
|
||||||
self.allergies = allergies
|
kitchen_size: float
|
||||||
|
allergies: str
|
||||||
|
|
||||||
def get_penalty(self) -> dt.timedelta:
|
def get_penalty(self) -> dt.timedelta:
|
||||||
return dt.timedelta(minutes=3 * (10 - self.kitchen_size))
|
return dt.timedelta(minutes=3 * (10 - self.kitchen_size))
|
||||||
@@ -37,7 +39,7 @@ class Participant:
|
|||||||
def __str__(self) -> str:
|
def __str__(self) -> str:
|
||||||
return self.__repr__()
|
return self.__repr__()
|
||||||
|
|
||||||
def dict(self) -> dict:
|
def dict(self) -> dict: # type: ignore[override]
|
||||||
return {
|
return {
|
||||||
"uuid": self.uuid,
|
"uuid": self.uuid,
|
||||||
"name": self.name,
|
"name": self.name,
|
||||||
@@ -48,39 +50,77 @@ class Participant:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class Group:
|
class Group(BaseModel):
|
||||||
def __init__(self, members: list[Participant], main_member: int = 0):
|
uuid: str = Field(default_factory=lambda: "Group_" + str(uuid4()))
|
||||||
self.uuid = "Group_" + str(uuid4())
|
members: list[Participant]
|
||||||
self.members = members
|
# The persisted reference to one of `members`. `hosts`/`host_uuids` reference
|
||||||
self.main_member = members[main_member]
|
# *other* Groups, which can form cycles (a group's hosts can host it back) -
|
||||||
self.course: str | None = (
|
# 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"
|
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
|
self.course = course
|
||||||
|
|
||||||
def set_hosts(self, hosts: list["Group"]):
|
def set_hosts(self, hosts: list["Group"]) -> None:
|
||||||
self.hosts = hosts
|
self._hosts = hosts
|
||||||
self.sort_hosts()
|
self.sort_hosts()
|
||||||
|
|
||||||
def sort_hosts(self):
|
def sort_hosts(self) -> None:
|
||||||
if self.hosts is None:
|
if self._hosts is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
courses = {"starter": 0, "main": 1, "dessert": 2}
|
self._hosts.sort(
|
||||||
self.hosts.sort(key=lambda x: courses[x.course] if x.course in courses else 3)
|
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"):
|
def add_host(self, host: "Group") -> None:
|
||||||
if self.hosts is None:
|
if self._hosts is None:
|
||||||
self.hosts = []
|
self._hosts = []
|
||||||
self.hosts.append(host)
|
self._hosts.append(host)
|
||||||
self.sort_hosts()
|
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:
|
if main_member:
|
||||||
self.main_member = member
|
self.main_member_uuid = member.uuid
|
||||||
self.members.append(member)
|
self.members.append(member)
|
||||||
|
|
||||||
def get_total_time(
|
def get_total_time(
|
||||||
@@ -106,13 +146,13 @@ class Group:
|
|||||||
|
|
||||||
return total_time
|
return total_time
|
||||||
|
|
||||||
def dict(self) -> dict:
|
def dict(self) -> dict: # type: ignore[override]
|
||||||
return {
|
return {
|
||||||
"uuid": self.uuid,
|
"uuid": self.uuid,
|
||||||
"members": [member.uuid for member in self.members],
|
"members": [member.uuid for member in self.members],
|
||||||
"main_member": self.main_member.uuid,
|
"main_member": self.main_member_uuid,
|
||||||
"course": self.course,
|
"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]:
|
def get_guests(self, groups: list["Group"]) -> list[Participant]:
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from tatami.classes import Group, Participant
|
||||||
|
|
||||||
|
|
||||||
|
class Plan(BaseModel):
|
||||||
|
"""The full, persistable state of one running dinner.
|
||||||
|
|
||||||
|
Bundles the computed groups together with the event-wide configuration
|
||||||
|
(course times, organizer contacts, info text, the Sheets spreadsheet id)
|
||||||
|
so the whole thing can be saved to a JSON file, hand-edited (swap a
|
||||||
|
member between groups, change a course, fill in `spreadsheet_id`, ...),
|
||||||
|
and reloaded without recomputing routes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
groups: list[Group]
|
||||||
|
after_party_group: Group
|
||||||
|
course_times: dict[str, str] = Field(default_factory=dict)
|
||||||
|
organizer_contacts: list[tuple[str, str]] = Field(default_factory=list)
|
||||||
|
info_text: str | None = None
|
||||||
|
spreadsheet_id: str | None = None
|
||||||
|
|
||||||
|
def model_post_init(self, __context: object) -> None:
|
||||||
|
groups_by_uuid = {g.uuid: g for g in [*self.groups, self.after_party_group]}
|
||||||
|
for group in self.groups:
|
||||||
|
group.resolve_hosts(groups_by_uuid)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def participants(self) -> list[Participant]:
|
||||||
|
"""Every participant across all groups (including the after party), deduplicated."""
|
||||||
|
by_uuid: dict[str, Participant] = {}
|
||||||
|
for group in [*self.groups, self.after_party_group]:
|
||||||
|
for member in group.members:
|
||||||
|
by_uuid[member.uuid] = member
|
||||||
|
return list(by_uuid.values())
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def load(cls, path: str | Path) -> "Plan":
|
||||||
|
return cls.model_validate_json(Path(path).read_text())
|
||||||
|
|
||||||
|
def save(self, path: str | Path) -> None:
|
||||||
|
Path(path).write_text(self.model_dump_json(indent=2))
|
||||||
@@ -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.traveltimes import reduce_distance_matrix, get_participant_distance_matrix
|
||||||
from tatami.sheets_export import load_sheets_client, export_masterplan_to_sheet
|
from tatami.sheets_export import load_sheets_client, export_masterplan_to_sheet
|
||||||
import pandas as pd
|
import pandas as pd
|
||||||
@@ -7,6 +10,10 @@ import random
|
|||||||
import os
|
import os
|
||||||
from tqdm import tqdm
|
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
|
# Courses run on a synchronized schedule shared by every group, so start times
|
||||||
# are fixed slots rather than derived from travel-time optimization.
|
# are fixed slots rather than derived from travel-time optimization.
|
||||||
COURSE_TIMES = {
|
COURSE_TIMES = {
|
||||||
@@ -36,7 +43,7 @@ def get_after_party_group(address: str) -> Group:
|
|||||||
participant = Participant(
|
participant = Participant(
|
||||||
name="After Party", address=address, phone="", kitchen_size=10, allergies=""
|
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(
|
def compute_masterplan_groups(
|
||||||
@@ -51,7 +58,7 @@ def compute_masterplan_groups(
|
|||||||
)
|
)
|
||||||
hosts = participants[: 3 * groups_per_course]
|
hosts = participants[: 3 * groups_per_course]
|
||||||
semi_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 = []
|
groups = []
|
||||||
for host in hosts:
|
for host in hosts:
|
||||||
@@ -90,7 +97,7 @@ def get_masterplan(
|
|||||||
return group_dicts, participant_dicts
|
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.
|
Assign courses to groups.
|
||||||
|
|
||||||
@@ -243,7 +250,7 @@ def load_csv_to_participants(file_path: str) -> list[Participant]:
|
|||||||
"""
|
"""
|
||||||
Load participants from a CSV file.
|
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 = []
|
participants = []
|
||||||
for _, row in df.iterrows():
|
for _, row in df.iterrows():
|
||||||
participant = Participant(
|
participant = Participant(
|
||||||
@@ -258,7 +265,16 @@ def load_csv_to_participants(file_path: str) -> list[Participant]:
|
|||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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")
|
participants = load_csv_to_participants("test-config.csv")
|
||||||
after_party_group = get_after_party_group(
|
after_party_group = get_after_party_group(
|
||||||
"Sebastian-Kneipp-Straße 6, 76131 Karlsruhe"
|
"Sebastian-Kneipp-Straße 6, 76131 Karlsruhe"
|
||||||
@@ -269,19 +285,30 @@ if __name__ == "__main__":
|
|||||||
groups, participants = compute_masterplan_groups(
|
groups, participants = compute_masterplan_groups(
|
||||||
participants, distance_matrix, after_party_group
|
participants, distance_matrix, after_party_group
|
||||||
)
|
)
|
||||||
print([group.dict() for group in groups], [p.dict() for p in participants])
|
plan = Plan(
|
||||||
print("Masterplan generated successfully.")
|
groups=groups,
|
||||||
|
after_party_group=after_party_group,
|
||||||
spreadsheet_id = os.getenv("GOOGLE_SHEETS_SPREADSHEET_ID")
|
course_times=COURSE_TIMES,
|
||||||
if spreadsheet_id:
|
|
||||||
client = load_sheets_client()
|
|
||||||
spreadsheet = client.open_by_key(spreadsheet_id)
|
|
||||||
export_masterplan_to_sheet(
|
|
||||||
spreadsheet,
|
|
||||||
groups,
|
|
||||||
after_party_group,
|
|
||||||
COURSE_TIMES,
|
|
||||||
organizer_contacts=ORGANIZER_CONTACTS,
|
organizer_contacts=ORGANIZER_CONTACTS,
|
||||||
info_text=INFO_TEXT,
|
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}")
|
print(f"Masterplan exported to Google Sheet: {spreadsheet.url}")
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"No spreadsheet_id set in {plan_path}; skipping Sheets export. "
|
||||||
|
"Edit the file to add one and rerun."
|
||||||
|
)
|
||||||
|
|||||||
@@ -96,12 +96,26 @@ def reduce_distance_matrix(
|
|||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
# Example usage
|
# Example usage
|
||||||
participants = [
|
participants = [
|
||||||
Participant("Alice", "Römerstr. 12 76189 Karlsruhe", "555-1234", 8.0, "None"),
|
|
||||||
Participant(
|
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(
|
Participant(
|
||||||
"Charlie", "Hermann-Hesse-Str 50 76189 Karlsruhe", "555-8765", 9.0, "None"
|
name="Bob",
|
||||||
|
address="Gottesauerstr. 30 76131 Karlsruhe",
|
||||||
|
phone="555-5678",
|
||||||
|
kitchen_size=7.5,
|
||||||
|
allergies="Peanuts",
|
||||||
|
),
|
||||||
|
Participant(
|
||||||
|
name="Charlie",
|
||||||
|
address="Hermann-Hesse-Str 50 76189 Karlsruhe",
|
||||||
|
phone="555-8765",
|
||||||
|
kitchen_size=9.0,
|
||||||
|
allergies="None",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
+16
-4
@@ -11,8 +11,12 @@ from conftest import make_participants, make_timedelta_matrix
|
|||||||
|
|
||||||
class TestParticipant:
|
class TestParticipant:
|
||||||
def test_uuid_is_unique(self):
|
def test_uuid_is_unique(self):
|
||||||
a = Participant("A", "addr", "", 5, "")
|
a = Participant(
|
||||||
b = Participant("A", "addr", "", 5, "")
|
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
|
assert a.uuid != b.uuid
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -20,7 +24,9 @@ class TestParticipant:
|
|||||||
[(10, 0), (7, 9), (0, 30), (5, 15)],
|
[(10, 0), (7, 9), (0, 30), (5, 15)],
|
||||||
)
|
)
|
||||||
def test_penalty_scales_with_kitchen_size(self, kitchen_size, minutes):
|
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)
|
assert p.get_penalty() == dt.timedelta(minutes=minutes)
|
||||||
|
|
||||||
def test_after_party_time_combines_penalty_and_travel(self):
|
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)
|
assert result == dt.timedelta(minutes=9) + dt.timedelta(seconds=600)
|
||||||
|
|
||||||
def test_dict_roundtrip_fields(self):
|
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()
|
d = p.dict()
|
||||||
assert d == {
|
assert d == {
|
||||||
"uuid": p.uuid,
|
"uuid": p.uuid,
|
||||||
|
|||||||
+45
-33
@@ -71,57 +71,69 @@ def _make_mock_groups() -> tuple[list[Group], Group]:
|
|||||||
"""3 groups of 3 fictional participants with addresses around Karlsruhe."""
|
"""3 groups of 3 fictional participants with addresses around Karlsruhe."""
|
||||||
hosts = [
|
hosts = [
|
||||||
Participant(
|
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(
|
Participant(
|
||||||
"Jonas Becker",
|
name="Jonas Becker",
|
||||||
"Waldstraße 5, 76133 Karlsruhe",
|
address="Waldstraße 5, 76133 Karlsruhe",
|
||||||
"0721-1000002",
|
phone="0721-1000002",
|
||||||
7,
|
kitchen_size=7,
|
||||||
"lactose",
|
allergies="lactose",
|
||||||
),
|
),
|
||||||
Participant(
|
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 = [
|
semi_hosts = [
|
||||||
Participant(
|
Participant(
|
||||||
"Lukas Schreiber",
|
name="Lukas Schreiber",
|
||||||
"Sophienstraße 40, 76135 Karlsruhe",
|
address="Sophienstraße 40, 76135 Karlsruhe",
|
||||||
"0721-1000004",
|
phone="0721-1000004",
|
||||||
5,
|
kitchen_size=5,
|
||||||
"nuts",
|
allergies="nuts",
|
||||||
),
|
),
|
||||||
Participant(
|
Participant(
|
||||||
"Sophie Lindner",
|
name="Sophie Lindner",
|
||||||
"Beiertheimer Allee 18, 76137 Karlsruhe",
|
address="Beiertheimer Allee 18, 76137 Karlsruhe",
|
||||||
"0721-1000005",
|
phone="0721-1000005",
|
||||||
6,
|
kitchen_size=6,
|
||||||
"none",
|
allergies="none",
|
||||||
),
|
),
|
||||||
Participant(
|
Participant(
|
||||||
"Tom Vogel",
|
name="Tom Vogel",
|
||||||
"Durlacher Allee 75, 76131 Karlsruhe",
|
address="Durlacher Allee 75, 76131 Karlsruhe",
|
||||||
"0721-1000006",
|
phone="0721-1000006",
|
||||||
4,
|
kitchen_size=4,
|
||||||
"none",
|
allergies="none",
|
||||||
),
|
),
|
||||||
Participant(
|
Participant(
|
||||||
"Lea Brandt",
|
name="Lea Brandt",
|
||||||
"Moltkestraße 30, 76133 Karlsruhe",
|
address="Moltkestraße 30, 76133 Karlsruhe",
|
||||||
"0721-1000007",
|
phone="0721-1000007",
|
||||||
3,
|
kitchen_size=3,
|
||||||
"gluten",
|
allergies="gluten",
|
||||||
),
|
),
|
||||||
Participant(
|
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(
|
Participant(
|
||||||
"Nora Fink",
|
name="Nora Fink",
|
||||||
"Rüppurrer Straße 60, 76137 Karlsruhe",
|
address="Rüppurrer Straße 60, 76137 Karlsruhe",
|
||||||
"0721-1000009",
|
phone="0721-1000009",
|
||||||
5,
|
kitchen_size=5,
|
||||||
"none",
|
allergies="none",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -2,6 +2,15 @@ version = 1
|
|||||||
revision = 3
|
revision = 3
|
||||||
requires-python = ">=3.13"
|
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]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2025.1.31"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pygments"
|
name = "pygments"
|
||||||
version = "2.20.0"
|
version = "2.20.0"
|
||||||
@@ -473,6 +553,7 @@ dependencies = [
|
|||||||
{ name = "numpy" },
|
{ name = "numpy" },
|
||||||
{ name = "pandas" },
|
{ name = "pandas" },
|
||||||
{ name = "pandas-stubs" },
|
{ name = "pandas-stubs" },
|
||||||
|
{ name = "pydantic" },
|
||||||
{ name = "python-dotenv" },
|
{ name = "python-dotenv" },
|
||||||
{ name = "requests" },
|
{ name = "requests" },
|
||||||
{ name = "tqdm" },
|
{ name = "tqdm" },
|
||||||
@@ -493,6 +574,7 @@ requires-dist = [
|
|||||||
{ name = "numpy", specifier = ">=2.2.4" },
|
{ name = "numpy", specifier = ">=2.2.4" },
|
||||||
{ name = "pandas", specifier = ">=2.2.3" },
|
{ name = "pandas", specifier = ">=2.2.3" },
|
||||||
{ name = "pandas-stubs", specifier = ">=2.2.3.250308" },
|
{ name = "pandas-stubs", specifier = ">=2.2.3.250308" },
|
||||||
|
{ name = "pydantic", specifier = ">=2.13.4" },
|
||||||
{ name = "python-dotenv", specifier = ">=1.2.2" },
|
{ name = "python-dotenv", specifier = ">=1.2.2" },
|
||||||
{ name = "requests", specifier = ">=2.32.3" },
|
{ name = "requests", specifier = ">=2.32.3" },
|
||||||
{ name = "tqdm", specifier = ">=4.67.1" },
|
{ name = "tqdm", specifier = ">=4.67.1" },
|
||||||
@@ -554,11 +636,23 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.13.2"
|
version = "4.15.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
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 = [
|
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]]
|
[[package]]
|
||||||
|
|||||||
Reference in New Issue
Block a user