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

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

A new Plan model (src/tatami/plan.py) bundles groups, the after-party group,
and the event config (course_times, organizer_contacts, info_text,
spreadsheet_id) and supports save()/load() to/from JSON. tatami_masterplan's
__main__ now saves to masterplan.json (PLAN_FILE env var to override) on
first run and loads it on later runs instead of recomputing, so the plan can
be hand-edited (move a member between groups, change a course, fill in
spreadsheet_id) and picked up on rerun without hitting the Routes API again.
spreadsheet_id moves out of .env (GOOGLE_SHEETS_SPREADSHEET_ID) onto the plan
itself, since it's part of the plan rather than a secret.
This commit is contained in:
2026-06-19 16:07:47 +02:00
parent 91dc7729f9
commit 6b0fca0100
12 changed files with 538 additions and 157 deletions
+5 -5
View File
@@ -4,9 +4,9 @@ GOOGLE_MAPS_API_KEY=your-api-key-here
# Optional: export the masterplan to a shared Google Sheet for participants. # 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
+11 -7
View File
@@ -13,14 +13,13 @@ The actively developed package is `src/tatami/`. `running_dinner/running_dinner.
This project uses `uv` for dependency management (Python >=3.13). 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 (010, 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 (010, used as a "willingness/suitability to host" proxy via `get_penalty`, which adds travel-time-equivalent minutes for smaller kitchens), and allergies.
- `Group`: a hosting unit with a `main_member` (used as the group's representative location for all distance lookups other members' addresses are not used for travel calculations), a `course`, and a `hosts` list (the groups that host *this* group across the evening, kept sorted starter→main→dessert via `sort_hosts`). `get_total_time` sums travel + penalty across this group's full route (its hosts, then the after-party). - `Group`: a hosting unit with a `main_member` (a property resolved from `main_member_uuid` against `members`used as the group's representative location for all distance lookups; other members' addresses are not used for travel calculations), a `course`, and a `hosts` property (the groups that host *this* group across the evening, kept sorted starter→main→dessert via `sort_hosts`). `get_total_time` sums travel + penalty across this group's full route (its hosts, then the after-party).
- `hosts` references *other* `Group`s and these references are genuinely cyclic (a group's hosts can host it back), so they can't be embedded directly in JSON. The live `hosts` list is a private, non-persisted attribute set via `set_hosts()`/`add_host()`; the persisted field is `host_uuids` (kept in sync automatically). After `Plan.load()`, `Group.resolve_hosts()` re-links `hosts` from `host_uuids` against the sibling groups in the same `Plan` — call it yourself if you ever construct `Group`s outside of a `Plan` and need `.hosts` populated from `host_uuids`.
- `Plan` (`plan.py`): bundles `groups`, `after_party_group`, and the event-wide config (`course_times`, `organizer_contacts`, `info_text`, `spreadsheet_id`). `Plan.save(path)` / `Plan.load(path)` round-trip the whole thing to/from JSON; `plan.participants` is a derived property (flattened, deduplicated `group.members` across all groups + the after party), not a separately stored field, so editing a participant's data in a group's `members` is the single source of truth.
- Distance/time lookups throughout the codebase are keyed by `Participant.uuid` (host groups are addressed via `main_member.uuid`), not by name — when adding new matrix operations, index by uuid for consistency with `traveltimes.py` and `classes.py`. - 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`)
+67 -37
View File
@@ -79,9 +79,9 @@ gitignored, so your secret never gets committed. An alreadyexported
> The package raises at import time if no key is found, so `GOOGLE_MAPS_API_KEY` > 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 afterparty 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 afterparty 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 |
|------|-------|---------|--------| |------|-------|---------|--------|
| **Afterparty 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`. | | **Afterparty address** | `tatami_masterplan.py` (`__main__`) | a Karlsruhe address | Where everyone ends the night; also influences host ranking. Only used the first time a plan is computed. |
| **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 "coursetriple". 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. |
| **Kitchensize penalty** | `classes.py:19` (`minutes=3 * (10 - kitchen_size)`) | 3 min per point | Traveltimeequivalent penalty for small kitchens. Raise the `3` to push hosting toward big kitchens. | | **Group sizing** | `tatami_masterplan.py:54` (`len(participants) / 6`) | 1 group per ~6 people | The divisor sets how many participants form one "coursetriple". Larger → fewer, bigger groups. |
| **Annealing schedule** | `tatami_masterplan.py:4850` | `T=1000`, `cooling=0.99`, `iters=10000` | Optimization quality vs. runtime. More iterations / slower cooling → better routes, slower. | | **Kitchensize penalty** | `classes.py` (`minutes=3 * (10 - kitchen_size)`) | 3 min per point | Traveltimeequivalent penalty for small kitchens. Raise the `3` to push hosting toward big kitchens. |
| **Course names** | `tatami_masterplan.py:31` | `["starter", "main", "dessert"]` | The three courses. The 3course rotation is baked into the topology — changing the *count* needs more work (see below). | | **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:172173` (offsets `+1`, `-4`) | — | Defines who hosts whom. Changing these changes who meets whom; keep the invariant that each group's three hosts cover all three courses. | | **Course names** | `tatami_masterplan.py` (`courses = [...]`) | `["starter", "main", "dessert"]` | The three courses. The 3course rotation is baked into the topology — changing the *count* needs more work (see below). |
| **Rotation topology** | `tatami_masterplan.py` `get_courses` (offsets `+1`, `-4`) | — | Defines who hosts whom. Changing these changes who meets whom; keep the invariant that each group's three hosts cover all three courses. |
| **Distance vs. duration** | `traveltimes.py` `get_distance_matrix(value=…)` | `"duration"` | Optimize on travel **time** (`"duration"`) or **distance** (`"distanceMeters"`). | | **Distance vs. duration** | `traveltimes.py` `get_distance_matrix(value=…)` | `"duration"` | Optimize on travel **time** (`"duration"`) or **distance** (`"distanceMeters"`). |
After changing routingrelevant knobs, run the test suite (`uv run pytest`) — the After changing routingrelevant knobs, run the test suite (`uv run pytest`) — the
@@ -291,7 +320,8 @@ uv run pytest -m e2e # opt-in live test that calls the real Routes API
``` ```
src/tatami/ 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
+1
View File
@@ -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
View File
@@ -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]:
+44
View File
@@ -0,0 +1,44 @@
from pathlib import Path
from pydantic import BaseModel, Field
from tatami.classes import Group, Participant
class Plan(BaseModel):
"""The full, persistable state of one running dinner.
Bundles the computed groups together with the event-wide configuration
(course times, organizer contacts, info text, the Sheets spreadsheet id)
so the whole thing can be saved to a JSON file, hand-edited (swap a
member between groups, change a course, fill in `spreadsheet_id`, ...),
and reloaded without recomputing routes.
"""
groups: list[Group]
after_party_group: Group
course_times: dict[str, str] = Field(default_factory=dict)
organizer_contacts: list[tuple[str, str]] = Field(default_factory=list)
info_text: str | None = None
spreadsheet_id: str | None = None
def model_post_init(self, __context: object) -> None:
groups_by_uuid = {g.uuid: g for g in [*self.groups, self.after_party_group]}
for group in self.groups:
group.resolve_hosts(groups_by_uuid)
@property
def participants(self) -> list[Participant]:
"""Every participant across all groups (including the after party), deduplicated."""
by_uuid: dict[str, Participant] = {}
for group in [*self.groups, self.after_party_group]:
for member in group.members:
by_uuid[member.uuid] = member
return list(by_uuid.values())
@classmethod
def load(cls, path: str | Path) -> "Plan":
return cls.model_validate_json(Path(path).read_text())
def save(self, path: str | Path) -> None:
Path(path).write_text(self.model_dump_json(indent=2))
+45 -18
View File
@@ -1,4 +1,7 @@
from tatami.classes import Participant, Group from pathlib import Path
from tatami.classes import Participant, Group, Course
from tatami.plan import Plan
from tatami.traveltimes import reduce_distance_matrix, get_participant_distance_matrix from tatami.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."
)
+17 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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",
), ),
] ]
+103
View File
@@ -0,0 +1,103 @@
"""Tests for the Plan model: saving, reloading, and editing a masterplan."""
import json
from tatami.classes import Group
from tatami.plan import Plan
from tatami.tatami_masterplan import assign_courses, get_after_party_group
from conftest import make_participants
def make_plan(n: int = 6) -> Plan:
groups = [Group(members=[p]) for p in make_participants(n)]
courses = ["starter", "main", "dessert"] * (n // 3)
assign_courses(groups, courses)
after_party = get_after_party_group("party street")
return Plan(
groups=groups,
after_party_group=after_party,
course_times={"starter": "18:30", "main": "20:00", "dessert": "22:00"},
organizer_contacts=[("Lars (Organizer)", "0151-23456789")],
info_text="Welcome!",
)
class TestPlanRoundTrip:
def test_save_and_load_preserves_groups_and_hosts(self, tmp_path):
plan = make_plan()
path = tmp_path / "masterplan.json"
plan.save(path)
loaded = Plan.load(path)
assert [g.uuid for g in loaded.groups] == [g.uuid for g in plan.groups]
for original, reloaded in zip(plan.groups, loaded.groups):
assert reloaded.course == original.course
assert [h.uuid for h in reloaded.hosts] == [h.uuid for h in original.hosts]
def test_load_resolves_live_host_objects_not_just_uuids(self, tmp_path):
plan = make_plan()
path = tmp_path / "masterplan.json"
plan.save(path)
loaded = Plan.load(path)
group = loaded.groups[0]
assert group.hosts is not None
# Hosts are live Group objects (so .course etc. is accessible), not just ids.
assert all(isinstance(h, Group) for h in group.hosts)
assert {h.course for h in group.hosts} == {"starter", "main", "dessert"}
def test_round_trip_is_idempotent(self, tmp_path):
plan = make_plan()
path = tmp_path / "masterplan.json"
plan.save(path)
first = json.loads(path.read_text())
Plan.load(path).save(path)
second = json.loads(path.read_text())
assert first == second
def test_carries_course_times_contacts_info_and_spreadsheet_id(self, tmp_path):
plan = make_plan()
plan.spreadsheet_id = "abc123"
path = tmp_path / "masterplan.json"
plan.save(path)
loaded = Plan.load(path)
assert loaded.course_times == plan.course_times
assert loaded.organizer_contacts == plan.organizer_contacts
assert loaded.info_text == plan.info_text
assert loaded.spreadsheet_id == "abc123"
def test_spreadsheet_id_defaults_to_none(self):
assert make_plan().spreadsheet_id is None
class TestPlanEditing:
def test_editing_saved_json_changes_reloaded_plan(self, tmp_path):
plan = make_plan()
path = tmp_path / "masterplan.json"
plan.save(path)
data = json.loads(path.read_text())
data["spreadsheet_id"] = "edited-by-hand"
data["groups"][0]["members"][0]["address"] = "New Address 1"
path.write_text(json.dumps(data))
loaded = Plan.load(path)
assert loaded.spreadsheet_id == "edited-by-hand"
assert loaded.groups[0].members[0].address == "New Address 1"
class TestPlanParticipants:
def test_participants_property_collects_everyone_once(self):
plan = make_plan()
uuids = [p.uuid for p in plan.participants]
assert len(uuids) == len(set(uuids))
member_uuids = {m.uuid for g in plan.groups for m in g.members}
member_uuids.add(plan.after_party_group.main_member.uuid)
assert set(uuids) == member_uuids
Generated
+97 -3
View File
@@ -2,6 +2,15 @@ version = 1
revision = 3 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]]