diff --git a/docs/usage/snapshots.md b/docs/usage/snapshots.md index bef581e..3436e4a 100644 --- a/docs/usage/snapshots.md +++ b/docs/usage/snapshots.md @@ -82,15 +82,15 @@ The comment is optional but useful for identifying the snapshot later. === "CLI" ```bash - aimbat snapshot list # for a specific event - aimbat snapshot list --all-events # across all events + aimbat snapshot list # for a specific event + aimbat snapshot list all # across all events ``` === "Shell" ```bash - snapshot list # uses the current event context - snapshot list --all-events + snapshot list # uses the current event context + snapshot list all # across all events ``` The table shows the snapshot ID, date and time, comment, and number of @@ -128,7 +128,7 @@ Before rolling back, it can be useful to see what a snapshot contains. snapshot preview --matrix ``` -`details` shows the event-level parameters (window, filter, min_ccnorm) as +`details` shows the event-level parameters (window, filter, min_cc) as they were when the snapshot was taken. `preview` builds the ICCS stack from the snapshot's parameters and displays it — without modifying anything in the database. @@ -224,15 +224,13 @@ For archiving or scripting purposes, snapshot data can be exported to JSON: === "CLI" ```bash - aimbat snapshot dump # specific event - aimbat snapshot dump --all-events # all events + aimbat snapshot dump ``` === "Shell" ```bash - snapshot dump # uses the current event context - snapshot dump --all-events + snapshot dump ``` The output is a JSON object with five keys, all cross-referenced by @@ -317,17 +315,17 @@ without opening individual snapshot records: === "CLI" ```bash - aimbat snapshot quality list # for a specific event - aimbat snapshot quality list --all-events # across all events - aimbat snapshot quality dump # raw JSON export + aimbat snapshot quality list # for a specific event + aimbat snapshot quality list all # across all events + aimbat snapshot quality dump # raw JSON export ``` === "Shell" ```bash - snapshot quality list - snapshot quality list --all-events - snapshot quality dump + snapshot quality list # for a specific event + snapshot quality list all # across all events + snapshot quality dump # raw JSON export ``` The table shows per-snapshot aggregated ICCS correlation coefficients and, where diff --git a/src/aimbat/_cli/common/_parameters.py b/src/aimbat/_cli/common/_parameters.py index 75cfaf6..32620f5 100644 --- a/src/aimbat/_cli/common/_parameters.py +++ b/src/aimbat/_cli/common/_parameters.py @@ -232,7 +232,14 @@ def open_in_editor(initial_content: str) -> str: tmp_path = tmp.name try: - subprocess.run([*shlex.split(editor), tmp_path], check=False) + result = subprocess.run([*shlex.split(editor), tmp_path], check=False) + if result.returncode != 0: + from aimbat.logger import logger + + logger.warning( + f"Editor '{editor}' exited with code {result.returncode}; discarding changes." + ) + return initial_content with open(tmp_path, encoding="utf-8") as f: return f.read() finally: diff --git a/src/aimbat/_cli/snapshot.py b/src/aimbat/_cli/snapshot.py index c8f271f..15a3ae3 100644 --- a/src/aimbat/_cli/snapshot.py +++ b/src/aimbat/_cli/snapshot.py @@ -125,7 +125,17 @@ def cli_snapshot_rollback( *, _: DebugParameter = DebugParameter(), ) -> None: - """Rollback to snapshot.""" + """Restore saved parameters from a snapshot as the current live values. + + Overwrites the current event and per-seismogram parameters for the event + with those recorded in the snapshot. Any ICCS runs or parameter changes + made after the snapshot was taken are undone. The snapshot itself is not + deleted — you can roll back to it again. + + If the snapshot has MCCC quality data, the live quality metrics are also + restored from the best matching snapshot (same parameter hash, most recent + MCCC run). + """ from sqlmodel import Session from aimbat.core import rollback_to_snapshot diff --git a/src/aimbat/_cli/tool.py b/src/aimbat/_cli/tool.py index 0c8c527..8bde6b6 100644 --- a/src/aimbat/_cli/tool.py +++ b/src/aimbat/_cli/tool.py @@ -136,7 +136,7 @@ def cli_pick_min_cc( ) -> None: """Interactively pick a new minimum cross-correlation for auto-selection. - Opens an interactive plot; click to set the cc threshold. Seismograms + Opens an interactive plot; scroll to set the cc threshold. Seismograms whose cross-correlation with the stack falls below this value will be automatically de-selected when running ICCS with `--autoselect`. """ diff --git a/src/aimbat/_tui/help/tab-project.md b/src/aimbat/_tui/help/tab-project.md index 6e86c79..13aea54 100644 --- a/src/aimbat/_tui/help/tab-project.md +++ b/src/aimbat/_tui/help/tab-project.md @@ -16,10 +16,12 @@ ICCS status: - **● ICCS ready** — the event's seismograms are loaded in memory and alignment can run. This is the normal working state. -- **○ no ICCS** — the ICCS instance could not be built. Usually this means - a parameter combination is invalid (e.g. the time window is too wide) - or a waveform file is missing. Fix the problem and the status updates - automatically. +- **○ no ICCS** — ICCS is built automatically in the background when you + select an event. If this status persists, the ICCS instance could not be + built — usually because a parameter combination is invalid or a waveform + file is missing. Press `p` to check the event parameters; the most common + cause is a time window longer than the available waveform data. Fix the + problem and the status updates automatically. ### Events table (top) diff --git a/src/aimbat/_tui/help/tab-seismograms.md b/src/aimbat/_tui/help/tab-seismograms.md index 14373c9..f8c86ce 100644 --- a/src/aimbat/_tui/help/tab-seismograms.md +++ b/src/aimbat/_tui/help/tab-seismograms.md @@ -33,10 +33,10 @@ algorithm. It cross-correlates each selected seismogram against the current stack waveform, adjusts the picks, rebuilds the stack, and repeats until convergence. Only seismograms with `Select = ✓` contribute to the stack. -The Stack CC column updates live as soon as the event is loaded — it shows -how well each seismogram matches the current stack, even before you run -alignment. After running ICCS (`a`), the picks (Δt) and Stack CC values are -updated and written to the database immediately. +The Stack CC column is recalculated each time the event is loaded or after +any parameter change — it shows how well each seismogram matches the current +stack, even before you run alignment. After running ICCS (`a`), the picks +(Δt) and Stack CC values are updated and written to the database immediately. ### Seismogram plot (right panel) diff --git a/src/aimbat/_tui/help/tab-snapshots.md b/src/aimbat/_tui/help/tab-snapshots.md index 3aaacf5..3899e16 100644 --- a/src/aimbat/_tui/help/tab-snapshots.md +++ b/src/aimbat/_tui/help/tab-snapshots.md @@ -35,8 +35,8 @@ needed. Each snapshot has its own note, which persists in the database. ## What a snapshot captures -- **Event parameters** — time window (`t0`/`t1` window bounds), bandpass - filter settings, and Min CC threshold +- **Event parameters** — time window (pre- and post-pick window lengths), + bandpass filter settings, and Min CC threshold - **Per-seismogram parameters** — the `t1` pick, `select` flag, and `flip` flag for every seismogram - **Quality metrics** — ICCS correlation coefficients per seismogram (always captured); diff --git a/src/aimbat/core/_data.py b/src/aimbat/core/_data.py index f017c97..4ed1116 100644 --- a/src/aimbat/core/_data.py +++ b/src/aimbat/core/_data.py @@ -81,6 +81,15 @@ def _create_event( logger.debug( f"Using existing event {aimbat_event.time} instead of adding new one." ) + if ( + new_aimbat_event.latitude != aimbat_event.latitude + or new_aimbat_event.longitude != aimbat_event.longitude + or new_aimbat_event.depth != aimbat_event.depth + ): + logger.warning( + f"Event at {aimbat_event.time} matched by time but has different " + f"location metadata in {datasource}. The existing record will be used." + ) return aimbat_event @@ -136,7 +145,9 @@ def _process_datasource( # Resolve event — use the provided UUID, extract from the source, or skip if event_id is not None: aimbat_event: AimbatEvent | None = session.get(AimbatEvent, event_id) - logger.debug(f"Using event {aimbat_event.time} (ID={event_id}).") # type: ignore[union-attr] + if aimbat_event is None: + raise ValueError(f"No event found with ID={event_id}.") + logger.debug(f"Using event {aimbat_event.time} (ID={event_id}).") elif supports_event_creation(datatype): aimbat_event = _create_event(session, datasource, datatype) else: @@ -148,12 +159,12 @@ def _process_datasource( # Seismogram creation requires both a station and an event to link to if aimbat_station is None: - raise NotImplementedError( + raise ValueError( f"{datatype} does not support station creation. " "Provide a station UUID via --use-station." ) if aimbat_event is None: - raise NotImplementedError( + raise ValueError( f"{datatype} does not support event creation. " "Provide an event UUID via --use-event." ) diff --git a/src/aimbat/core/_event.py b/src/aimbat/core/_event.py index 4c8e6b6..c431dfa 100644 --- a/src/aimbat/core/_event.py +++ b/src/aimbat/core/_event.py @@ -8,7 +8,7 @@ from pydantic import TypeAdapter from sqlalchemy.exc import NoResultFound from sqlalchemy.orm import selectinload -from sqlmodel import Session, select +from sqlmodel import Session, col, select from aimbat._types import EventParameter from aimbat.logger import logger @@ -51,7 +51,7 @@ def resolve_event(session: Session, event_id: UUID | None = None) -> AimbatEvent NoResultFound: If no event_id is given. """ if event_id: - logger.debug(f"Resolving event by explicit ID: {event_id}") + logger.debug(f"Resolving event by explicit ID: {event_id}.") event = session.get(AimbatEvent, event_id) if event is None: raise NoResultFound(f"No AimbatEvent found with id: {event_id}.") @@ -108,7 +108,7 @@ def get_completed_events(session: Session) -> Sequence[AimbatEvent]: statement = ( select(AimbatEvent) .join(AimbatEventParameters) - .where(AimbatEventParameters.completed == 1) + .where(col(AimbatEventParameters.completed).is_(True)) ) return session.exec(statement).all() @@ -157,7 +157,9 @@ def get_event_quality(session: Session, event_id: UUID) -> SeismogramQualityStat event_id: UUID of the event. Returns: - Aggregated seismogram quality statistics. + Aggregated seismogram quality statistics. The `mccc_rmse` field is + taken from the event-level quality record rather than the per-seismogram + records, and is `None` if MCCC has not been run. Raises: NoResultFound: If no event with the given ID is found. diff --git a/src/aimbat/core/_iccs.py b/src/aimbat/core/_iccs.py index f238f43..85aa0ef 100644 --- a/src/aimbat/core/_iccs.py +++ b/src/aimbat/core/_iccs.py @@ -209,7 +209,9 @@ def _write_mccc_quality( Upserts the event-level RMSE, clears MCCC fields for all seismograms in the ICCS instance, then writes the per-seismogram metrics for the seismograms - that were actually used in the inversion. + that were actually used in the inversion. The `iccs_cc` field is preserved + when an existing quality row is found; seismograms with no prior quality row + will have `iccs_cc = NULL` until ICCS stats are written separately. Uses its own short-lived session. @@ -343,6 +345,7 @@ def build_iccs_from_snapshot(session: Session, snapshot_id: UUID) -> BoundICCS: .selectinload(rel(AimbatEvent.seismograms)) .selectinload(rel(AimbatSeismogram.parameters)), selectinload(rel(AimbatSnapshot.event_parameters_snapshot)), + selectinload(rel(AimbatSnapshot.seismogram_parameters_snapshots)), ) ) snapshot = session.exec(statement).one_or_none() @@ -420,6 +423,9 @@ def validate_iccs_construction( def _write_back_seismograms(session: Session, iccs: ICCS) -> None: """Write t1, flip, and select from ICCS seismograms back to the database. + Calls `session.commit()` after writing; any other pending changes on + `session` are also committed. + Args: session: Database session. iccs: ICCS instance whose seismograms carry UUIDs in their extra dict. @@ -484,7 +490,7 @@ def run_iccs( IccsResult from the algorithm run. """ - logger.info(f"Running ICCS with {autoflip=}, {autoselect=}.") + logger.info(f"Running ICCS (autoflip={autoflip}, autoselect={autoselect}).") result = iccs(autoflip=autoflip, autoselect=autoselect) n_iter = len(result.convergence) @@ -510,7 +516,9 @@ def run_mccc( McccResult from the algorithm run. """ - logger.info(f"Running MCCC for event {event.id} with {all_seismograms=}.") + logger.info( + f"Running MCCC for event {event.id} (all_seismograms={all_seismograms})." + ) result = iccs.run_mccc( all_seismograms=all_seismograms, diff --git a/src/aimbat/core/_project.py b/src/aimbat/core/_project.py index 8715339..6e76f04 100644 --- a/src/aimbat/core/_project.py +++ b/src/aimbat/core/_project.py @@ -45,7 +45,7 @@ def create_project(engine: Engine) -> None: # Import locally to ensure SQLModel registers all table metadata before create_all() import aimbat.models # noqa: F401 - logger.info(f"Creating new project in {engine.url}") + logger.info(f"Creating new project in {engine.url}.") if _project_exists(engine): raise RuntimeError( @@ -64,7 +64,7 @@ def create_project(engine: Engine) -> None: CREATE TRIGGER IF NOT EXISTS event_modified_on_params_update AFTER UPDATE ON aimbateventparameters BEGIN - UPDATE aimbatevent SET last_modified = datetime('now') + UPDATE aimbatevent SET last_modified = strftime('%Y-%m-%d %H:%M:%f', 'now') WHERE id = NEW.event_id; END; """) @@ -310,18 +310,23 @@ def delete_project(engine: Engine) -> None: RuntimeError: If unable to delete project. """ - logger.info(f"Deleting project in {engine=}.") + logger.info(f"Deleting project at {engine.url}.") - if _project_exists(engine): - if engine.driver == "pysqlite": - database = engine.url.database - engine.dispose() - if database == ":memory:": - logger.info("Running database in memory, nothing to delete.") - return - elif database: - project_path = Path(database) - logger.info(f"Deleting project file: {project_path=}") - project_path.unlink() - return - raise RuntimeError("Unable to find/delete project.") + if not _project_exists(engine): + raise RuntimeError("No project found to delete.") + + if engine.driver == "pysqlite": + database = engine.url.database + engine.dispose() + if database == ":memory:": + logger.info("Running database in memory, nothing to delete.") + return + elif database: + project_path = Path(database) + logger.info(f"Deleting project file: {project_path}.") + project_path.unlink() + return + + raise RuntimeError( + f"Unable to delete project: unsupported engine driver '{engine.driver}'." + ) diff --git a/src/aimbat/core/_snapshot.py b/src/aimbat/core/_snapshot.py index 9c80cd6..9c8e00d 100644 --- a/src/aimbat/core/_snapshot.py +++ b/src/aimbat/core/_snapshot.py @@ -120,7 +120,11 @@ def create_snapshot( comment: Optional comment. """ - logger.info(f"Creating snapshot for event with id={event.id} with {comment=}.") + logger.info( + f"Creating snapshot for event {event.id}" + + (f" with comment '{comment}'" if comment else "") + + "." + ) event = session.exec( select(AimbatEvent) @@ -332,7 +336,10 @@ def sync_from_matching_hash( logger.info(f"Syncing quality metrics from snapshot {snapshot.id}.") event_quality_snap = snapshot.event_quality_snapshot - assert event_quality_snap is not None + if event_quality_snap is None: + raise ValueError( + f"Snapshot {snapshot.id} has no event quality data despite passing filter." + ) live_event_quality = session.get( AimbatEventQuality, event_quality_snap.event_quality_id ) @@ -424,8 +431,8 @@ def dump_snapshot_table( Args: session: Database session. - event_id: Event ID to filter seismograms by (if none is provided, - seismograms for all events are dumped). + event_id: Event ID to filter snapshots by (if none is provided, + snapshots for all events are dumped). from_read_model: Whether to dump from the read model (True) or the ORM model. Only affects the `snapshots` table. by_alias: Whether to use serialization aliases for the field names in the output. @@ -559,8 +566,8 @@ def dump_event_parameter_snapshot_table( Args: session: Database session. - event_id: Event ID to filter seismograms by (if none is provided, - seismograms for all events are dumped). + event_id: Event ID to filter snapshots by (if none is provided, + snapshots for all events are dumped). by_alias: Whether to use serialization aliases for the field names in the output. exclude: Set of field names to exclude from the output. """ @@ -592,8 +599,8 @@ def dump_seismogram_parameter_snapshot_table( Args: session: Database session. - event_id: Event ID to filter seismograms by (if none is provided, - seismograms for all events are dumped). + event_id: Event ID to filter snapshots by (if none is provided, + snapshots for all events are dumped). by_alias: Whether to use serialization aliases for the field names in the output. exclude: Set of field names to exclude from the output. """ diff --git a/src/aimbat/models/_parameters.py b/src/aimbat/models/_parameters.py index 05f0ae1..003a547 100644 --- a/src/aimbat/models/_parameters.py +++ b/src/aimbat/models/_parameters.py @@ -125,7 +125,12 @@ def validate_iccs_context(self, info: ValidationInfo) -> Self: class AimbatSeismogramParametersBase(SQLModel): - """Base class defining seismogram-level processing parameters for AIMBAT.""" + """Base class defining seismogram-level processing parameters for AIMBAT. + + This class serves as a base that is inherited by the actual classes that + create the database tables. The attributes correspond exactly to the AIMBAT + per-seismogram parameters. + """ flip: bool = Field( default=False, diff --git a/src/aimbat/models/_readers.py b/src/aimbat/models/_readers.py index 739be5e..8f6aa78 100644 --- a/src/aimbat/models/_readers.py +++ b/src/aimbat/models/_readers.py @@ -509,7 +509,9 @@ class AimbatSeismogramRead(BaseModel): def from_seismogram( cls, seismogram: AimbatSeismogram, session: Session | None = None ) -> Self: - name = (f"{seismogram.station.network}." or "") + seismogram.station.name + name = ( + f"{seismogram.station.network}." if seismogram.station.network else "" + ) + seismogram.station.name short_id = None short_event_id = None diff --git a/src/aimbat/utils/_uuid.py b/src/aimbat/utils/_uuid.py index c1ccc2c..c7f093f 100644 --- a/src/aimbat/utils/_uuid.py +++ b/src/aimbat/utils/_uuid.py @@ -97,7 +97,7 @@ def uuid_shortener[T: AimbatTypes]( candidate = target_full[:current_length] if candidate.endswith("-"): current_length += 1 - candidate = target_full[:current_length] + continue matches = [u for u in relevant_pool if u.startswith(candidate)] if len(matches) == 1: diff --git a/tests/integration/core/test_iccs.py b/tests/integration/core/test_iccs.py index ba23688..cab842f 100644 --- a/tests/integration/core/test_iccs.py +++ b/tests/integration/core/test_iccs.py @@ -3,11 +3,13 @@ from sqlmodel import Session, select from aimbat.core import ( + build_iccs_from_snapshot, create_iccs_instance, + create_snapshot, run_iccs, run_mccc, ) -from aimbat.models import AimbatEvent, AimbatSeismogramQuality +from aimbat.models import AimbatEvent, AimbatSeismogramQuality, AimbatSnapshot class TestIccsMcccInterplay: @@ -155,3 +157,66 @@ def test_run_iccs_t1_change_nulls_all_iccs_cc( if s.id in initial_ccs: assert s.quality is not None assert s.quality.iccs_cc is None + + +class TestBuildIccsFromSnapshot: + """Tests for building an ICCS instance from a snapshot.""" + + def test_uses_snapshot_event_parameters(self, loaded_session: Session) -> None: + """Verifies that the ICCS built from a snapshot uses the snapshot's + event parameters, not the live ones changed after the snapshot was taken. + + Args: + loaded_session: The database session with data loaded. + """ + from pandas import Timedelta + + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + + original_window_pre = event.parameters.window_pre + + # Take a snapshot that captures the original window_pre + create_snapshot(loaded_session, event) + snapshot = loaded_session.exec(select(AimbatSnapshot)).one() + + # Shrink window_pre in the live DB after the snapshot was taken + event.parameters.window_pre = original_window_pre - Timedelta(seconds=1) + loaded_session.add(event.parameters) + loaded_session.commit() + loaded_session.refresh(event) + assert event.parameters.window_pre != original_window_pre + + # Build ICCS from snapshot — must use the original value, not the live one + bound = build_iccs_from_snapshot(loaded_session, snapshot.id) + assert bound.iccs.window_pre == original_window_pre + + def test_uses_snapshot_seismogram_parameters(self, loaded_session: Session) -> None: + """Verifies that the ICCS built from a snapshot uses the per-seismogram + parameters captured at snapshot time, not any changes made afterwards. + + Args: + loaded_session: The database session with data loaded. + """ + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + + seis = event.seismograms[0] + original_select = seis.parameters.select + + # Take a snapshot that captures the original select flag + create_snapshot(loaded_session, event) + snapshot = loaded_session.exec(select(AimbatSnapshot)).one() + + # Toggle select in the live DB after the snapshot was taken + seis.parameters.select = not original_select + loaded_session.add(seis.parameters) + loaded_session.commit() + loaded_session.refresh(event) + + # Build ICCS from snapshot — must use the original select flag + bound = build_iccs_from_snapshot(loaded_session, snapshot.id) + snapshot_seis = next( + s for s in bound.iccs.seismograms if s.extra["id"] == seis.id + ) + assert snapshot_seis.select == original_select diff --git a/tests/integration/core/test_snapshots.py b/tests/integration/core/test_snapshots.py index bbe094d..383bc80 100644 --- a/tests/integration/core/test_snapshots.py +++ b/tests/integration/core/test_snapshots.py @@ -579,6 +579,70 @@ def test_sync_no_match(self, loaded_session: Session) -> None: is False ) + def test_sync_prefers_specified_snapshot_id_over_most_recent( + self, loaded_session: Session + ) -> None: + """Verifies that sync_from_matching_hash prefers the snapshot whose ID + is given as a tie-breaker, even when a more recent candidate exists. + + Args: + loaded_session: The database session with data loaded. + """ + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + + seis_ids = [s.id for s in event.seismograms] + select_flags = [s.select for s in event.seismograms] + + # Write MCCC quality (RMSE = 1 ms) and take first snapshot + _write_mock_mccc_quality( + loaded_session, event.id, seis_ids, select_flags, all_seismograms=True + ) + loaded_session.refresh(event) + eq = loaded_session.exec( + select(AimbatEventQuality).where( + col(AimbatEventQuality.event_id) == event.id + ) + ).one() + eq.mccc_rmse = pd.Timedelta(milliseconds=1) + loaded_session.add(eq) + loaded_session.commit() + + create_snapshot(loaded_session, event) + snapshot1 = loaded_session.exec(select(AimbatSnapshot)).one() + parameters_hash = compute_parameters_hash(event) + + # Change RMSE to 2 ms (no parameter change) and take a second snapshot + loaded_session.refresh(eq) + eq.mccc_rmse = pd.Timedelta(milliseconds=2) + loaded_session.add(eq) + loaded_session.commit() + loaded_session.refresh(event) + create_snapshot(loaded_session, event) + + # Clear live RMSE + loaded_session.refresh(eq) + eq.mccc_rmse = None + loaded_session.add(eq) + loaded_session.commit() + + # Without a snapshot_id the most recent candidate wins (snapshot2, RMSE=2 ms) + sync_from_matching_hash(loaded_session, parameters_hash=parameters_hash) + loaded_session.refresh(eq) + assert eq.mccc_rmse == pd.Timedelta(milliseconds=2) + + # With snapshot_id=snapshot1.id the older candidate is preferred (RMSE=1 ms) + eq.mccc_rmse = None + loaded_session.add(eq) + loaded_session.commit() + sync_from_matching_hash( + loaded_session, + parameters_hash=parameters_hash, + snapshot_id=snapshot1.id, + ) + loaded_session.refresh(eq) + assert eq.mccc_rmse == pd.Timedelta(milliseconds=1) + class TestDumpSnapshotTable: """Tests for dump_snapshot_table.""" diff --git a/tests/integration/models/test_note.py b/tests/integration/models/test_note.py index 8e3231c..1fb681d 100644 --- a/tests/integration/models/test_note.py +++ b/tests/integration/models/test_note.py @@ -1,4 +1,4 @@ -"""Integration tests for the AimbatNote model's single-parent constraint.""" +"""Integration tests for the AimbatNote model's single-parent constraint and core note functions.""" import uuid from datetime import timezone @@ -9,6 +9,7 @@ from sqlalchemy.exc import IntegrityError from sqlmodel import Session +from aimbat.core import get_note_content, save_note from aimbat.models import AimbatEvent, AimbatEventParameters, AimbatNote, AimbatStation @@ -86,3 +87,45 @@ def test_db_constraint_rejects_two_parents(self, patched_session: Session) -> No patched_session.add(note) with pytest.raises(IntegrityError): patched_session.flush() + + +class TestNoteCore: + """Tests for get_note_content and save_note at the core layer.""" + + def test_get_note_content_returns_empty_string_when_no_note_exists( + self, patched_session: Session + ) -> None: + """Verifies that get_note_content returns an empty string when no note exists. + + Args: + patched_session: The database session. + """ + result = get_note_content(patched_session, "event", uuid.uuid4()) + assert result == "" + + def test_save_note_creates_note_when_none_exists( + self, patched_session: Session + ) -> None: + """Verifies that save_note creates a new note record when one does not yet exist. + + Args: + patched_session: The database session. + """ + ev = _make_event(patched_session) + assert get_note_content(patched_session, "event", ev.id) == "" + + save_note(patched_session, "event", ev.id, "initial content") + + assert get_note_content(patched_session, "event", ev.id) == "initial content" + + def test_save_note_updates_existing_note(self, patched_session: Session) -> None: + """Verifies that save_note updates the content of an existing note. + + Args: + patched_session: The database session. + """ + ev = _make_event(patched_session) + save_note(patched_session, "event", ev.id, "first version") + save_note(patched_session, "event", ev.id, "second version") + + assert get_note_content(patched_session, "event", ev.id) == "second version" diff --git a/uv.lock b/uv.lock index 8852b63..e2f1ef3 100644 --- a/uv.lock +++ b/uv.lock @@ -710,11 +710,11 @@ wheels = [ [[package]] name = "griffelib" -version = "2.0.1" +version = "2.0.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/71/d7/2b805e89cdc609e5b304361d80586b272ef00f6287ee63de1e571b1f71ec/griffelib-2.0.1.tar.gz", hash = "sha256:59f39eabb4c777483a3823e39e8f9e03e69df271a7e49aee64e91a8cfa91bdf5", size = 166383, upload-time = "2026-03-23T21:05:25.882Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4b/4c/cc8c68196db727cfc1432f2ad5de50aa6707e630d44b2e6361dc06d8f134/griffelib-2.0.1-py3-none-any.whl", hash = "sha256:b769eed581c0e857d362fc8fcd8e57ecd2330c124b6104ac8b4c1c86d76970aa", size = 142377, upload-time = "2026-03-23T21:04:01.116Z" }, + { url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" }, ] [[package]] @@ -1925,8 +1925,8 @@ wheels = [ [[package]] name = "pysmo" -version = "1.0.0.dev42+g39ff86acb" -source = { git = "https://github.com/pysmo/pysmo?rev=master#39ff86acbf51fa93c4e0b2628c7206ed34c08bc2" } +version = "1.0.0.dev43+gbca634e0d" +source = { git = "https://github.com/pysmo/pysmo?rev=master#bca634e0d083d04fa85cdf6eac63fea7f8c8ae18" } dependencies = [ { name = "annotated-types" }, { name = "attrs" }, @@ -1983,7 +1983,7 @@ sdist = { url = "https://files.pythonhosted.org/packages/c2/ea/84509533e0f647796 [[package]] name = "pytest-mpl" -version = "0.18.0" +version = "0.19.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jinja2" }, @@ -1992,9 +1992,9 @@ dependencies = [ { name = "pillow" }, { name = "pytest" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9c/40/ba3caed7a54a6a5fe035ecc62dbb473f6f41ecade2ea3e0474f7e8e65c5c/pytest_mpl-0.18.0.tar.gz", hash = "sha256:04c949ea1278a38ca3d8d675871f6c5a0958bfedfaf4fd8bfd4e25e5861759a7", size = 880354, upload-time = "2025-11-15T13:54:52.69Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/c1/d5347e8aff4f6306f33caac80e1527831f733788ca98a6d58cfa0d40999c/pytest_mpl-0.19.0.tar.gz", hash = "sha256:c08e7b51ba4c98eed77ac3f3c6e336ed6163647d9e27ea1d928f79b542178cbe", size = 881884, upload-time = "2026-03-25T10:12:35.992Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/eb/01/f9128b75808840fc070042c57168c800da57d2d80b97071dc6f07c793d58/pytest_mpl-0.18.0-py3-none-any.whl", hash = "sha256:e474b7676957c521104dbe351df937da5e582231cd34daeb43b9d1ecc048e5c0", size = 28063, upload-time = "2025-11-15T13:54:51.147Z" }, + { url = "https://files.pythonhosted.org/packages/a3/f9/60a6c3da23525e600131eae9e17cad738e2e820335ab45e1ea78ec8df4cb/pytest_mpl-0.19.0-py3-none-any.whl", hash = "sha256:ceb6f7201587b07af82ba8892631a29a4716fdc84306db3ec1b4ef3758f0507d", size = 28139, upload-time = "2026-03-25T10:12:34.966Z" }, ] [[package]] @@ -2131,27 +2131,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a1/22/9e4f66ee588588dc6c9af6a994e12d26e19efbe874d1a909d09a6dac7a59/ruff-0.15.7.tar.gz", hash = "sha256:04f1ae61fc20fe0b148617c324d9d009b5f63412c0b16474f3d5f1a1a665f7ac", size = 4601277, upload-time = "2026-03-19T16:26:22.605Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/41/2f/0b08ced94412af091807b6119ca03755d651d3d93a242682bf020189db94/ruff-0.15.7-py3-none-linux_armv6l.whl", hash = "sha256:a81cc5b6910fb7dfc7c32d20652e50fa05963f6e13ead3c5915c41ac5d16668e", size = 10489037, upload-time = "2026-03-19T16:26:32.47Z" }, - { url = "https://files.pythonhosted.org/packages/91/4a/82e0fa632e5c8b1eba5ee86ecd929e8ff327bbdbfb3c6ac5d81631bef605/ruff-0.15.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:722d165bd52403f3bdabc0ce9e41fc47070ac56d7a91b4e0d097b516a53a3477", size = 10955433, upload-time = "2026-03-19T16:27:00.205Z" }, - { url = "https://files.pythonhosted.org/packages/ab/10/12586735d0ff42526ad78c049bf51d7428618c8b5c467e72508c694119df/ruff-0.15.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:7fbc2448094262552146cbe1b9643a92f66559d3761f1ad0656d4991491af49e", size = 10269302, upload-time = "2026-03-19T16:26:26.183Z" }, - { url = "https://files.pythonhosted.org/packages/eb/5d/32b5c44ccf149a26623671df49cbfbd0a0ae511ff3df9d9d2426966a8d57/ruff-0.15.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b39329b60eba44156d138275323cc726bbfbddcec3063da57caa8a8b1d50adf", size = 10607625, upload-time = "2026-03-19T16:27:03.263Z" }, - { url = "https://files.pythonhosted.org/packages/5d/f1/f0001cabe86173aaacb6eb9bb734aa0605f9a6aa6fa7d43cb49cbc4af9c9/ruff-0.15.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:87768c151808505f2bfc93ae44e5f9e7c8518943e5074f76ac21558ef5627c85", size = 10324743, upload-time = "2026-03-19T16:27:09.791Z" }, - { url = "https://files.pythonhosted.org/packages/7a/87/b8a8f3d56b8d848008559e7c9d8bf367934d5367f6d932ba779456e2f73b/ruff-0.15.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fb0511670002c6c529ec66c0e30641c976c8963de26a113f3a30456b702468b0", size = 11138536, upload-time = "2026-03-19T16:27:06.101Z" }, - { url = "https://files.pythonhosted.org/packages/e4/f2/4fd0d05aab0c5934b2e1464784f85ba2eab9d54bffc53fb5430d1ed8b829/ruff-0.15.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e0d19644f801849229db8345180a71bee5407b429dd217f853ec515e968a6912", size = 11994292, upload-time = "2026-03-19T16:26:48.718Z" }, - { url = "https://files.pythonhosted.org/packages/64/22/fc4483871e767e5e95d1622ad83dad5ebb830f762ed0420fde7dfa9d9b08/ruff-0.15.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4806d8e09ef5e84eb19ba833d0442f7e300b23fe3f0981cae159a248a10f0036", size = 11398981, upload-time = "2026-03-19T16:26:54.513Z" }, - { url = "https://files.pythonhosted.org/packages/b0/99/66f0343176d5eab02c3f7fcd2de7a8e0dd7a41f0d982bee56cd1c24db62b/ruff-0.15.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dce0896488562f09a27b9c91b1f58a097457143931f3c4d519690dea54e624c5", size = 11242422, upload-time = "2026-03-19T16:26:29.277Z" }, - { url = "https://files.pythonhosted.org/packages/5d/3a/a7060f145bfdcce4c987ea27788b30c60e2c81d6e9a65157ca8afe646328/ruff-0.15.7-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:1852ce241d2bc89e5dc823e03cff4ce73d816b5c6cdadd27dbfe7b03217d2a12", size = 11232158, upload-time = "2026-03-19T16:26:42.321Z" }, - { url = "https://files.pythonhosted.org/packages/a7/53/90fbb9e08b29c048c403558d3cdd0adf2668b02ce9d50602452e187cd4af/ruff-0.15.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:5f3e4b221fb4bd293f79912fc5e93a9063ebd6d0dcbd528f91b89172a9b8436c", size = 10577861, upload-time = "2026-03-19T16:26:57.459Z" }, - { url = "https://files.pythonhosted.org/packages/2f/aa/5f486226538fe4d0f0439e2da1716e1acf895e2a232b26f2459c55f8ddad/ruff-0.15.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b15e48602c9c1d9bdc504b472e90b90c97dc7d46c7028011ae67f3861ceba7b4", size = 10327310, upload-time = "2026-03-19T16:26:35.909Z" }, - { url = "https://files.pythonhosted.org/packages/99/9e/271afdffb81fe7bfc8c43ba079e9d96238f674380099457a74ccb3863857/ruff-0.15.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1b4705e0e85cedc74b0a23cf6a179dbb3df184cb227761979cc76c0440b5ab0d", size = 10840752, upload-time = "2026-03-19T16:26:45.723Z" }, - { url = "https://files.pythonhosted.org/packages/bf/29/a4ae78394f76c7759953c47884eb44de271b03a66634148d9f7d11e721bd/ruff-0.15.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:112c1fa316a558bb34319282c1200a8bf0495f1b735aeb78bfcb2991e6087580", size = 11336961, upload-time = "2026-03-19T16:26:39.076Z" }, - { url = "https://files.pythonhosted.org/packages/26/6b/8786ba5736562220d588a2f6653e6c17e90c59ced34a2d7b512ef8956103/ruff-0.15.7-py3-none-win32.whl", hash = "sha256:6d39e2d3505b082323352f733599f28169d12e891f7dd407f2d4f54b4c2886de", size = 10582538, upload-time = "2026-03-19T16:26:15.992Z" }, - { url = "https://files.pythonhosted.org/packages/2b/e9/346d4d3fffc6871125e877dae8d9a1966b254fbd92a50f8561078b88b099/ruff-0.15.7-py3-none-win_amd64.whl", hash = "sha256:4d53d712ddebcd7dace1bc395367aec12c057aacfe9adbb6d832302575f4d3a1", size = 11755839, upload-time = "2026-03-19T16:26:19.897Z" }, - { url = "https://files.pythonhosted.org/packages/8f/e8/726643a3ea68c727da31570bde48c7a10f1aa60eddd628d94078fec586ff/ruff-0.15.7-py3-none-win_arm64.whl", hash = "sha256:18e8d73f1c3fdf27931497972250340f92e8c861722161a9caeb89a58ead6ed2", size = 11023304, upload-time = "2026-03-19T16:26:51.669Z" }, +version = "0.15.8" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/b0/73cf7550861e2b4824950b8b52eebdcc5adc792a00c514406556c5b80817/ruff-0.15.8.tar.gz", hash = "sha256:995f11f63597ee362130d1d5a327a87cb6f3f5eae3094c620bcc632329a4d26e", size = 4610921, upload-time = "2026-03-26T18:39:38.675Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4a/92/c445b0cd6da6e7ae51e954939cb69f97e008dbe750cfca89b8cedc081be7/ruff-0.15.8-py3-none-linux_armv6l.whl", hash = "sha256:cbe05adeba76d58162762d6b239c9056f1a15a55bd4b346cfd21e26cd6ad7bc7", size = 10527394, upload-time = "2026-03-26T18:39:41.566Z" }, + { url = "https://files.pythonhosted.org/packages/eb/92/f1c662784d149ad1414cae450b082cf736430c12ca78367f20f5ed569d65/ruff-0.15.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d3e3d0b6ba8dca1b7ef9ab80a28e840a20070c4b62e56d675c24f366ef330570", size = 10905693, upload-time = "2026-03-26T18:39:30.364Z" }, + { url = "https://files.pythonhosted.org/packages/ca/f2/7a631a8af6d88bcef997eb1bf87cc3da158294c57044aafd3e17030613de/ruff-0.15.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6ee3ae5c65a42f273f126686353f2e08ff29927b7b7e203b711514370d500de3", size = 10323044, upload-time = "2026-03-26T18:39:33.37Z" }, + { url = "https://files.pythonhosted.org/packages/67/18/1bf38e20914a05e72ef3b9569b1d5c70a7ef26cd188d69e9ca8ef588d5bf/ruff-0.15.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fdce027ada77baa448077ccc6ebb2fa9c3c62fd110d8659d601cf2f475858d94", size = 10629135, upload-time = "2026-03-26T18:39:44.142Z" }, + { url = "https://files.pythonhosted.org/packages/d2/e9/138c150ff9af60556121623d41aba18b7b57d95ac032e177b6a53789d279/ruff-0.15.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:12e617fc01a95e5821648a6df341d80456bd627bfab8a829f7cfc26a14a4b4a3", size = 10348041, upload-time = "2026-03-26T18:39:52.178Z" }, + { url = "https://files.pythonhosted.org/packages/02/f1/5bfb9298d9c323f842c5ddeb85f1f10ef51516ac7a34ba446c9347d898df/ruff-0.15.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:432701303b26416d22ba696c39f2c6f12499b89093b61360abc34bcc9bf07762", size = 11121987, upload-time = "2026-03-26T18:39:55.195Z" }, + { url = "https://files.pythonhosted.org/packages/10/11/6da2e538704e753c04e8d86b1fc55712fdbdcc266af1a1ece7a51fff0d10/ruff-0.15.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d910ae974b7a06a33a057cb87d2a10792a3b2b3b35e33d2699fdf63ec8f6b17a", size = 11951057, upload-time = "2026-03-26T18:39:19.18Z" }, + { url = "https://files.pythonhosted.org/packages/83/f0/c9208c5fd5101bf87002fed774ff25a96eea313d305f1e5d5744698dc314/ruff-0.15.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2033f963c43949d51e6fdccd3946633c6b37c484f5f98c3035f49c27395a8ab8", size = 11464613, upload-time = "2026-03-26T18:40:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/f8/22/d7f2fabdba4fae9f3b570e5605d5eb4500dcb7b770d3217dca4428484b17/ruff-0.15.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f29b989a55572fb885b77464cf24af05500806ab4edf9a0fd8977f9759d85b1", size = 11257557, upload-time = "2026-03-26T18:39:57.972Z" }, + { url = "https://files.pythonhosted.org/packages/71/8c/382a9620038cf6906446b23ce8632ab8c0811b8f9d3e764f58bedd0c9a6f/ruff-0.15.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ac51d486bf457cdc985a412fb1801b2dfd1bd8838372fc55de64b1510eff4bec", size = 11169440, upload-time = "2026-03-26T18:39:22.205Z" }, + { url = "https://files.pythonhosted.org/packages/4d/0d/0994c802a7eaaf99380085e4e40c845f8e32a562e20a38ec06174b52ef24/ruff-0.15.8-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:c9861eb959edab053c10ad62c278835ee69ca527b6dcd72b47d5c1e5648964f6", size = 10605963, upload-time = "2026-03-26T18:39:46.682Z" }, + { url = "https://files.pythonhosted.org/packages/19/aa/d624b86f5b0aad7cef6bbf9cd47a6a02dfdc4f72c92a337d724e39c9d14b/ruff-0.15.8-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8d9a5b8ea13f26ae90838afc33f91b547e61b794865374f114f349e9036835fb", size = 10357484, upload-time = "2026-03-26T18:39:49.176Z" }, + { url = "https://files.pythonhosted.org/packages/35/c3/e0b7835d23001f7d999f3895c6b569927c4d39912286897f625736e1fd04/ruff-0.15.8-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c2a33a529fb3cbc23a7124b5c6ff121e4d6228029cba374777bd7649cc8598b8", size = 10830426, upload-time = "2026-03-26T18:40:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/f0/51/ab20b322f637b369383adc341d761eaaa0f0203d6b9a7421cd6e783d81b9/ruff-0.15.8-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:75e5cd06b1cf3f47a3996cfc999226b19aa92e7cce682dcd62f80d7035f98f49", size = 11345125, upload-time = "2026-03-26T18:39:27.799Z" }, + { url = "https://files.pythonhosted.org/packages/37/e6/90b2b33419f59d0f2c4c8a48a4b74b460709a557e8e0064cf33ad894f983/ruff-0.15.8-py3-none-win32.whl", hash = "sha256:bc1f0a51254ba21767bfa9a8b5013ca8149dcf38092e6a9eb704d876de94dc34", size = 10571959, upload-time = "2026-03-26T18:39:36.117Z" }, + { url = "https://files.pythonhosted.org/packages/1f/a2/ef467cb77099062317154c63f234b8a7baf7cb690b99af760c5b68b9ee7f/ruff-0.15.8-py3-none-win_amd64.whl", hash = "sha256:04f79eff02a72db209d47d665ba7ebcad609d8918a134f86cb13dd132159fc89", size = 11743893, upload-time = "2026-03-26T18:39:25.01Z" }, + { url = "https://files.pythonhosted.org/packages/15/e2/77be4fff062fa78d9b2a4dea85d14785dac5f1d0c1fb58ed52331f0ebe28/ruff-0.15.8-py3-none-win_arm64.whl", hash = "sha256:cf891fa8e3bb430c0e7fac93851a5978fc99c8fa2c053b57b118972866f8e5f2", size = 11048175, upload-time = "2026-03-26T18:40:01.06Z" }, ] [[package]] @@ -2315,7 +2315,7 @@ wheels = [ [[package]] name = "textual" -version = "8.1.1" +version = "8.2.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "markdown-it-py", extra = ["linkify"] }, @@ -2325,9 +2325,9 @@ dependencies = [ { name = "rich" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/23/8c709655c5f2208ee82ab81b8104802421865535c278a7649b842b129db1/textual-8.1.1.tar.gz", hash = "sha256:eef0256a6131f06a20ad7576412138c1f30f92ddeedd055953c08d97044bc317", size = 1843002, upload-time = "2026-03-10T10:01:38.493Z" } +sdist = { url = "https://files.pythonhosted.org/packages/70/41/fd22435b96a50f24472af31f15e9874ce0ffef0edabb74fcf3d598ab8e53/textual-8.2.0.tar.gz", hash = "sha256:a06201a732b2d2683d2ea11c2538eeb7315c4c7138ed8c8bdb48150b805eaf1e", size = 1847670, upload-time = "2026-03-27T09:28:29.976Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/50/21/421b02bf5943172b7a9320712a5e0d74a02a8f7597284e3f8b5b06c70b8d/textual-8.1.1-py3-none-any.whl", hash = "sha256:6712f96e335cd782e76193dee16b9c8875fe0699d923bc8d3f1228fd23e773a6", size = 719598, upload-time = "2026-03-10T10:01:48.318Z" }, + { url = "https://files.pythonhosted.org/packages/a2/41/ed1f3e47b71b9e0bae23445189274048332a423798627d47e30314c739a0/textual-8.2.0-py3-none-any.whl", hash = "sha256:0478fed6e945bcadd6f3841e10d05213d318cdb5c4345c04ae78f7ec32e7b63d", size = 723752, upload-time = "2026-03-27T09:28:32.705Z" }, ] [[package]] @@ -2438,11 +2438,11 @@ wheels = [ [[package]] name = "vulture" -version = "2.15" +version = "2.16" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/59/c6/4f147b621b4c0899eb1770f98113334bb706ebd251ac2be979316b1985fa/vulture-2.15.tar.gz", hash = "sha256:f9d8b4ce29c69950d323f21dceab4a4d6c694403dffbed7713c4691057e561fe", size = 52438, upload-time = "2026-03-04T21:41:39.096Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/3e/4d08c5903b2c0c70cad583c170cc4a663fc6a61e2ad00b711fcda61358cd/vulture-2.16.tar.gz", hash = "sha256:f8d9f6e2af03011664a3c6c240c9765b3f392917d3135fddca6d6a68d359f717", size = 52680, upload-time = "2026-03-25T14:41:27.141Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1c/f3/07cf122e145bc6df976030e9935123124c3fcb5044cf407b5e71e85821b4/vulture-2.15-py3-none-any.whl", hash = "sha256:a3d8ebef918694326620eb128fa783486c8d285b23381c2b457d864ac056ef8d", size = 26895, upload-time = "2026-03-04T21:41:39.878Z" }, + { url = "https://files.pythonhosted.org/packages/f5/be/f935130312330614811dae2ea9df3f395f6d63889eb6c2e68c14507152ee/vulture-2.16-py3-none-any.whl", hash = "sha256:6e0f1c312cef1c87856957e5c2ca9608834a7c794c2180477f30bf0e4cc58eee", size = 26993, upload-time = "2026-03-25T14:41:26.21Z" }, ] [[package]]