From 459e0df069cd5d3108d31ae0b128b8acd713a880 Mon Sep 17 00:00:00 2001 From: Simon Lloyd Date: Thu, 19 Mar 2026 11:02:33 +0000 Subject: [PATCH 1/6] refactor: remove dead code --- CHANGELOG.md | 2 + docs/usage/event-selection.md | 40 +- docs/usage/index.md | 36 +- pyproject.toml | 7 +- src/aimbat/_cli/__init__.py | 25 + src/aimbat/_cli/align.py | 72 +-- src/aimbat/_cli/common.py | 236 -------- src/aimbat/_cli/common/__init__.py | 10 + src/aimbat/_cli/common/_decorators.py | 45 ++ src/aimbat/_cli/common/_parameters.py | 229 +++++++ src/aimbat/_cli/common/_table.py | 197 ++++++ src/aimbat/_cli/data.py | 118 +++- src/aimbat/_cli/event.py | 171 ++---- src/aimbat/_cli/pick.py | 62 +- src/aimbat/_cli/plot.py | 25 +- src/aimbat/_cli/project.py | 24 +- src/aimbat/_cli/quality.py | 232 ------- src/aimbat/_cli/seismogram.py | 128 ++-- src/aimbat/_cli/shell.py | 209 +++---- src/aimbat/_cli/snapshot.py | 134 +++-- src/aimbat/_cli/station.py | 58 +- src/aimbat/_cli/utils/__init__.py | 4 +- src/aimbat/_config.py | 32 +- src/aimbat/_tui/_format.py | 154 +++++ src/aimbat/_tui/app.py | 128 +--- src/aimbat/_tui/modals.py | 63 +- src/aimbat/app.py | 31 +- src/aimbat/core/__init__.py | 5 +- src/aimbat/core/_data.py | 58 +- src/aimbat/core/_default_event.py | 95 --- src/aimbat/core/_event.py | 57 +- src/aimbat/core/_iccs.py | 28 +- src/aimbat/core/_project.py | 40 +- src/aimbat/core/_quality.py | 70 ++- src/aimbat/core/_seismogram.py | 45 +- src/aimbat/core/_snapshot.py | 230 +++++-- src/aimbat/core/_station.py | 26 +- src/aimbat/models/__init__.py | 4 +- src/aimbat/models/_format.py | 69 +++ src/aimbat/models/_models.py | 10 +- src/aimbat/models/_readers.py | 118 +++- src/aimbat/plot/_seismograms.py | 62 +- src/aimbat/utils/__init__.py | 2 +- src/aimbat/utils/_sqlalchemy.py | 15 + src/aimbat/utils/_table.py | 250 -------- src/aimbat/utils/formatters.py | 68 +++ tests/conftest.py | 23 +- tests/functional/test_cli_basic_ops.py | 132 ++-- tests/functional/test_cli_parameters.py | 214 ++++--- tests/functional/test_cli_snapshots.py | 139 +++-- tests/functional/test_shell.py | 42 +- tests/integration/core/test_data.py | 64 +- tests/integration/core/test_event.py | 127 +--- tests/integration/core/test_iccs.py | 9 +- tests/integration/core/test_mccc.py | 9 +- tests/integration/core/test_seismogram.py | 60 +- tests/integration/core/test_snapshots.py | 568 +++++++++++++----- tests/integration/core/test_station.py | 117 +--- tests/integration/core/test_views.py | 94 +-- tests/integration/models/test_models.py | 139 +---- tests/integration/models/test_operations.py | 85 +-- .../test_parameters.py} | 35 +- tests/unit/_cli/common/test_table.py | 173 ++++++ tests/unit/test_config.py | 2 +- tests/unit/utils/test_table.py | 177 ------ uv.lock | 168 +++--- 66 files changed, 3259 insertions(+), 2842 deletions(-) delete mode 100644 src/aimbat/_cli/common.py create mode 100644 src/aimbat/_cli/common/__init__.py create mode 100644 src/aimbat/_cli/common/_decorators.py create mode 100644 src/aimbat/_cli/common/_parameters.py create mode 100644 src/aimbat/_cli/common/_table.py delete mode 100644 src/aimbat/_cli/quality.py create mode 100644 src/aimbat/_tui/_format.py delete mode 100644 src/aimbat/core/_default_event.py create mode 100644 src/aimbat/models/_format.py create mode 100644 src/aimbat/utils/_sqlalchemy.py delete mode 100644 src/aimbat/utils/_table.py create mode 100644 src/aimbat/utils/formatters.py rename tests/unit/_cli/{test_common.py => common/test_parameters.py} (78%) create mode 100644 tests/unit/_cli/common/test_table.py delete mode 100644 tests/unit/utils/test_table.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c62545f..c83fcd21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,7 @@ All notable changes to the **AIMBAT** project will be documented in this file. - Add asciinema - Switch to zensical - Re-arange api reference +- Restructure usage section into workflow-based pages and TUI improvements ### ๐Ÿ“ฆ Miscellaneous @@ -123,6 +124,7 @@ All notable changes to the **AIMBAT** project will be documented in this file. - Add JSON datasource - Add TUI and supporting changes - Implement interactive shell and major documentation update for v2 +- Store ICCS/MCCC quality metrics in database ### ๐Ÿงช Testing diff --git a/docs/usage/event-selection.md b/docs/usage/event-selection.md index 9d9a489a..8a6eeb46 100644 --- a/docs/usage/event-selection.md +++ b/docs/usage/event-selection.md @@ -12,9 +12,9 @@ one event at a time. aimbat event list ``` - The table shows each event's ID, time, location, and whether it is - currently the default. IDs are displayed in their shortest unambiguous - form โ€” use any unique prefix when passing an ID to other commands. + The table shows each event's ID, time, and location. IDs are displayed in + their shortest unambiguous form โ€” use any unique prefix when passing an + ID to other commands. === "TUI" @@ -26,36 +26,42 @@ one event at a time. --- -## Setting the default event (CLI / Shell) +## Selecting an Event for CLI / Shell -The CLI and shell operate on a **default event** โ€” a single event stored in -the database that all commands target unless overridden with `--event`. Set it -after import: +Most processing commands (like `aimbat align iccs` or `aimbat snapshot create`) +operate on a single event. You can specify the target event in two ways: + +### 1. The `--event-id` flag (or `--event`) + +Pass the ID directly to any command. You can use the full UUID or any unique +prefix: ```bash -aimbat event default +aimbat align iccs --event-id 6a4a ``` -From that point on, commands like `aimbat plot seismograms` or -`aimbat align iccs` automatically target this event without needing an -explicit ID. +### 2. The `DEFAULT_EVENT_ID` environment variable -To target a different event for a single command without changing the default: +If you are working on the same event for multiple commands, you can set the +`DEFAULT_EVENT_ID` environment variable in your shell. This tells AIMBAT to +use that event whenever the `--event-id` flag is omitted: ```bash -aimbat align iccs --event +export DEFAULT_EVENT_ID=6a4a +aimbat align iccs +aimbat snapshot create "post-ICCS" ``` -The default event is marked in `aimbat event list` and is also shown in the -shell prompt. +The shell prompt also reflects this ID when set. To clear it, simply unset the +variable: `unset DEFAULT_EVENT_ID`. --- ## Selecting an event for processing (TUI / GUI) The TUI and GUI maintain their own event selection independently of the -database default โ€” changing it here does not affect what the CLI uses, and -vice versa. +CLI / shell context โ€” changing it here does not affect what the CLI uses, +and vice versa. === "TUI" diff --git a/docs/usage/index.md b/docs/usage/index.md index f9ed2fed..7d2aebbf 100644 --- a/docs/usage/index.md +++ b/docs/usage/index.md @@ -21,13 +21,21 @@ runs, prints its result, and exits. It is the natural choice for scripting, batch jobs, and any task where you already know what you want to do. Every command accepts `--help` for a full option listing. Most processing -commands operate on the [default event](index.md#default-event) unless you -pass an explicit `--event` flag: +commands require an event to operate on. You can pass an explicit `--event` +flag: ```bash aimbat align iccs --event 6a4a ``` +Alternatively, you can set the `DEFAULT_EVENT_ID` environment variable to +avoid passing the flag every time: + +```bash +export DEFAULT_EVENT_ID=6a4a +aimbat align iccs +``` + IDs can be supplied as the full UUID or any unique prefix. All commands exit with a non-zero status on error, making them safe to chain @@ -60,9 +68,9 @@ aimbat> event list aimbat> align iccs ``` -The shell maintains a local **event context** that is independent of the -database default event and is never written to the database. Switch it at any -time: +The shell maintains a local **event context** that can be pre-selected on +launch or switched at any time. When an event is selected, the shell +automatically injects it into all relevant commands: ``` aimbat [6a4a]> event switch @@ -201,18 +209,22 @@ with an environment variable: export AIMBAT_PROJECT=/path/to/my/project.db ``` -### Default event +### Event selection + +Projects can contain multiple seismic events. Most commands operate on a single +event at a time. You can choose the target event by passing the `--event-id` +(or `--event`) flag to any command. -Projects can contain multiple seismic events. The **default event** is a -database-level setting used by the CLI and, on startup, by the shell. Set it -with: +For convenience, you can also set the `DEFAULT_EVENT_ID` environment variable: ```bash -aimbat event default +export DEFAULT_EVENT_ID=6a4a ``` -The TUI and GUI maintain their own **event selection** independently of the -database default and never change it. +When this variable is set, the CLI and shell use it as the default target +whenever an explicit ID is omitted. The shell prompt also reflects this ID. +The TUI and GUI maintain their own event selection independently and never +change it. ### The ICCS instance diff --git a/pyproject.toml b/pyproject.toml index 171b2868..e9a39e2b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -30,6 +30,7 @@ dependencies = [ "textual-fspicker>=1.0.0", "prompt-toolkit>=3.0.52", "mplcursors>=0.7", + "typing-extensions>=4.15.0", ] [project.urls] @@ -49,11 +50,7 @@ test = [ "pytest-sugar>=1.1.1", "ruff>=0.13.0", ] -dev = [ - "ruff>=0.15.5", - "textual-dev>=1.8.0", - "vulture>=2.15", -] +dev = ["ruff>=0.15.5", "textual-dev>=1.8.0", "vulture>=2.15"] docs = [ "git-cliff>=2.12.0", "griffe-fieldz>=0.5.0", diff --git a/src/aimbat/_cli/__init__.py b/src/aimbat/_cli/__init__.py index e69de29b..db62f90b 100644 --- a/src/aimbat/_cli/__init__.py +++ b/src/aimbat/_cli/__init__.py @@ -0,0 +1,25 @@ +from .align import app as align +from .data import app as data +from .event import app as event +from .pick import app as pick +from .plot import app as plot +from .project import app as project +from .seismogram import app as seismogram +from .shell import app as shell +from .snapshot import app as snapshot +from .station import app as station +from .utils import app as utils + +__all__ = [ + "utils", + "align", + "data", + "event", + "pick", + "plot", + "project", + "seismogram", + "shell", + "snapshot", + "station", +] diff --git a/src/aimbat/_cli/align.py b/src/aimbat/_cli/align.py index b004dc61..c6ad4644 100644 --- a/src/aimbat/_cli/align.py +++ b/src/aimbat/_cli/align.py @@ -5,29 +5,27 @@ starting point instead, with the resulting pick stored in `t1`. """ -from dataclasses import dataclass from typing import Annotated +from uuid import UUID from cyclopts import App, Parameter -from .common import GlobalParameters, _DebugTrait, _EventContextTrait, simple_exception +from .common import ( + DebugParameter, + event_parameter, + simple_exception, +) __all__ = ["cli_iccs_run", "cli_mccc_run"] app = App(name="align", help=__doc__, help_format="markdown") -@Parameter(name="*") -@dataclass -class _IccsParametersTrait: - autoflip: Annotated[ - bool, - Parameter( - name="autoflip", - help="Whether to automatically flip seismograms (multiply data" - " by -1) when the cross-correlation is negative.", - ), - ] = False +@app.command(name="iccs") +@simple_exception +def cli_iccs_run( + event_id: Annotated[UUID, event_parameter()], + *, autoselect: Annotated[ bool, Parameter( @@ -36,31 +34,22 @@ class _IccsParametersTrait: " cross-correlation with the stack falls below `min_cc`, and" " re-select them if the cross-correlation later exceeds `min_cc`.", ), - ] = False - - -@dataclass -class _IccsParameters(_IccsParametersTrait, _EventContextTrait, _DebugTrait): - pass - - -@app.command(name="iccs") -@simple_exception -def cli_iccs_run( - *, - iccs_parameters: _IccsParameters = _IccsParameters(), + ] = False, + autoflip: Annotated[ + bool, + Parameter( + name="autoflip", + help="Whether to automatically flip seismograms (multiply data" + " by -1) when the cross-correlation is negative.", + ), + ] = False, + _: DebugParameter = DebugParameter(), ) -> None: - """Run the ICCS algorithm to align seismograms for the default event. + """Run the ICCS algorithm to align seismograms for an event. Iteratively cross-correlates seismograms against a running stack to refine arrival time picks (`t1`). If `t1` is not yet set, `t0` is used as the starting point. - - Args: - autoflip: Whether to automatically flip seismograms (multiply data by -1) - when the cross-correlation is negative. - autoselect: Whether to automatically de-select seismograms whose - cross-correlation with the stack falls below `min_cc`. """ from sqlmodel import Session @@ -68,28 +57,27 @@ def cli_iccs_run( from aimbat.db import engine with Session(engine) as session: - event = resolve_event(session, iccs_parameters.event_id) + event = resolve_event(session, event_id) iccs = create_iccs_instance(session, event).iccs - run_iccs( - session, event, iccs, iccs_parameters.autoflip, iccs_parameters.autoselect - ) + run_iccs(session, event, iccs, autoflip, autoselect) @app.command(name="mccc") @simple_exception def cli_mccc_run( + event_id: Annotated[UUID, event_parameter()], *, all_seismograms: Annotated[ bool, Parameter( name="all", - help="Include all seismograms in MCCC processing, not just the " - "currently selected ones", + help="Include all seismograms of an event in MCCC processing, " + "not just the currently selected ones.", ), ] = False, - global_parameters: GlobalParameters = GlobalParameters(), + _: DebugParameter = DebugParameter(), ) -> None: - """Run the MCCC algorithm to refine arrival time picks for the default event. + """Run the MCCC algorithm to refine arrival time picks for an event. Multi-channel cross-correlation simultaneously determines the optimal time shifts for all seismograms. Results are stored in `t1`. @@ -100,7 +88,7 @@ def cli_mccc_run( from aimbat.db import engine with Session(engine) as session: - event = resolve_event(session, global_parameters.event_id) + event = resolve_event(session, event_id) iccs = create_iccs_instance(session, event).iccs run_mccc(session, event, iccs, all_seismograms) diff --git a/src/aimbat/_cli/common.py b/src/aimbat/_cli/common.py deleted file mode 100644 index b19aba67..00000000 --- a/src/aimbat/_cli/common.py +++ /dev/null @@ -1,236 +0,0 @@ -"""Common parameters and functions for the AIMBAT CLI.""" - -import uuid -from dataclasses import dataclass -from typing import Annotated, Any, Callable - -from cyclopts import Parameter, Token - -from aimbat import settings - -# ----------------------------------------------------------------------- -# Shared Parameter instances and factories -# ----------------------------------------------------------------------- - - -def _make_uuid_converter(model_class: type) -> Callable[..., uuid.UUID]: - """Return a cyclopts converter that resolves a UUID prefix for the given model.""" - - def converter(hint: type, tokens: tuple[Token, ...]) -> uuid.UUID: - (token,) = tokens - value = token.value - try: - return uuid.UUID(value) - except ValueError: - from sqlmodel import Session - - from aimbat.db import engine - from aimbat.utils import string_to_uuid - - with Session(engine) as session: - return string_to_uuid(session, value, model_class) - - return converter - - -def _event_id_converter(hint: type, tokens: tuple[Token, ...]) -> uuid.UUID: - """Converter for the global --event parameter with late-bound model import.""" - from aimbat.models import AimbatEvent - - return _make_uuid_converter(AimbatEvent)(hint, tokens) - - -def id_parameter(model_class: type, help: str = "") -> Parameter: - """Create a Parameter for a record ID with automatic UUID prefix resolution.""" - return Parameter( - name="ID", - help=help or "Full UUID or any unique prefix as shown in the table.", - converter=_make_uuid_converter(model_class), - ) - - -def _station_id_converter(hint: type, tokens: tuple[Token, ...]) -> uuid.UUID: - from aimbat.models import AimbatStation - - return _make_uuid_converter(AimbatStation)(hint, tokens) - - -def event_parameter(help: str = "") -> Parameter: - """Create a Parameter for --event with automatic UUID prefix resolution.""" - return Parameter( - name=["event", "event-id"], - help=help or "Process a specific event instead of default one (if set). ", - converter=_event_id_converter, - ) - - -def seismogram_parameter(help: str = "") -> Parameter: - """Create a Parameter for --seismogram with automatic UUID prefix resolution.""" - from aimbat.models import AimbatSeismogram - - return Parameter( - name=["seismogram", "seismogram-id"], - help=help - or "ID of seismogram to process. Full UUID or any unique prefix as shown in the table.", - converter=_make_uuid_converter(AimbatSeismogram), - ) - - -def use_station_parameter() -> Parameter: - """Create a Parameter for --use-station with automatic UUID prefix resolution.""" - return Parameter( - name="use-station", - help="UUID (or unique prefix) of an existing station to link to instead of" - " extracting one from each data source.", - converter=_station_id_converter, - ) - - -def use_event_parameter() -> Parameter: - """Create a Parameter for --use-event with automatic UUID prefix resolution.""" - return Parameter( - name="use-event", - help="UUID (or unique prefix) of an existing event to link to instead of" - " extracting one from each data source.", - converter=_event_id_converter, - ) - - -#: Shared Parameter for --all (all events) flags. -ALL_EVENTS_PARAMETER = Parameter( - name="all", - help="Include records from all events instead of just the default one.", -) - -# ----------------------------------------------------------------------- -# Common parameters -# ----------------------------------------------------------------------- - - -@dataclass -class _DebugTrait: - debug: bool = False - """Enable verbose logging for troubleshooting.""" - - # NOTE: only one __post_init__ is allowed per dataclass - def __post_init__(self) -> None: - if self.debug: - settings.log_level = "DEBUG" - from aimbat.logger import configure_logging - - configure_logging() - - -@dataclass -class _AllEventsTrait: - all_events: Annotated[ - bool, - Parameter( - name="all", - help="Include records from all events. Overrides any event selection parameters.", - ), - ] = False - - -@dataclass -class _EventContextTrait: - event_id: Annotated[ - uuid.UUID | None, - Parameter( - name=["event", "event-id"], - help="Process a specific event instead of default one (if set). " - "Full UUID or any unique prefix as shown in the table.", - converter=_event_id_converter, - ), - ] = None - - -@dataclass -class _ByAliasTrait: - by_alias: Annotated[ - bool, - Parameter( - name="alias", - help="Dump records using their alias instead of attribute names.", - ), - ] = False - - -@Parameter(name="*") -@dataclass -class DebugParameter(_DebugTrait): - pass - - -@Parameter(name="*") -@dataclass -class JsonDumpParameters(_ByAliasTrait, _DebugTrait): - pass - - -@Parameter(name="*") -@dataclass -class GlobalParameters(_DebugTrait, _AllEventsTrait, _EventContextTrait): - """Parameters for commands that operate on individual or all events, with optional debug mode.""" - - pass - - -@Parameter(name="*") -@dataclass -class IccsPlotParameters: - context: bool = True - "Plot seismograms with extra context instead of the short tapered ones used for cross-correlation." - all_seismograms: Annotated[bool, Parameter(name="all")] = False - "Include all seismograms in the plot, even if not used in stack." - - -@Parameter(name="*") -@dataclass -class TableParameters: - short: bool = True - "Shorten UUIDs and format data." - - -# ------------------------------------------------- -# Decorators -# ------------------------------------------------- - - -def print_error_panel(e: Exception) -> None: - """Print an exception to the console in a red panel.""" - from rich.console import Console - from rich.panel import Panel - - console = Console(stderr=True) - panel = Panel( - f"{e}", - title="Error", - title_align="left", - border_style="red", - expand=True, - ) - console.print(panel) - - -def simple_exception[F: Callable[..., Any]](func: F) -> F: - """Decorator to handle exceptions and print them to the console. - - Using this decorator prints only the exception to the console without - traceback, and then exits. In debugging mode this decorator returns the - callable unchanged. - """ - import sys - from functools import wraps - - @wraps(func) - def wrapper(*args: Any, **kwargs: Any) -> Any: - if settings.log_level in ("TRACE", "DEBUG"): - return func(*args, **kwargs) - try: - return func(*args, **kwargs) - except Exception as e: - print_error_panel(e) - sys.exit(1) - - return wrapper # type: ignore diff --git a/src/aimbat/_cli/common/__init__.py b/src/aimbat/_cli/common/__init__.py new file mode 100644 index 00000000..75cf1a36 --- /dev/null +++ b/src/aimbat/_cli/common/__init__.py @@ -0,0 +1,10 @@ +# flake8: noqa: E402, F403 +_internal_names = set(dir()) + +from ._decorators import * +from ._parameters import * +from ._table import * + +__all__ = [s for s in dir() if not s.startswith("_") and s not in _internal_names] + +del _internal_names diff --git a/src/aimbat/_cli/common/_decorators.py b/src/aimbat/_cli/common/_decorators.py new file mode 100644 index 00000000..dce55d8b --- /dev/null +++ b/src/aimbat/_cli/common/_decorators.py @@ -0,0 +1,45 @@ +from collections.abc import Callable +from typing import Any + +from aimbat import settings + +__all__ = ["print_error_panel", "simple_exception"] + + +def print_error_panel(e: Exception) -> None: + """Print an exception to the console in a red panel.""" + from rich.console import Console + from rich.panel import Panel + + console = Console(stderr=True) + panel = Panel( + f"{e}", + title="Error", + title_align="left", + border_style="red", + expand=True, + ) + console.print(panel) + + +def simple_exception[F: Callable[..., Any]](func: F) -> F: + """Decorator to handle exceptions and print them to the console. + + Using this decorator prints only the exception to the console without + traceback, and then exits. In debugging mode this decorator returns the + callable unchanged. + """ + import sys + from functools import wraps + + @wraps(func) + def wrapper(*args: Any, **kwargs: Any) -> Any: + if settings.log_level in ("TRACE", "DEBUG"): + return func(*args, **kwargs) + try: + return func(*args, **kwargs) + except Exception as e: + print_error_panel(e) + sys.exit(1) + + return wrapper # type: ignore diff --git a/src/aimbat/_cli/common/_parameters.py b/src/aimbat/_cli/common/_parameters.py new file mode 100644 index 00000000..1f5d615a --- /dev/null +++ b/src/aimbat/_cli/common/_parameters.py @@ -0,0 +1,229 @@ +"""Common parameters and functions for the AIMBAT CLI.""" + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Annotated, Literal, overload +from uuid import UUID + +from cyclopts import Parameter, Token + +from aimbat import settings + +try: + from typing import TypeIs +except ImportError: + from typing_extensions import TypeIs + +__all__ = [ + "id_parameter", + "event_parameter", + "event_parameter_with_all", + "event_parameter_is_all", + "use_station_parameter", + "use_event_parameter", + "use_matrix_image", + "DebugParameter", + "EventDebugParameters", + "IccsPlotParameters", + "TableParameters", + "JsonDumpParameters", +] + + +# ----------------------------------------------------------------------- +# Shared Parameter instances and factories +# ----------------------------------------------------------------------- + + +@overload +def _make_uuid_converter( + model_class: type, allow_all: Literal[False] = ... +) -> Callable[..., UUID]: ... + + +@overload +def _make_uuid_converter( + model_class: type, allow_all: Literal[True] +) -> Callable[..., UUID | Literal["all"]]: ... + + +def _make_uuid_converter( + model_class: type, allow_all: bool = False +) -> Callable[..., UUID | Literal["all"]]: + """Return a cyclopts converter that resolves a UUID prefix for the given model. + + Args: + model_class: AIMBAT model class to resolve the UUID against. + allow_all: If True, the converter will also accept the string "all" + (case-insensitive) and return it as a literal. + """ + + def _converter(hint: type, tokens: tuple[Token, ...]) -> UUID | Literal["all"]: + (token,) = tokens + value = token.value + + if allow_all and value.lower() == "all": + return "all" + try: + return UUID(value) + except ValueError: + from sqlmodel import Session + + from aimbat.db import engine + from aimbat.utils import string_to_uuid + + with Session(engine) as session: + return string_to_uuid(session, value, model_class) + + return _converter + + +def id_parameter(model_class: type, help: str = "") -> Parameter: + return Parameter( + name="id", + help=help or "UUID (or any unique prefix).", + converter=_make_uuid_converter(model_class), + ) + + +def event_parameter(help: str | None = None) -> Parameter: + from aimbat.models import AimbatEvent + + return Parameter( + name=["event", "event-id"], + help=help or "UUID (or unique prefix) of event to process.", + env_var="DEFAULT_EVENT_ID", + converter=_make_uuid_converter(AimbatEvent), + ) + + +def event_parameter_with_all(help: str | None = None) -> Parameter: + from aimbat.models import AimbatEvent + + return Parameter( + name=["event", "event-id"], + help=help + or '"all" for all events, or UUID (or unique prefix) of event to process.', + env_var="DEFAULT_EVENT_ID", + converter=_make_uuid_converter(AimbatEvent, allow_all=True), + show_choices=False, + ) + + +def event_parameter_is_all(event_id: UUID | Literal["all"]) -> TypeIs[Literal["all"]]: + if isinstance(event_id, str) and event_id.lower() == "all": + return True + return False + + +def use_station_parameter() -> Parameter: + from aimbat.models import AimbatStation + + return Parameter( + name="use-station", + help="UUID (or unique prefix) of an existing station to link to instead of" + " extracting one from each data source.", + converter=_make_uuid_converter(AimbatStation), + ) + + +def use_event_parameter() -> Parameter: + from aimbat.models import AimbatEvent + + return Parameter( + name="use-event", + help="UUID (or unique prefix) of an existing event to link to instead of" + " extracting one from each data source.", + converter=_make_uuid_converter(AimbatEvent), + ) + + +def use_matrix_image() -> Parameter: + return Parameter( + name="matrix", + help="Use matrix image instead of stack plot.", + ) + + +# ----------------------------------------------------------------------- +# Common parameters +# ----------------------------------------------------------------------- + + +@dataclass +class _DebugTrait: + debug: bool = False + """Enable verbose logging for troubleshooting.""" + + # NOTE: only one __post_init__ is allowed per dataclass + def __post_init__(self) -> None: + if self.debug: + settings.log_level = "DEBUG" + from aimbat.logger import configure_logging + + configure_logging() + + +@dataclass +class _EventContextTrait: + event_id: Annotated[UUID, event_parameter()] + + +@dataclass +class _TableParametersTrait: + raw: bool = False + + +@dataclass +class _ByAliasTrait: + by_alias: Annotated[ + bool, + Parameter( + name="alias", + help="Dump records using their alias instead of attribute names.", + ), + ] = False + + +@Parameter(name="*") +@dataclass +class EventDebugParameters(_DebugTrait, _EventContextTrait): + """Parameters for commands that operate on individual events, with optional debug mode.""" + + pass + + +@Parameter(name="*") +@dataclass +class JsonDumpParameters(_ByAliasTrait, _DebugTrait): + pass + + +@Parameter(name="*") +@dataclass +class TableParameters(_TableParametersTrait, _DebugTrait): + pass + + +@Parameter(name="*") +@dataclass +class DebugParameter(_DebugTrait): + pass + + +@Parameter(name="*") +@dataclass +class IccsPlotParameters: + context: Annotated[ + bool, + Parameter( + help="Plot seismograms with extra context instead of the short tapered ones used for cross-correlation" + ), + ] = True + all_seismograms: Annotated[ + bool, + Parameter( + name="all", + help="Include all seismograms in the plot, even if not used in stack.", + ), + ] = False diff --git a/src/aimbat/_cli/common/_table.py b/src/aimbat/_cli/common/_table.py new file mode 100644 index 00000000..b6f80638 --- /dev/null +++ b/src/aimbat/_cli/common/_table.py @@ -0,0 +1,197 @@ +import types +from datetime import datetime +from typing import ( + Annotated, + Any, + TypeAliasType, + Union, + get_args, + get_origin, +) + +from pandas import NaT, Timedelta, Timestamp, to_datetime +from pydantic import BaseModel +from rich.console import Console +from rich.table import Table + +from aimbat.models import RichColSpec +from aimbat.utils.formatters import fmt_bool, fmt_float, fmt_timedelta, fmt_timestamp + +__all__ = ["json_to_table"] + + +def _justify_for_annotation(annotation: Any) -> str | None: + """Infer a default column justification from a field's type annotation. + + Fully unwraps `X | None` unions, `Annotated[X, ...]` layers, and PEP 695 + `type` aliases (`TypeAliasType`) before checking the concrete type. Returns + `"right"` for numeric and Timedelta types, `"center"` for booleans, and + `None` for everything else (letting Rich use its default of left-aligned). + """ + while True: + origin = get_origin(annotation) + if origin is Union or origin is types.UnionType: + non_none = [a for a in get_args(annotation) if a is not type(None)] + annotation = non_none[0] if len(non_none) == 1 else annotation + elif origin is Annotated: + annotation = get_args(annotation)[0] + elif isinstance(annotation, TypeAliasType): + annotation = annotation.__value__ + else: + break + + if isinstance(annotation, type): + if issubclass(annotation, bool): + return "center" + if issubclass(annotation, (int, float, Timedelta)): + return "right" + return None + + +def json_to_table( + data: dict[str, Any] | list[dict[str, Any]], + model: type[BaseModel], + title: str | None = None, + raw: bool = False, + col_specs: dict[str, RichColSpec] | None = None, + column_order: list[str] | None = None, + key_header: str = "Property", + value_header: str = "Value", +) -> None: + """Print a JSON dict or list of dicts as a rich table driven by a Pydantic model. + + Args: + data: A single row (dict) or list of rows to display. + model: Pydantic model whose field metadata drives column configuration. + title: Optional table title. + raw: If `True`, ignore `RichColSpec` metadata and render using only + type-based heuristics. Useful for a quick unformatted view. + col_specs: Optional per-field overrides. Each entry is merged on top of + the spec derived from the model field, so only the attributes that + differ need to be set. Ignored when `raw=True`. + column_order: Optional list of field names that should appear first, in + that order. Fields not listed appear after in model-declaration order. + key_header: Header for the property-name column in vertical (dict) tables. + value_header: Header for the value column in vertical (dict) tables. + """ + console = Console() + table = Table(title=title) + + data_list = [data] if isinstance(data, dict) else data + if not data_list: + console.print(table) + return + + # Build specs from model field metadata (skipped in raw mode). + specs: dict[str, RichColSpec] = {} + if not raw: + for name, field in model.model_fields.items(): + spec: RichColSpec | None = None + # Pydantic Field uses json_schema_extra; SQLModel Field uses schema_extra + # which spreads its keys directly into _attributes_set on the FieldInfo. + for source in ( + field.json_schema_extra, + getattr(field, "_attributes_set", None), + ): + if isinstance(source, dict): + candidate = source.get("rich") + if isinstance(candidate, RichColSpec): + spec = candidate + break + specs[name] = spec if spec is not None else RichColSpec() + + # Apply caller-supplied overrides. model_fields_set tracks which fields + # were explicitly provided, so only those replace the model-derived spec. + if col_specs: + for name, override in col_specs.items(): + base = specs.get(name, RichColSpec()) + specs[name] = base.model_copy( + update={k: getattr(override, k) for k in override.model_fields_set} + ) + + def _ordered(names: list[str]) -> list[str]: + if not column_order: + return names + ordered = [n for n in column_order if n in names] + rest = [n for n in names if n not in set(column_order)] + return ordered + rest + + def _fmt_val(name: str, val: Any) -> str: + if raw: + return "" if val is None else str(val) + if val is None or val is NaT: + return "โ€”" + spec = specs.get(name) + if spec and spec.formatter: + return spec.formatter(val) + if isinstance(val, bool): + return fmt_bool(val) + if isinstance(val, float): + return fmt_float(val) + if isinstance(val, Timedelta): + return fmt_timedelta(val) + if isinstance(val, (Timestamp, datetime)): + return fmt_timestamp(val) + low_key = name.lower() + if ( + isinstance(val, str) + and val.strip() + and any(k in low_key for k in ("time", "date", "modified")) + ): + try: + dt = to_datetime(val) + return fmt_timestamp(dt) + except (ValueError, TypeError): + return str(val) + return str(val) + + field_names = _ordered(list(model.model_fields.keys())) + + if isinstance(data, dict): + # Vertical table + table.add_column(key_header, style="cyan") + table.add_column(value_header) + for name in field_names: + if name not in data: + continue + spec = specs.get(name) + if spec and spec.display_title: + label = spec.display_title + else: + field = model.model_fields[name] + label = field.title if field.title else name + table.add_row(label, _fmt_val(name, data[name])) + else: + # Horizontal table โ€” restrict to fields present in data. + data_keys: set[str] = set() + for item in data_list: + data_keys.update(item.keys()) + visible_fields = [n for n in field_names if n in data_keys] + + for name in visible_fields: + spec = specs.get(name) + field = model.model_fields[name] + header = ( + spec.display_title + if spec and spec.display_title + else field.title or name + ) + kwargs: dict[str, Any] = {} + default_justify = _justify_for_annotation(field.annotation) + if default_justify: + kwargs["justify"] = default_justify + if spec: + if spec.justify: + kwargs["justify"] = spec.justify + if spec.style: + kwargs["style"] = spec.style + if spec.no_wrap is not None: + kwargs["no_wrap"] = spec.no_wrap + if spec.highlight is not None: + kwargs["highlight"] = spec.highlight + table.add_column(header, **kwargs) + + for item in data: + table.add_row(*[_fmt_val(n, item.get(n)) for n in visible_fields]) + + console.print(table) diff --git a/src/aimbat/_cli/data.py b/src/aimbat/_cli/data.py index 449f0613..f510741a 100644 --- a/src/aimbat/_cli/data.py +++ b/src/aimbat/_cli/data.py @@ -20,7 +20,7 @@ aimbat project create aimbat data add *.sac aimbat event list # list events created from SAC headers -aimbat event default # optionally set default event for future commands +aimbat snapshot create "initial import" --event-id ``` Re-adding a data source that is already in the project is safe โ€” existing @@ -28,8 +28,9 @@ """ import uuid +from collections.abc import Sequence from pathlib import Path -from typing import Annotated +from typing import TYPE_CHECKING, Annotated, Literal from cyclopts import App, Parameter, validators from sqlmodel import Session @@ -38,17 +39,72 @@ from .common import ( DebugParameter, - GlobalParameters, JsonDumpParameters, TableParameters, + event_parameter_is_all, + event_parameter_with_all, simple_exception, use_event_parameter, use_station_parameter, ) +if TYPE_CHECKING: + from aimbat.models import AimbatDataSource + app = App(name="data", help=__doc__, help_format="markdown") +def _print_dry_run_results( + added_datasources: Sequence[AimbatDataSource], + existing_station_ids: set, + existing_event_ids: set, + existing_seismogram_ids: set, +) -> None: + """Print a summary table showing which entities were added vs skipped.""" + from pydantic import BaseModel, Field + from rich.console import Console + + from .common import json_to_table + + class _DryRunRow(BaseModel): + source: str = Field(title="Source") + station: bool = Field(title="Station") + event: bool = Field(title="Event") + seismogram: bool = Field(title="Seismogram") + + json_to_table( + [ + { + "source": str(ds.sourcename), + "station": ds.seismogram.station_id not in existing_station_ids, + "event": ds.seismogram.event_id not in existing_event_ids, + "seismogram": ds.seismogram_id not in existing_seismogram_ids, + } + for ds in added_datasources + ], + model=_DryRunRow, + title="Dry Run: Data to be added", + ) + new_stations = sum( + ds.seismogram.station_id not in existing_station_ids for ds in added_datasources + ) + new_events = sum( + ds.seismogram.event_id not in existing_event_ids for ds in added_datasources + ) + new_seismograms = sum( + ds.seismogram_id not in existing_seismogram_ids for ds in added_datasources + ) + console = Console() + console.print( + f"\n{new_stations} station(s) added, " + f"{len(added_datasources) - new_stations} skipped. " + f"{new_events} event(s) added, " + f"{len(added_datasources) - new_events} skipped. " + f"{new_seismograms} seismogram(s) added, " + f"{len(added_datasources) - new_seismograms} skipped." + ) + + @app.command(name="add") @simple_exception def cli_data_add( @@ -56,8 +112,9 @@ def cli_data_add( list[Path], Parameter( name="sources", - help="One or more data source file paths to add.", - consume_multiple=True, + help="One or more data source paths to add.", + consume_multiple=1, + negative_iterable=(), validator=validators.Path(exists=True), ), ], @@ -85,7 +142,7 @@ def cli_data_add( name="progress", help="Display a progress bar while ingesting sources." ), ] = True, - global_parameters: DebugParameter = DebugParameter(), + _: DebugParameter = DebugParameter(), ) -> None: """Add or update data sources in the AIMBAT project. @@ -107,7 +164,7 @@ def cli_data_add( disable_progress_bar = not show_progress_bar with Session(engine) as session: - add_data_to_project( + results = add_data_to_project( session, data_sources, data_type, @@ -116,6 +173,9 @@ def cli_data_add( dry_run=dry_run, disable_progress_bar=disable_progress_bar, ) + if results is not None: + if dry_run: + _print_dry_run_results(*results) @app.command(name="dump") @@ -140,47 +200,53 @@ def cli_data_dump( @app.command(name="list") @simple_exception def cli_data_list( + event_id: Annotated[uuid.UUID | Literal["all"], event_parameter_with_all()], *, table_parameters: TableParameters = TableParameters(), - global_parameters: GlobalParameters = GlobalParameters(), ) -> None: """Print a table of data sources registered in the AIMBAT project.""" from aimbat.core import dump_data_table, resolve_event from aimbat.db import engine from aimbat.logger import logger - from aimbat.models import AimbatDataSource, AimbatSeismogram - from aimbat.utils import json_to_table, uuid_shortener + from aimbat.models import AimbatDataSource, AimbatSeismogram, RichColSpec + from aimbat.utils import uuid_shortener + + from .common import json_to_table - short = table_parameters.short + raw = table_parameters.raw with Session(engine) as session: logger.debug("Printing data sources table.") - if global_parameters.all_events: - data = dump_data_table(session, by_title=True) + if event_parameter_is_all(event_id): + data = dump_data_table(session) title = "Data sources for all events" else: - event = resolve_event(session, global_parameters.event_id) - data = dump_data_table(session, event.id, by_title=True) - _time = event.time.strftime("%Y-%m-%d %H:%M:%S") if short else event.time - _id = uuid_shortener(session, event) if short else event.id + event = resolve_event(session, event_id) + data = dump_data_table(session, event.id) + _time = event.time.strftime("%Y-%m-%d %H:%M:%S") if not raw else event.time + _id = uuid_shortener(session, event) if not raw else event.id title = f"Data sources for event {_time} (ID={_id})" - formatters = { - "ID": lambda x: ( - uuid_shortener(session, AimbatDataSource, str_uuid=x) if short else x + col_specs = { + "id": RichColSpec( + formatter=lambda x: uuid_shortener( + session, AimbatDataSource, str_uuid=x + ) ), - "Seismogram ID": lambda x: ( - uuid_shortener(session, AimbatSeismogram, str_uuid=x) if short else x + "seismogram_id": RichColSpec( + formatter=lambda x: uuid_shortener( + session, AimbatSeismogram, str_uuid=x + ) ), } - column_order = ["ID"] json_to_table( - data, + model=AimbatDataSource, + data=data, title=title, - formatters=formatters, - column_order=column_order, + raw=raw, + col_specs=col_specs, ) diff --git a/src/aimbat/_cli/event.py b/src/aimbat/_cli/event.py index c7a55361..cfd599aa 100644 --- a/src/aimbat/_cli/event.py +++ b/src/aimbat/_cli/event.py @@ -1,7 +1,7 @@ """View and manage events in the AIMBAT project.""" import uuid -from typing import Annotated +from typing import Annotated, Literal from cyclopts import App from sqlmodel import Session @@ -10,19 +10,18 @@ from aimbat.models import AimbatEvent from .common import ( - ALL_EVENTS_PARAMETER, DebugParameter, - GlobalParameters, + EventDebugParameters, JsonDumpParameters, TableParameters, event_parameter, - id_parameter, + event_parameter_is_all, + event_parameter_with_all, simple_exception, ) __all__ = [ "cli_event_delete", - "cli_event_default", "cli_event_dump", "cli_event_list", "cli_event_parameter_get", @@ -32,42 +31,16 @@ ] app = App(name="event", help=__doc__, help_format="markdown") -parameter = App( +_parameter = App( name="parameter", help="Manage event parameters.", help_format="markdown" ) -app.command(parameter) - - -@app.command(name="default") -@simple_exception -def cli_event_default( - new_default_event_id: Annotated[ - uuid.UUID, - id_parameter( - AimbatEvent, - help="Full UUID or unique prefix of event ID to set as default.", - ), - ], - /, - *, - _: DebugParameter = DebugParameter(), -) -> None: - """Select default event for CLI commands. - - Sets an event to be used by default when no explicit event ID is given. - Avoids having to specify an event ID for every command. - """ - from aimbat.core import set_default_event - from aimbat.db import engine - - with Session(engine) as session: - set_default_event(session, new_default_event_id) +app.command(_parameter) @app.command(name="delete") @simple_exception def cli_event_delete( - event_id: Annotated[uuid.UUID | None, event_parameter()] = None, + event_id: Annotated[uuid.UUID, event_parameter()], *, _: DebugParameter = DebugParameter(), ) -> None: @@ -103,43 +76,38 @@ def cli_event_dump( def cli_event_list( *, table_parameters: TableParameters = TableParameters(), - global_parameters: GlobalParameters = GlobalParameters(), ) -> None: - """Print a table of events stored in the AIMBAT project. - - The default event is highlighted. Use `event default` to change which event - is processed by subsequent commands. - """ + """Print a table of events stored in the AIMBAT project.""" from aimbat.core import dump_event_table from aimbat.db import engine from aimbat.logger import logger - from aimbat.utils import TABLE_STYLING, json_to_table + from aimbat.models import AimbatEventRead - if short := table_parameters.short: - exclude = {"id"} - else: + from .common import json_to_table + + if raw := table_parameters.raw: exclude = {"short_id"} + else: + exclude = {"id"} with Session(engine) as session: logger.info("Printing AIMBAT events table.") json_to_table( data=dump_event_table( - session, from_read_model=True, by_title=True, exclude=exclude + session, from_read_model=True, by_title=False, exclude=exclude ), + model=AimbatEventRead, title="AIMBAT Events", - formatters={"Default": TABLE_STYLING.bool_formatter}, - short=short, + raw=raw, ) -@parameter.command(name="get") +@_parameter.command(name="get") @simple_exception def cli_event_parameter_get( - name: EventParameter, - *, - global_parameters: GlobalParameters = GlobalParameters(), + name: EventParameter, *, event_debug_parameters: EventDebugParameters ) -> None: """Get parameter value for an event. @@ -152,19 +120,18 @@ def cli_event_parameter_get( from aimbat.core import resolve_event from aimbat.db import engine + event_id = event_debug_parameters.event_id + with Session(engine) as session: - event = resolve_event(session, global_parameters.event_id) + event = resolve_event(session, event_id) value = event.parameters.model_dump(mode="json").get(name) print(value) -@parameter.command(name="set") +@_parameter.command(name="set") @simple_exception def cli_event_parameter_set( - name: EventParameter, - value: str, - *, - global_parameters: GlobalParameters = GlobalParameters(), + name: EventParameter, value: str, *, event_debug_parameters: EventDebugParameters ) -> None: """Set parameter value for an event. @@ -177,42 +144,40 @@ def cli_event_parameter_set( from aimbat.core import resolve_event, set_event_parameter from aimbat.db import engine + event_id = event_debug_parameters.event_id + with Session(engine) as session: - event = resolve_event(session, global_parameters.event_id) + event = resolve_event(session, event_id) set_event_parameter(session, event.id, name, value, validate_iccs=True) -@parameter.command(name="dump") +@_parameter.command(name="dump") @simple_exception def cli_event_parameter_dump( *, - all_events: Annotated[bool, ALL_EVENTS_PARAMETER] = False, - global_parameters: GlobalParameters = GlobalParameters(), + dump_parameters: JsonDumpParameters = JsonDumpParameters(), ) -> None: """Dump event parameter table to json.""" from rich import print_json from sqlmodel import Session - from aimbat.core import dump_event_parameter_table, resolve_event + from aimbat.core import dump_event_parameter_table from aimbat.db import engine + by_alias = dump_parameters.by_alias + with Session(engine) as session: - if all_events: - print_json(data=dump_event_parameter_table(session, by_alias=True)) - else: - event = resolve_event(session, global_parameters.event_id) - print_json(event.parameters.model_dump_json(by_alias=True)) + print_json(data=dump_event_parameter_table(session, by_alias=by_alias)) -@parameter.command(name="list") +@_parameter.command(name="list") @simple_exception def cli_event_parameter_list( + event_id: Annotated[uuid.UUID | Literal["all"], event_parameter_with_all()], *, - all_events: Annotated[bool, ALL_EVENTS_PARAMETER] = False, - global_parameters: GlobalParameters = GlobalParameters(), table_parameters: TableParameters = TableParameters(), ) -> None: - """List processing parameter values for the default event. + """List processing parameter for an event or all events. Displays all event-level parameters (e.g. time window, bandpass filter settings, minimum cc) in a table. @@ -220,55 +185,43 @@ def cli_event_parameter_list( from aimbat.core import dump_event_parameter_table, resolve_event from aimbat.db import engine - from aimbat.utils import TABLE_STYLING, json_to_table, uuid_shortener + from aimbat.models import AimbatEventParameters, RichColSpec + from aimbat.utils import uuid_shortener + + from .common import json_to_table - short = table_parameters.short + raw = table_parameters.raw with Session(engine) as session: - if all_events: + if event_parameter_is_all(event_id): title = "Event parameters for all events" - data = dump_event_parameter_table(session, by_title=True) + data = dump_event_parameter_table(session) else: - event = resolve_event(session, global_parameters.event_id) - title = f"Event parameters for event: {uuid_shortener(session, event) if short else str(event.id)}" + event = resolve_event(session, event_id) + title = f"Event parameters for event: {uuid_shortener(session, event) if not raw else str(event.id)}" data = dump_event_parameter_table( - session, event_id=event.id, by_title=True, exclude={"event_id"} + session, event_id=event.id, exclude={"event_id"} ) + column_order = ["id"] + col_specs = { + "id": RichColSpec( + formatter=lambda x: uuid_shortener( + session, AimbatEventParameters, str_uuid=x + ) + ), + "event_id": RichColSpec( + formatter=lambda x: uuid_shortener(session, AimbatEvent, str_uuid=x) + ), + } + json_to_table( + model=AimbatEventParameters, title=title, data=data, - skip_keys=["ID"], - formatters={ - "Event ID": lambda x: ( - uuid_shortener(session, AimbatEvent, str_uuid=x) - if short - else str(x) - ), - "Completed": TABLE_STYLING.bool_formatter, - "Bandpass apply": TABLE_STYLING.bool_formatter, - }, - column_order=[ - "Event ID", - "Completed", - "Window pre", - "Window post", - "Ramp width", - "Bandpass apply", - "Bandpass f min", - "Bandpass f max", - "Min CC", - ], - common_column_kwargs={"highlight": True}, - column_kwargs={ - "Event ID": { - "justify": "center", - "no_wrap": True, - "style": TABLE_STYLING.mine, - }, - "Completed": {"justify": "center"}, - "Bandpass apply": {"justify": "center"}, - }, + raw=raw, + col_specs=col_specs, + column_order=column_order, ) diff --git a/src/aimbat/_cli/pick.py b/src/aimbat/_cli/pick.py index 80126e45..6377dc4f 100644 --- a/src/aimbat/_cli/pick.py +++ b/src/aimbat/_cli/pick.py @@ -1,15 +1,23 @@ """Interactively pick phase arrival times and processing parameters. -These commands open an interactive matplotlib plot for the default event. -Click on the plot to set the chosen value, then close the window to save it. -Use `aimbat event default` to switch the default event before picking. +These commands open an interactive matplotlib plot for an event. Use +`--event-id` or set the `DEFAULT_EVENT_ID` environment variable to choose +which event to pick. Click on the plot to set the chosen value, then close +the window to save it. """ from typing import Annotated +from uuid import UUID -from cyclopts import App, Parameter +from cyclopts import App -from .common import GlobalParameters, IccsPlotParameters, simple_exception +from .common import ( + DebugParameter, + IccsPlotParameters, + event_parameter, + simple_exception, + use_matrix_image, +) app = App(name="pick", help=__doc__, help_format="markdown") @@ -17,18 +25,16 @@ @app.command(name="phase") @simple_exception def cli_update_phase_pick( + event_id: Annotated[UUID, event_parameter()], *, - iccs_parameters: IccsPlotParameters = IccsPlotParameters(), - use_matrix_image: Annotated[bool, Parameter(name="img")] = False, - global_parameters: GlobalParameters = GlobalParameters(), + iccs_plot_parameters: IccsPlotParameters = IccsPlotParameters(), + use_matrix_image: Annotated[bool, use_matrix_image()] = False, + _: DebugParameter = DebugParameter(), ) -> None: """Interactively pick a new phase arrival time (t1) for an event. Opens an interactive plot; click to place the new pick, then close the window to save. The pick is stored as `t1` for each seismogram in the ICCS instance. - - Args: - use_matrix_image: If True, pick from the matrix image; otherwise pick from the stack plot. """ from sqlmodel import Session @@ -37,13 +43,13 @@ def cli_update_phase_pick( from aimbat.plot import update_pick with Session(engine) as session: - event = resolve_event(session, global_parameters.event_id) + event = resolve_event(session, event_id) iccs = create_iccs_instance(session, event).iccs update_pick( session, iccs, - iccs_parameters.context, - all_seismograms=iccs_parameters.all_seismograms, + context=iccs_plot_parameters.context, + all_seismograms=iccs_plot_parameters.all_seismograms, use_matrix_image=use_matrix_image, return_fig=False, ) @@ -52,19 +58,17 @@ def cli_update_phase_pick( @app.command(name="window") @simple_exception def cli_pick_timewindow( + event_id: Annotated[UUID, event_parameter()], *, - iccs_parameters: IccsPlotParameters = IccsPlotParameters(), - use_matrix_image: Annotated[bool, Parameter(name="img")] = False, - global_parameters: GlobalParameters = GlobalParameters(), + iccs_plot_parameters: IccsPlotParameters = IccsPlotParameters(), + use_matrix_image: Annotated[bool, use_matrix_image()] = False, + _: DebugParameter = DebugParameter(), ) -> None: """Interactively pick a new cross-correlation time window for an event. Opens an interactive plot; click to set the left and right window boundaries, then close the window to save. The window controls which portion of each seismogram is used during ICCS alignment. - - Args: - use_matrix_image: If True, pick from the matrix image; otherwise pick from the stack plot. """ from sqlmodel import Session @@ -73,14 +77,14 @@ def cli_pick_timewindow( from aimbat.plot import update_timewindow with Session(engine) as session: - event = resolve_event(session, global_parameters.event_id) + event = resolve_event(session, event_id) iccs = create_iccs_instance(session, event).iccs update_timewindow( session, event, iccs, - iccs_parameters.context, - all_seismograms=iccs_parameters.all_seismograms, + iccs_plot_parameters.context, + all_seismograms=iccs_plot_parameters.all_seismograms, use_matrix_image=use_matrix_image, return_fig=False, ) @@ -89,9 +93,11 @@ def cli_pick_timewindow( @app.command(name="cc") @simple_exception def cli_pick_min_cc( + event_id: Annotated[UUID, event_parameter()], *, - iccs_parameters: IccsPlotParameters = IccsPlotParameters(), - global_parameters: GlobalParameters = GlobalParameters(), + iccs_plot_parameters: IccsPlotParameters = IccsPlotParameters(), + use_matrix_image: Annotated[bool, use_matrix_image()] = True, + _: DebugParameter = DebugParameter(), ) -> None: """Interactively pick a new minimum cross-correlation for auto-selection. @@ -106,14 +112,14 @@ def cli_pick_min_cc( from aimbat.plot import update_min_cc with Session(engine) as session: - event = resolve_event(session, global_parameters.event_id) + event = resolve_event(session, event_id) iccs = create_iccs_instance(session, event).iccs update_min_cc( session, event, iccs, - iccs_parameters.context, - all_seismograms=iccs_parameters.all_seismograms, + iccs_plot_parameters.context, + all_seismograms=iccs_plot_parameters.all_seismograms, return_fig=False, ) diff --git a/src/aimbat/_cli/plot.py b/src/aimbat/_cli/plot.py index 0c8502b2..ef5e72ae 100644 --- a/src/aimbat/_cli/plot.py +++ b/src/aimbat/_cli/plot.py @@ -10,9 +10,17 @@ waveform context, and `--all` to include deselected seismograms. """ +from typing import Annotated +from uuid import UUID + from cyclopts import App -from .common import GlobalParameters, IccsPlotParameters, simple_exception +from .common import ( + DebugParameter, + IccsPlotParameters, + event_parameter, + simple_exception, +) app = App(name="plot", help=__doc__, help_format="markdown") @@ -20,8 +28,9 @@ @app.command(name="seismograms") @simple_exception def cli_seismogram_plot( + event_id: Annotated[UUID, event_parameter()], *, - global_parameters: GlobalParameters = GlobalParameters(), + _: DebugParameter = DebugParameter(), ) -> None: """Plot input seismograms in an event sorted by epicentral distance.""" from sqlmodel import Session @@ -31,16 +40,17 @@ def cli_seismogram_plot( from aimbat.plot import plot_seismograms with Session(engine) as session: - event = resolve_event(session, global_parameters.event_id) + event = resolve_event(session, event_id) plot_seismograms(session, event, return_fig=False) @app.command(name="stack") @simple_exception def cli_plot_stack( + event_id: Annotated[UUID, event_parameter()], *, iccs_plot_parameters: IccsPlotParameters = IccsPlotParameters(), - global_parameters: GlobalParameters = GlobalParameters(), + _: DebugParameter = DebugParameter(), ) -> None: """Plot the ICCS stack of an event.""" from sqlmodel import Session @@ -50,7 +60,7 @@ def cli_plot_stack( from aimbat.plot import plot_stack with Session(engine) as session: - event = resolve_event(session, global_parameters.event_id) + event = resolve_event(session, event_id) iccs = create_iccs_instance(session, event).iccs plot_stack( iccs, @@ -63,9 +73,10 @@ def cli_plot_stack( @app.command(name="matrix") @simple_exception def cli_plot_matrix_image( + event_id: Annotated[UUID, event_parameter()], *, iccs_plot_parameters: IccsPlotParameters = IccsPlotParameters(), - global_parameters: GlobalParameters = GlobalParameters(), + _: DebugParameter = DebugParameter(), ) -> None: """Plot the ICCS seismograms of an event as a matrix image. @@ -82,7 +93,7 @@ def cli_plot_matrix_image( from aimbat.plot import plot_matrix_image with Session(engine) as session: - event = resolve_event(session, global_parameters.event_id) + event = resolve_event(session, event_id) iccs = create_iccs_instance(session, event).iccs plot_matrix_image( iccs, diff --git a/src/aimbat/_cli/project.py b/src/aimbat/_cli/project.py index 156aec38..9761099d 100644 --- a/src/aimbat/_cli/project.py +++ b/src/aimbat/_cli/project.py @@ -9,9 +9,16 @@ executed with a database url directly. """ +from typing import Annotated +from uuid import UUID + from cyclopts import App -from .common import DebugParameter, GlobalParameters, simple_exception +from .common import ( + DebugParameter, + event_parameter, + simple_exception, +) app = App(name="project", help=__doc__, help_format="markdown") @@ -43,7 +50,9 @@ def cli_project_delete(*, _: DebugParameter = DebugParameter()) -> None: @app.command(name="info") @simple_exception def cli_project_info( - *, global_parameters: GlobalParameters = GlobalParameters() + event_id: Annotated[UUID | None, event_parameter()] = None, + *, + _: DebugParameter = DebugParameter(), ) -> None: """Show information on an existing project.""" @@ -59,11 +68,8 @@ def cli_project_info( from aimbat.core import resolve_event from aimbat.core._project import _project_exists from aimbat.db import engine - from aimbat.logger import logger from aimbat.models import AimbatEvent, AimbatSeismogram, AimbatStation - logger.info("Printing project info.") - if not _project_exists(engine): raise RuntimeError( 'No AIMBAT project found. Try running "aimbat project create" first.' @@ -93,7 +99,7 @@ def cli_project_info( ) try: - target_event = resolve_event(session, global_parameters.event_id) + target_event = resolve_event(session, event_id) target_event_id = target_event.id active_stations = len( station.get_stations_in_event(session, target_event.id) @@ -108,11 +114,7 @@ def cli_project_info( seismograms_in_event = None selected_seismograms_in_event = None - event_label = ( - "Selected Event ID: " - if global_parameters.event_id - else "Default Event ID: " - ) + event_label = "Selected Event ID: " grid.add_row(event_label, f"{target_event_id}") grid.add_row( "Number of Stations in Project (total/selected event): ", diff --git a/src/aimbat/_cli/quality.py b/src/aimbat/_cli/quality.py deleted file mode 100644 index 3656ccaa..00000000 --- a/src/aimbat/_cli/quality.py +++ /dev/null @@ -1,232 +0,0 @@ -"""View alignment quality metrics.""" - -import json -import uuid -from enum import StrEnum -from typing import Annotated, Any - -from cyclopts import App - -from aimbat.models import AimbatSeismogram, AimbatStation -from aimbat.models._quality import AimbatSeismogramQualityBase - -from .common import ( - DebugParameter, - GlobalParameters, - id_parameter, - simple_exception, -) - - -class SeismogramQualityField(StrEnum): - """Available seismogram-level quality metric field names.""" - - mccc_cc_mean = "mccc_cc_mean" - mccc_cc_std = "mccc_cc_std" - mccc_error = "mccc_error" - - -class EventQualityField(StrEnum): - """Available event-level quality metric field names.""" - - mccc_rmse = "mccc_rmse" - - -app = App(name="quality", help=__doc__, help_format="markdown") -_seismogram = App( - name="seismogram", help="Seismogram alignment quality.", help_format="markdown" -) -_event = App(name="event", help="Event alignment quality.", help_format="markdown") -_station = App( - name="station", help="Station alignment quality.", help_format="markdown" -) -app.command(_seismogram) -app.command(_event) -app.command(_station) - - -def _fmt(v: Any) -> str: - """Format a quality metric value for display.""" - if v is None: - return "โ€”" - if isinstance(v, bool): - return "โœ“" if v else "โœ—" - if isinstance(v, float): - return f"{v:.5f}" - return str(v) - - -# --------------------------------------------------------------------------- -# Seismogram -# --------------------------------------------------------------------------- - - -@_seismogram.command(name="list") -@simple_exception -def cli_quality_seismogram_list( - seismogram_id: Annotated[uuid.UUID, id_parameter(AimbatSeismogram)], - *, - _: DebugParameter = DebugParameter(), -) -> None: - """Show quality metrics for a seismogram as a table.""" - from sqlmodel import Session - - from aimbat.core import get_quality_seismogram - from aimbat.db import engine - from aimbat.utils import json_to_table - - with Session(engine) as session: - quality = get_quality_seismogram(session, seismogram_id) - - data: dict[str, Any] - _skip = {"id", "seismogram_id", "snapshot_id"} - if quality is None: - data = { - k: None for k in AimbatSeismogramQualityBase.model_fields if k not in _skip - } - else: - data = quality.model_dump(mode="json") - - json_to_table( - data, - title=f"Quality โ€” Seismogram {str(seismogram_id)[:8]}", - skip_keys=list(_skip), - formatters={k: _fmt for k in data}, - ) - - -@_seismogram.command(name="dump") -@simple_exception -def cli_quality_seismogram_dump( - seismogram_id: Annotated[uuid.UUID, id_parameter(AimbatSeismogram)], - *, - _: DebugParameter = DebugParameter(), -) -> None: - """Dump seismogram quality metrics as JSON.""" - from rich import print_json - from sqlmodel import Session - - from aimbat.core import get_quality_seismogram - from aimbat.db import engine - - with Session(engine) as session: - quality = get_quality_seismogram(session, seismogram_id) - - if quality is None: - data: dict[str, Any] = { - "seismogram_id": str(seismogram_id), - **{ - k: None - for k in AimbatSeismogramQualityBase.model_fields - if k not in ("id", "seismogram_id", "snapshot_id") - }, - } - else: - data = quality.model_dump(mode="json") - - print_json(json.dumps(data)) - - -# --------------------------------------------------------------------------- -# Event -# --------------------------------------------------------------------------- - - -@_event.command(name="list") -@simple_exception -def cli_quality_event_list( - *, - global_parameters: GlobalParameters = GlobalParameters(), -) -> None: - """Show quality metrics for an event as a table.""" - from sqlmodel import Session - - from aimbat.core import dump_quality_event, resolve_event - from aimbat.db import engine - from aimbat.utils import json_to_table - - with Session(engine) as session: - event = resolve_event(session, global_parameters.event_id) - data = dump_quality_event(session, event.id) - title = f"Quality โ€” Event {str(event.id)[:8]}" - - json_to_table( - data, - title=title, - skip_keys=["id", "event_id"], - formatters={k: _fmt for k in data}, - ) - - -@_event.command(name="dump") -@simple_exception -def cli_quality_event_dump( - *, - global_parameters: GlobalParameters = GlobalParameters(), -) -> None: - """Dump event quality metrics as JSON.""" - from rich import print_json - from sqlmodel import Session - - from aimbat.core import dump_quality_event, resolve_event - from aimbat.db import engine - - with Session(engine) as session: - event = resolve_event(session, global_parameters.event_id) - data = dump_quality_event(session, event.id) - - print_json(json.dumps(data)) - - -# --------------------------------------------------------------------------- -# Station -# --------------------------------------------------------------------------- - - -@_station.command(name="list") -@simple_exception -def cli_quality_station_list( - station_id: Annotated[uuid.UUID, id_parameter(AimbatStation)], - *, - _: DebugParameter = DebugParameter(), -) -> None: - """Show quality metrics for a station as a table.""" - from sqlmodel import Session - - from aimbat.core import dump_quality_station - from aimbat.db import engine - from aimbat.utils import json_to_table - - with Session(engine) as session: - data = dump_quality_station(session, station_id) - - json_to_table( - data, - title=f"Quality โ€” Station {str(station_id)[:8]}", - skip_keys=["station_id"], - formatters={k: _fmt for k in data}, - ) - - -@_station.command(name="dump") -@simple_exception -def cli_quality_station_dump( - station_id: Annotated[uuid.UUID, id_parameter(AimbatStation)], - *, - _: DebugParameter = DebugParameter(), -) -> None: - """Dump station quality metrics as JSON.""" - from rich import print_json - from sqlmodel import Session - - from aimbat.core import dump_quality_station - from aimbat.db import engine - - with Session(engine) as session: - data = dump_quality_station(session, station_id) - - print_json(json.dumps(data)) - - -if __name__ == "__main__": - app() diff --git a/src/aimbat/_cli/seismogram.py b/src/aimbat/_cli/seismogram.py index 0ba383ec..4abce65b 100644 --- a/src/aimbat/_cli/seismogram.py +++ b/src/aimbat/_cli/seismogram.py @@ -1,19 +1,20 @@ """View and manage seismograms in the AIMBAT project.""" -import uuid -from typing import Annotated +from typing import Annotated, Literal +from uuid import UUID from cyclopts import App from aimbat._types import SeismogramParameter +from aimbat.models import AimbatSeismogram from .common import ( - ALL_EVENTS_PARAMETER, DebugParameter, - GlobalParameters, JsonDumpParameters, TableParameters, - seismogram_parameter, + event_parameter_is_all, + event_parameter_with_all, + id_parameter, simple_exception, ) @@ -27,7 +28,12 @@ @app.command(name="delete") @simple_exception def cli_seismogram_delete( - seismogram_id: Annotated[uuid.UUID, seismogram_parameter()], + seismogram_id: Annotated[ + UUID, + id_parameter( + AimbatSeismogram, help="UUID (or unique prefix) of seismogram to delete." + ), + ], *, _: DebugParameter = DebugParameter(), ) -> None: @@ -66,53 +72,46 @@ def cli_seismogram_dump( @app.command(name="list") @simple_exception def cli_seismogram_list( + event_id: Annotated[UUID | Literal["all"], event_parameter_with_all()], *, - all_events: Annotated[bool, ALL_EVENTS_PARAMETER] = False, table_parameters: TableParameters = TableParameters(), - global_parameters: GlobalParameters = GlobalParameters(), ) -> None: """Print information on the seismograms in an event.""" from sqlmodel import Session from aimbat.core import dump_seismogram_table, resolve_event from aimbat.db import engine - from aimbat.utils import TABLE_STYLING, json_to_table, uuid_shortener + from aimbat.models import AimbatSeismogramRead + from aimbat.utils import uuid_shortener - if short := table_parameters.short: - exclude = {"id", "event_id"} - else: + from .common import json_to_table + + if raw := table_parameters.raw: exclude = {"short_id", "short_event_id"} + else: + exclude = {"id", "event_id"} with Session(engine) as session: - if all_events is True: + if event_parameter_is_all(event_id): title = "AIMBAT seismograms for all events" - data = dump_seismogram_table( - session, from_read_model=True, by_title=True, exclude=exclude - ) + data = dump_seismogram_table(session, from_read_model=True, exclude=exclude) else: - event = resolve_event(session, global_parameters.event_id) - if short: + event = resolve_event(session, event_id) + if raw: + title = f"AIMBAT seismograms for event {event.time} (ID={event.id})" + exclude.add("event_id") + else: title = f"AIMBAT seismograms for event {event.time.strftime('%Y-%m-%d %H:%M:%S')}" title += f" (ID={uuid_shortener(session, event)})" exclude.add("short_event_id") - else: - title = f"AIMBAT seismograms for event {event.time} (ID={event.id})" - exclude.add("event_id") data = dump_seismogram_table( session, from_read_model=True, - by_title=True, event_id=event.id, exclude=exclude, ) - json_to_table( - data, - title=title, - formatters={ - "Flip": TABLE_STYLING.flip_formatter, - }, - ) + json_to_table(data, model=AimbatSeismogramRead, title=title, raw=raw) if __name__ == "__main__": @@ -122,9 +121,14 @@ def cli_seismogram_list( @parameter.command(name="get") @simple_exception def cli_seismogram_parameter_get( - seismogram_id: Annotated[uuid.UUID, seismogram_parameter()], name: SeismogramParameter, *, + seismogram_id: Annotated[ + UUID, + id_parameter( + AimbatSeismogram, help="UUID (or unique prefix) of seismogram to query." + ), + ], _: DebugParameter = DebugParameter(), ) -> None: """Get the value of a processing parameter. @@ -149,10 +153,15 @@ def cli_seismogram_parameter_get( @parameter.command(name="set") @simple_exception def cli_seismogram_parameter_set( - seismogram_id: Annotated[uuid.UUID, seismogram_parameter()], name: SeismogramParameter, value: str, *, + seismogram_id: Annotated[ + UUID, + id_parameter( + AimbatSeismogram, help="UUID (or unique prefix) of seismogram to modify." + ), + ], _: DebugParameter = DebugParameter(), ) -> None: """Set value of a processing parameter. @@ -173,7 +182,13 @@ def cli_seismogram_parameter_set( @parameter.command(name="reset") @simple_exception def cli_seismogram_parameter_reset( - seismogram_id: Annotated[uuid.UUID, seismogram_parameter()], + seismogram_id: Annotated[ + UUID, + id_parameter( + AimbatSeismogram, + help="UUID (or unique prefix) of seismogram to reset parameters for.", + ), + ], *, _: DebugParameter = DebugParameter(), ) -> None: @@ -211,8 +226,8 @@ def cli_seismogram_parameter_dump( @parameter.command(name="list") @simple_exception def cli_seismogram_parameter_list( + event_id: Annotated[UUID | Literal["all"], event_parameter_with_all()], *, - global_parameters: GlobalParameters = GlobalParameters(), table_parameters: TableParameters = TableParameters(), ) -> None: """List processing parameter values for seismograms in an event. @@ -225,38 +240,49 @@ def cli_seismogram_parameter_list( from aimbat.core import dump_seismogram_parameter_table, resolve_event from aimbat.db import engine - from aimbat.models import AimbatSeismogram, AimbatSeismogramParameters - from aimbat.utils import TABLE_STYLING, json_to_table, uuid_shortener + from aimbat.models import AimbatSeismogram, AimbatSeismogramParameters, RichColSpec + from aimbat.utils import uuid_shortener + from aimbat.utils.formatters import fmt_flip + + from .common import json_to_table - short = table_parameters.short + raw = table_parameters.raw with Session(engine) as session: - if global_parameters.all_events: + if event_parameter_is_all(event_id): event = None title = "Seismogram parameters for all events" else: - event = resolve_event(session, global_parameters.event_id) - title = f"Seismogram parameters for event: {uuid_shortener(session, event) if short else str(event.id)}" + event = resolve_event(session, event_id) + title = f"Seismogram parameters for event: {uuid_shortener(session, event) if not raw else str(event.id)}" data = dump_seismogram_parameter_table( - session, event_id=event.id if event else None, by_title=True + session, event_id=event.id if event else None ) json_to_table( data=data, + model=AimbatSeismogramParameters, title=title, - column_order=["ID", "Seismogram ID", "Select"], - formatters={ - "ID": lambda x: ( - uuid_shortener(session, AimbatSeismogramParameters, str_uuid=x) - if short - else x + raw=raw, + col_specs={ + "id": RichColSpec( + style="yellow", + no_wrap=True, + highlight=False, + formatter=lambda x: uuid_shortener( + session, AimbatSeismogramParameters, str_uuid=str(x) + ), ), - "Seismogram ID": lambda x: ( - uuid_shortener(session, AimbatSeismogram, str_uuid=x) - if short - else x + "seismogram_id": RichColSpec( + style="magenta", + no_wrap=True, + highlight=False, + formatter=lambda x: uuid_shortener( + session, AimbatSeismogram, str_uuid=str(x) + ), ), - "Flip": TABLE_STYLING.flip_formatter, + "flip": RichColSpec(formatter=fmt_flip), }, + column_order=["id"], ) diff --git a/src/aimbat/_cli/shell.py b/src/aimbat/_cli/shell.py index 0eedc107..b741f6e5 100644 --- a/src/aimbat/_cli/shell.py +++ b/src/aimbat/_cli/shell.py @@ -4,18 +4,17 @@ or type `exit` to leave. Shell-only commands: - event switch [ID] Switch the shell's event context without changing the - database default. Omit the ID to reset to the default event. + event switch [ID] Switch the shell's event context. """ import uuid from typing import TYPE_CHECKING, Annotated -from cyclopts import App, Parameter +from cyclopts import App from .common import ( DebugParameter, - _event_id_converter, + event_parameter, print_error_panel, simple_exception, ) @@ -65,48 +64,6 @@ def _extract_event_flag(tokens: list[str]) -> str | None: return None -# Commands where --event must NOT be auto-injected even if the flag exists. -# These use --event for a purpose other than selecting the processing event. -_EVENT_INJECTION_EXCLUDED: frozenset[tuple[str, ...]] = frozenset( - { - ("data", "add"), - } -) - - -def _inject_event(tokens: list[str], event_id: uuid.UUID) -> list[str]: - """Append --event to tokens unless an event flag is already present.""" - if _extract_event_flag(tokens) is None: - return tokens + ["--event", str(event_id)] - return tokens - - -def _command_accepts_event_flag( - tokens: list[str], completion_dict: dict[str, dict | None] -) -> bool: - """Return True if the resolved command declares --event or --event-id.""" - node: dict | None = completion_dict - for tok in tokens: - if tok.startswith("-"): - break - if isinstance(node, dict) and tok in node: - node = node[tok] - else: - break - return isinstance(node, dict) and bool({"--event", "--event-id"} & node.keys()) - - -def _should_inject_event( - tokens: list[str], completion_dict: dict[str, dict | None] -) -> bool: - """Return True if the shell should auto-inject --event into this command.""" - non_flag = tuple(tok for tok in tokens if not tok.startswith("-")) - for excluded in _EVENT_INJECTION_EXCLUDED: - if non_flag[: len(excluded)] == excluded: - return False - return _command_accepts_event_flag(tokens, completion_dict) - - def _parse_event_id(value: str) -> uuid.UUID: """Parse a full UUID or unique prefix into a UUID. @@ -143,7 +100,7 @@ def _check_iccs( console: Rich console for output. prev: The BoundICCS from the previous check, or None. startup: If True, always print regardless of previous state. - event_id: Event to check ICCS for, or None to use the default event. + event_id: Event to check ICCS for. Returns: The current BoundICCS, or None if unavailable. @@ -175,20 +132,18 @@ def _check_iccs( @app.default @simple_exception def cli_shell( - *, event_id: Annotated[ uuid.UUID | None, - Parameter( - name=["--event", "--event-id"], + event_parameter( help="Start the shell in the context of a specific event. " "Full UUID or any unique prefix as shown in the table. " - "Does not change the database default event.", - converter=_event_id_converter, ), ] = None, + *, _: DebugParameter = DebugParameter(), ) -> None: """Start an interactive AIMBAT shell.""" + import os import shlex from pathlib import Path @@ -202,10 +157,25 @@ def cli_shell( console = Console() - # Shell-local event context โ€” None means "use DB default event". - # Modified by `event switch`; never written to the database. + # Shell-local event context โ€” None if no event specified. + # Modified by `event switch`. shell_event_id: uuid.UUID | None = event_id + # Propagate the shell event context to commands via the DEFAULT_EVENT_ID env + # var, which all event_parameter() / event_parameter_with_all() converters + # pick up as a fallback. Explicit --event flags always take precedence. + _prev_default_event = os.environ.get("DEFAULT_EVENT_ID") + + def _set_shell_event(eid: uuid.UUID | None) -> None: + nonlocal shell_event_id + shell_event_id = eid + if eid is not None: + os.environ["DEFAULT_EVENT_ID"] = str(eid) + else: + os.environ.pop("DEFAULT_EVENT_ID", None) + + _set_shell_event(shell_event_id) + completion_dict = _build_completion_dict(aimbat_app) completion_dict.pop("shell", None) completion_dict.pop("tui", None) @@ -236,71 +206,80 @@ def _prompt() -> str: return f"aimbat [{str(shell_event_id)[:8]}]> " return "aimbat> " - while True: - try: - if pt_session is not None: - text = pt_session.prompt(_prompt).strip() - else: - raw = sys.stdin.readline() - if not raw: - break - text = raw.strip() - except KeyboardInterrupt: - continue - except EOFError: - break - - if not text: - continue - if text in ("exit", "quit", "q"): - break + try: + while True: + try: + if pt_session is not None: + text = pt_session.prompt(_prompt).strip() + else: + raw = sys.stdin.readline() + if not raw: + break + text = raw.strip() + except KeyboardInterrupt: + continue + except EOFError: + break - try: - tokens = shlex.split(text) - except ValueError as exc: - console.print(f"[red]Parse error:[/red] {exc}") - continue + if not text: + continue + if text in ("exit", "quit", "q"): + break - # Strip a leading "aimbat" token typed out of habit and remind the user. - if tokens and tokens[0] == "aimbat": - tokens = tokens[1:] - console.print("[dim]Tip: no need to type 'aimbat' in the shell.[/dim]") - if not tokens: + try: + tokens = shlex.split(text) + except ValueError as exc: + console.print(f"[red]Parse error:[/red] {exc}") continue - # Shell-only: event switch [id] โ€” changes context without touching the DB. - if tokens[:2] == ["event", "switch"]: - if len(tokens) < 3: - # No argument: reset to DB default event. - shell_event_id = None - current_bound = _check_iccs(console, None, startup=True, event_id=None) - else: - try: - new_event_id = _parse_event_id(tokens[2]) - shell_event_id = new_event_id + # Strip a leading "aimbat" token typed out of habit and remind the user. + if tokens and tokens[0] == "aimbat": + tokens = tokens[1:] + console.print("[dim]Tip: no need to type 'aimbat' in the shell.[/dim]") + if not tokens: + continue + + # Shell-only: event switch [id] โ€” changes context. + if tokens[:2] == ["event", "switch"]: + if len(tokens) < 3: + # No argument: clear shell event context. + _set_shell_event(None) current_bound = _check_iccs( - console, None, startup=True, event_id=shell_event_id + console, None, startup=True, event_id=None ) - except Exception as exc: - print_error_panel(exc) - continue + else: + try: + _set_shell_event(_parse_event_id(tokens[2])) + current_bound = _check_iccs( + console, None, startup=True, event_id=shell_event_id + ) + except Exception as exc: + print_error_panel(exc) + continue - # Inject the shell event context only for commands that accept it and - # where automatic injection is appropriate. - if shell_event_id is not None and _should_inject_event(tokens, completion_dict): - tokens = _inject_event(tokens, shell_event_id) - - try: - aimbat_app(tokens, exit_on_error=False) - - # Check ICCS for whichever event was actually targeted by the command. - effective_flag = _extract_event_flag(tokens) - check_event_id = ( - _parse_event_id(effective_flag) if effective_flag else shell_event_id - ) - current_bound = _check_iccs(console, current_bound, event_id=check_event_id) - except (CycloptsError, SystemExit): - # Errors already printed by Cyclopts or subcommand - pass - except Exception as exc: - print_error_panel(exc) + try: + aimbat_app(tokens, exit_on_error=False) + + # Check ICCS for the event targeted by the command. If the + # user passed --event all (or no explicit flag), fall back to + # the shell context event. + explicit_flag = _extract_event_flag(tokens) + check_event_id: uuid.UUID | None + if explicit_flag and explicit_flag.lower() != "all": + check_event_id = _parse_event_id(explicit_flag) + else: + check_event_id = shell_event_id + current_bound = _check_iccs( + console, current_bound, event_id=check_event_id + ) + except (CycloptsError, SystemExit): + # Errors already printed by Cyclopts or subcommand + pass + except Exception as exc: + print_error_panel(exc) + finally: + # Restore DEFAULT_EVENT_ID to whatever it was before the shell started. + if _prev_default_event is not None: + os.environ["DEFAULT_EVENT_ID"] = _prev_default_event + else: + os.environ.pop("DEFAULT_EVENT_ID", None) diff --git a/src/aimbat/_cli/snapshot.py b/src/aimbat/_cli/snapshot.py index 09782da7..6092096a 100644 --- a/src/aimbat/_cli/snapshot.py +++ b/src/aimbat/_cli/snapshot.py @@ -6,20 +6,21 @@ to undo them if needed. """ -import uuid -from typing import Annotated +from typing import Annotated, Literal +from uuid import UUID from cyclopts import App, Parameter from aimbat.models import AimbatSnapshot from .common import ( - ALL_EVENTS_PARAMETER, DebugParameter, - GlobalParameters, IccsPlotParameters, JsonDumpParameters, TableParameters, + event_parameter, + event_parameter_is_all, + event_parameter_with_all, id_parameter, simple_exception, ) @@ -30,9 +31,10 @@ @app.command(name="create") @simple_exception def cli_snapshot_create( + event_id: Annotated[UUID, event_parameter()], comment: str | None = None, *, - global_parameters: GlobalParameters = GlobalParameters(), + _: DebugParameter = DebugParameter(), ) -> None: """Create a new snapshot of current processing parameters. @@ -48,14 +50,20 @@ def cli_snapshot_create( from aimbat.db import engine with Session(engine) as session: - event = resolve_event(session, global_parameters.event_id) + event = resolve_event(session, event_id) create_snapshot(session, event, comment) @app.command(name="rollback") @simple_exception def cli_snapshot_rollback( - snapshot_id: Annotated[uuid.UUID, id_parameter(AimbatSnapshot)], + snapshot_id: Annotated[ + UUID, + id_parameter( + AimbatSnapshot, + help="UUID (or unique prefix) of snapshot to use for rollback.", + ), + ], *, _: DebugParameter = DebugParameter(), ) -> None: @@ -72,7 +80,13 @@ def cli_snapshot_rollback( @app.command(name="delete") @simple_exception def cli_snapshot_delete( - snapshot_id: Annotated[uuid.UUID, id_parameter(AimbatSnapshot)], + snapshot_id: Annotated[ + UUID, + id_parameter( + AimbatSnapshot, + help="UUID (or unique prefix) of snapshot to delete.", + ), + ], *, _: DebugParameter = DebugParameter(), ) -> None: @@ -96,70 +110,98 @@ def cli_snapshot_dump( from rich import print_json from sqlmodel import Session - from aimbat.core import dump_snapshot_tables + from aimbat.core import ( + dump_event_parameter_snapshot_table, + dump_event_quality_snapshot_table, + dump_seismogram_parameter_snapshot_table, + dump_seismogram_quality_snapshot_table, + dump_snapshot_table, + ) from aimbat.db import engine with Session(engine) as session: - print_json( - data=dump_snapshot_tables(session, by_alias=dump_parameters.by_alias) - ) + data = { + "snapshots": dump_snapshot_table( + session, by_alias=dump_parameters.by_alias + ), + "event_parameters": dump_event_parameter_snapshot_table( + session, by_alias=dump_parameters.by_alias + ), + "seismogram_parameters": dump_seismogram_parameter_snapshot_table( + session, by_alias=dump_parameters.by_alias + ), + "event_quality": dump_event_quality_snapshot_table( + session, by_alias=dump_parameters.by_alias + ), + "seismogram_quality": dump_seismogram_quality_snapshot_table( + session, by_alias=dump_parameters.by_alias + ), + } + print_json(data=data) @app.command(name="list") @simple_exception def cli_snapshot_list( + event_id: Annotated[UUID | Literal["all"], event_parameter_with_all()], *, - all_events: Annotated[bool, ALL_EVENTS_PARAMETER] = False, table_parameters: TableParameters = TableParameters(), - global_parameters: GlobalParameters = GlobalParameters(), ) -> None: """Print information on the snapshots for an event.""" from sqlmodel import Session - from aimbat.core import dump_snapshot_tables, resolve_event + from aimbat.core import dump_snapshot_table, resolve_event from aimbat.db import engine from aimbat.logger import logger - from aimbat.utils import json_to_table, uuid_shortener + from aimbat.models import AimbatSnapshotRead + from aimbat.utils import uuid_shortener - if short := table_parameters.short: - exclude = {"id", "event_id"} - else: + from .common import json_to_table + + if raw := table_parameters.raw: exclude = {"short_id", "short_event_id"} + else: + exclude = {"id", "event_id"} with Session(engine) as session: logger.info("Printing AIMBAT snapshots table.") - if all_events: + if event_parameter_is_all(event_id): event = None title = "AIMBAT snapshots for all events" else: - event = resolve_event(session, global_parameters.event_id) - if short: - time = event.time.strftime("%Y-%m-%d %H:%M:%S") - id = uuid_shortener(session, event) - exclude.add("short_event_id") - else: + event = resolve_event(session, event_id) + if raw: time = event.time.isoformat() id = str(event.id) exclude.add("event_id") + else: + time = event.time.strftime("%Y-%m-%d %H:%M:%S") + id = uuid_shortener(session, event) + exclude.add("short_event_id") title = f"AIMBAT snapshots for event {time} (ID={id})" - data = dump_snapshot_tables( + snapshot_data = dump_snapshot_table( session, from_read_model=True, - by_title=True, event_id=event.id if event else None, exclude=exclude, ) - snapshot_data = data["snapshots"] - json_to_table(data=snapshot_data, title=title, short=short) + json_to_table( + data=snapshot_data, model=AimbatSnapshotRead, title=title, raw=raw + ) @app.command(name="preview") @simple_exception def cli_snapshot_preview( - snapshot_id: Annotated[uuid.UUID, id_parameter(AimbatSnapshot)], + snapshot_id: Annotated[ + UUID, + id_parameter( + AimbatSnapshot, help="UUID (or unique prefix) of snapshot to preview." + ), + ], *, iccs_plot_parameters: IccsPlotParameters = IccsPlotParameters(), as_matrix: Annotated[bool, Parameter(name="matrix")] = False, @@ -193,16 +235,24 @@ def cli_snapshot_preview( @app.command(name="details") @simple_exception def cli_snapshot_details( - snapshot_id: Annotated[uuid.UUID, id_parameter(AimbatSnapshot)], + snapshot_id: Annotated[ + UUID, + id_parameter( + AimbatSnapshot, + help="UUID (or unique prefix) of snapshot to show details for.", + ), + ], *, table_parameters: TableParameters = TableParameters(), - _: DebugParameter = DebugParameter(), ) -> None: """Print information on the event parameters saved in a snapshot.""" from sqlmodel import Session from aimbat.db import engine - from aimbat.utils import json_to_table, uuid_shortener + from aimbat.models import AimbatEventParametersSnapshot + from aimbat.utils import uuid_shortener + + from .common import json_to_table with Session(engine) as session: snapshot = session.get(AimbatSnapshot, snapshot_id) @@ -212,10 +262,12 @@ def cli_snapshot_details( f"Unable to print snapshot parameters: snapshot with id={snapshot_id} not found." ) - if table_parameters.short: - title = f"Saved event parameters in snapshot: {uuid_shortener(session, snapshot)}" - else: + raw = table_parameters.raw + + if raw: title = f"Saved event parameters in snapshot: {snapshot.id}" + else: + title = f"Saved event parameters in snapshot: {uuid_shortener(session, snapshot)}" parameters_snapshot = snapshot.event_parameters_snapshot @@ -223,13 +275,9 @@ def cli_snapshot_details( data=parameters_snapshot.model_dump( mode="json", exclude={"id", "snapshot_id", "parameters_id"} ), + model=AimbatEventParametersSnapshot, title=title, - column_kwargs={ - "Key": { - "header": "Parameter", - "justify": "left", - }, - }, + key_header="Parameter", ) diff --git a/src/aimbat/_cli/station.py b/src/aimbat/_cli/station.py index c9a764f1..fcea13f3 100644 --- a/src/aimbat/_cli/station.py +++ b/src/aimbat/_cli/station.py @@ -1,18 +1,18 @@ """View and manage stations.""" -import uuid -from typing import Annotated +from typing import Annotated, Literal +from uuid import UUID from cyclopts import App from aimbat.models import AimbatStation from .common import ( - ALL_EVENTS_PARAMETER, DebugParameter, - GlobalParameters, JsonDumpParameters, TableParameters, + event_parameter_is_all, + event_parameter_with_all, id_parameter, simple_exception, ) @@ -23,11 +23,17 @@ @app.command(name="delete") @simple_exception def cli_station_delete( - station_id: Annotated[uuid.UUID, id_parameter(AimbatStation)], + station_id: Annotated[ + UUID, + id_parameter( + AimbatStation, + help="UUID (or unique prefix) of station to delete.", + ), + ], *, _: DebugParameter = DebugParameter(), ) -> None: - """Delete existing station.""" + """Delete existing station (for all events).""" from sqlmodel import Session from aimbat.core import delete_station @@ -40,7 +46,13 @@ def cli_station_delete( @app.command(name="plotseis") @simple_exception def cli_station_seismograms_plot( - station_id: Annotated[uuid.UUID, id_parameter(AimbatStation)], + station_id: Annotated[ + UUID, + id_parameter( + AimbatStation, + help="UUID (or unique prefix) of station to plot seismograms for.", + ), + ], *, _: DebugParameter = DebugParameter(), ) -> None: @@ -81,10 +93,8 @@ def cli_station_dump( @app.command(name="list") @simple_exception def cli_station_list( - *, - all_events: Annotated[bool, ALL_EVENTS_PARAMETER] = False, + event_id: Annotated[UUID | Literal["all"], event_parameter_with_all()], table_parameters: TableParameters = TableParameters(), - global_parameters: GlobalParameters = GlobalParameters(), ) -> None: """Print information on the stations used in an event.""" from sqlmodel import Session @@ -92,36 +102,36 @@ def cli_station_list( from aimbat.core import dump_station_table, resolve_event from aimbat.db import engine from aimbat.logger import logger - from aimbat.utils import json_to_table, uuid_shortener + from aimbat.models import AimbatStationRead + from aimbat.utils import uuid_shortener - if short := table_parameters.short: - exclude = {"id"} - else: + from .common import json_to_table + + if raw := table_parameters.raw: exclude = {"short_id"} + else: + exclude = {"id"} with Session(engine) as session: - if all_events: + if event_parameter_is_all(event_id): logger.debug("Selecting all AIMBAT stations.") - data = dump_station_table( - session, from_read_model=True, by_title=True, exclude=exclude - ) + data = dump_station_table(session, from_read_model=True, exclude=exclude) title = "AIMBAT stations for all events" else: logger.debug("Selecting AIMBAT stations used by event.") - event = resolve_event(session, global_parameters.event_id) + event = resolve_event(session, event_id) data = dump_station_table( session, event_id=event.id, from_read_model=True, - by_title=True, exclude={"seismogram_count", "event_count"} | exclude, ) - if short: - title = f"AIMBAT stations for event {event.time.strftime('%Y-%m-%d %H:%M:%S')} (ID={uuid_shortener(session, event)})" - else: + if raw: title = f"AIMBAT stations for event {event.time} (ID={event.id})" + else: + title = f"AIMBAT stations for event {event.time.strftime('%Y-%m-%d %H:%M:%S')} (ID={uuid_shortener(session, event)})" - json_to_table(data, title=title, short=short) + json_to_table(data, model=AimbatStationRead, title=title, raw=raw) if __name__ == "__main__": diff --git a/src/aimbat/_cli/utils/__init__.py b/src/aimbat/_cli/utils/__init__.py index 668eb685..99c1350f 100644 --- a/src/aimbat/_cli/utils/__init__.py +++ b/src/aimbat/_cli/utils/__init__.py @@ -1,5 +1,3 @@ """Utilities for AIMBAT.""" -from .app import app - -__all__ = ["app"] +from .app import app as app diff --git a/src/aimbat/_config.py b/src/aimbat/_config.py index 3a46b1cb..05187f4a 100644 --- a/src/aimbat/_config.py +++ b/src/aimbat/_config.py @@ -144,7 +144,24 @@ def print_settings_table(pretty: bool) -> None: """ import json - from aimbat.utils import TABLE_STYLING, json_to_table + from pydantic import BaseModel + + from aimbat._cli.common import json_to_table + from aimbat.models import RichColSpec + + class _SettingsRow(BaseModel): + name: str = Field( + title="Name", + json_schema_extra={"rich": RichColSpec(justify="left", no_wrap=True)}, # type: ignore[dict-item] + ) + value: str = Field( + title="Value", + json_schema_extra={"rich": RichColSpec(justify="left", no_wrap=True)}, # type: ignore[dict-item] + ) + description: str = Field( + title="Description", + json_schema_extra={"rich": RichColSpec(justify="left")}, # type: ignore[dict-item] + ) env_prefix = Settings.model_config.get("env_prefix") values: dict[str, str] = json.loads(settings.model_dump_json()) @@ -166,19 +183,10 @@ def print_settings_table(pretty: bool) -> None: description = field_info.description if field_info else "" description_with_env_var = (f"{description} " if description else "") + env_var rows.append( - {"Name": k, "Value": str(v), "Description": description_with_env_var} + {"name": k, "value": str(v), "description": description_with_env_var} ) - json_to_table( - rows, - title="AIMBAT settings", - common_column_kwargs={"justify": "left"}, - column_kwargs={ - "Name": {"no_wrap": True, "style": TABLE_STYLING.ID}, - "Value": {"no_wrap": True, "style": TABLE_STYLING.mine}, - "Description": {"style": TABLE_STYLING.linked}, - }, - ) + json_to_table(rows, model=_SettingsRow, title="AIMBAT settings") def cli_settings_list( diff --git a/src/aimbat/_tui/_format.py b/src/aimbat/_tui/_format.py new file mode 100644 index 00000000..23847c54 --- /dev/null +++ b/src/aimbat/_tui/_format.py @@ -0,0 +1,154 @@ +"""Shared formatting helpers for TUI tables.""" + +from __future__ import annotations + +from functools import lru_cache +from typing import TYPE_CHECKING + +from pandas import Timedelta +from pydantic import BaseModel +from rich.text import Text + +from aimbat.models._format import TuiColSpec +from aimbat.utils.formatters import fmt_bool, fmt_float + +if TYPE_CHECKING: + from aimbat.core import FieldGroup + +__all__ = [ + "fmt_float_sem", + "fmt_groups", + "fmt_td_sem", + "fmt_val", + "tui_cell", + "tui_display_title", + "tui_fmt", +] + + +def tui_display_title(model: type[BaseModel], field_name: str) -> str: + """Return the TUI display title for a model field. + + Reads `TuiColSpec.display_title` from `json_schema_extra` if present, + otherwise falls back to the field's `title` or a humanised field name. + """ + info = model.model_fields.get(field_name) + if info is None: + return field_name.replace("_", " ") + extra = info.json_schema_extra + if isinstance(extra, dict): + col_spec = extra.get("tui") + if isinstance(col_spec, TuiColSpec) and col_spec.display_title is not None: + return col_spec.display_title + return info.title or field_name.replace("_", " ") + + +def fmt_float_sem(v: float | None, sem: float | None, decimals: int = 4) -> str: + """Format a float with an optional SEM as `value ยฑ sem`, or `โ€”` if None.""" + if v is None: + return "โ€”" + if sem is not None: + return f"{v:.{decimals}f} ยฑ {sem:.{decimals}f}" + return f"{v:.{decimals}f}" + + +def fmt_td_sem(td: Timedelta | None, sem: Timedelta | None, decimals: int = 5) -> str: + """Format a Timedelta in seconds with an optional SEM, or `โ€”` if None.""" + if td is None: + return "โ€”" + s = f"{td.total_seconds():.{decimals}f}" + if sem is not None: + s += f" ยฑ {sem.total_seconds():.{decimals}f}" + return s + " s" + + +def fmt_val(val: object, sem: object = None) -> str: + """Format a model field value for display in a quality panel. + + Dispatches to `fmt_float_sem` or `fmt_td_sem` for numeric types so that an + optional `sem` sibling is rendered as `value ยฑ sem`. Booleans render as โœ“/โœ—. + Returns `โ€”` for None.""" + if val is None: + return "โ€”" + if isinstance(val, bool): + return "โœ“" if val else "โœ—" + if isinstance(val, Timedelta): + return fmt_td_sem(val, sem if isinstance(sem, Timedelta) else None) + if isinstance(val, float): + return fmt_float_sem(val, sem if isinstance(sem, float) else None) + return str(val) + + +def fmt_groups( + groups: list[FieldGroup], +) -> list[tuple[str, list[tuple[str, str]]]]: + """Format a list of `FieldGroup` instances for `QualityModal`. + + Returns a list of `(group_title, rows)` pairs, skipping groups with no + content. Each `rows` element is a pre-formatted `(label, value)` pair. + """ + result = [] + for group in groups: + rows: list[tuple[str, str]] = [] + if group.fields: + rows = [ + (spec.title, fmt_val(spec.value, spec.sem)) for spec in group.fields + ] + elif group.empty_message: + rows = [(group.empty_message, "")] + if rows: + result.append((group.title, rows)) + return result + + +@lru_cache(maxsize=None) +def _col_spec_map(model: type[BaseModel]) -> dict[str, TuiColSpec]: + """Return a map of Pydantic field title โ†’ `TuiColSpec` for fields that carry one.""" + result: dict[str, TuiColSpec] = {} + for name, info in model.model_fields.items(): + extra = info.json_schema_extra + if isinstance(extra, dict): + col_spec = extra.get("tui") + if isinstance(col_spec, TuiColSpec): + title = info.title or name.replace("_", " ") + result[title] = col_spec + return result + + +def tui_cell(model: type[BaseModel], title: str, val: object) -> str | Text: + """Format a model field value for a DataTable cell. + + If the field's `TuiColSpec` specifies a `formatter`, it is called with the + raw value (after None is handled). Otherwise delegates to `tui_fmt`. Wraps + the result in `rich.text.Text` when `text_align` is set. + """ + col_spec = _col_spec_map(model).get(title) + if val is None: + formatted = "โ€”" + elif col_spec and col_spec.formatter is not None: + formatted = col_spec.formatter(val) + else: + formatted = tui_fmt(val) + if col_spec and col_spec.text_align: + return Text(formatted, justify=col_spec.text_align) + return formatted + + +def tui_fmt(val: object) -> str: + """Format a raw field value for display in a Textual DataTable cell. + + Applies generic type-based rules (bool as โœ“/โœ—, float to 3 d.p., ISO + timestamp truncation) before falling back to `str`. Field-specific + formatting should be handled via `TuiColSpec.formatter` instead. + Returns `โ€”` for `None`.""" + if val is None: + return "โ€”" + if isinstance(val, bool): + return fmt_bool(val) + if isinstance(val, float): + return fmt_float(val) + if isinstance(val, int): + return str(val) + if isinstance(val, str) and "T" in val and len(val) >= 19: + return val[:19] + return str(val) diff --git a/src/aimbat/_tui/app.py b/src/aimbat/_tui/app.py index a49e06f3..74794eb7 100644 --- a/src/aimbat/_tui/app.py +++ b/src/aimbat/_tui/app.py @@ -50,7 +50,6 @@ from aimbat._types import SeismogramParameter from aimbat.core import ( BoundICCS, - FieldGroup, add_data_to_project, build_iccs_from_snapshot, create_iccs_instance, @@ -62,7 +61,7 @@ delete_station, dump_event_table, dump_seismogram_table, - dump_snapshot_tables, + dump_snapshot_table, dump_station_table, reset_seismogram_parameters, rollback_to_snapshot, @@ -94,7 +93,11 @@ update_pick, update_timewindow, ) -from aimbat.utils import get_title_map + +from ._format import fmt_float_sem as _fmt_float_sem +from ._format import fmt_groups as _format_groups +from ._format import tui_cell as _tui_cell +from ._format import tui_display_title as _tui_display_title _DEFAULT_THEME = settings.tui_dark_theme _LIGHT_THEME = settings.tui_light_theme @@ -120,9 +123,7 @@ ], } -_TITLE_REMAP: dict[str, str] = {"Short ID": "ID"} - -_EVENT_TABLE_EXCLUDE: set[str] = {"is_default", "last_modified"} +_EVENT_TABLE_EXCLUDE: set[str] = {""} _STATION_TABLE_EXCLUDE: set[str] = {"event_count"} _SEISMOGRAM_TABLE_EXCLUDE: set[str] = {"event_id", "short_event_id"} _SNAPSHOT_TABLE_EXCLUDE: set[str] = {"event_id", "short_event_id"} @@ -215,82 +216,6 @@ def _tool_image( } -# --------------------------------------------------------------------------- -# Quality display helpers -# --------------------------------------------------------------------------- - - -def _fmt_float_sem(v: float | None, sem: float | None, decimals: int = 4) -> str: - if v is None: - return "โ€”" - if sem is not None: - return f"{v:.{decimals}f} ยฑ {sem:.{decimals}f}" - return f"{v:.{decimals}f}" - - -def _fmt_td_sem(td: Timedelta | None, sem: Timedelta | None, decimals: int = 5) -> str: - if td is None: - return "โ€”" - s = f"{td.total_seconds():.{decimals}f}" - if sem is not None: - s += f" ยฑ {sem.total_seconds():.{decimals}f}" - return s + " s" - - -def _fmt_val(val: object, sem: object = None) -> str: - """Format a model field value, optionally with its SEM sibling.""" - if val is None: - return "โ€”" - if isinstance(val, bool): - return "โœ“" if val else "โœ—" - if isinstance(val, Timedelta): - return _fmt_td_sem(val, sem if isinstance(sem, Timedelta) else None) - if isinstance(val, float): - return _fmt_float_sem(val, sem if isinstance(sem, float) else None) - return str(val) - - -def _format_groups( - groups: list[FieldGroup], -) -> list[tuple[str, list[tuple[str, str]]]]: - """Format a list of `FieldGroup` instances for `QualityModal`. - - Returns a list of `(group_title, rows)` pairs, skipping groups with no - content. Each `rows` element is a pre-formatted `(label, value)` pair. - """ - result = [] - for group in groups: - rows: list[tuple[str, str]] = [] - if group.fields: - rows = [ - (spec.title, _fmt_val(spec.value, spec.sem)) for spec in group.fields - ] - elif group.empty_message: - rows = [(group.empty_message, "")] - if rows: - result.append((group.title, rows)) - return result - - -def _tui_fmt(val: object, *, field_name: str = "") -> str: - """Format a dump value for display in a Textual DataTable cell.""" - if val is None: - return "โ€”" - if isinstance(val, bool): - if field_name == "Flip": - return "โ†•" if val else "" - return "โœ“" if val else "" - if field_name == "Depth" and isinstance(val, (int, float)): - return f"{val / 1000:.1f}" - if isinstance(val, float): - return f"{val:.3f}" - if isinstance(val, int): - return str(val) - if isinstance(val, str) and "T" in val and len(val) >= 19: - return val[:19] - return str(val) - - # --------------------------------------------------------------------------- # Main application # --------------------------------------------------------------------------- @@ -486,17 +411,17 @@ def _assign_iccs(self, bound_iccs: BoundICCS) -> None: # ------------------------------------------------------------------ def _setup_project_tables(self) -> None: - event_headers = [ - _TITLE_REMAP.get(t, t) - for f, t in get_title_map(AimbatEventRead).items() + et_headers = [ + _tui_display_title(AimbatEventRead, f) + for f in AimbatEventRead.model_fields if f not in _EVENT_TABLE_EXCLUDE | {"id"} ] et = self.query_one("#project-event-table", DataTable) et.cursor_type = "row" - et.add_columns(" ", *event_headers) + et.add_columns(" ", *et_headers) station_headers = [ - _TITLE_REMAP.get(t, t) - for f, t in get_title_map(AimbatStationRead).items() + _tui_display_title(AimbatStationRead, f) + for f in AimbatStationRead.model_fields if f not in _STATION_TABLE_EXCLUDE | {"id"} ] st = self.query_one("#project-station-table", DataTable) @@ -505,8 +430,8 @@ def _setup_project_tables(self) -> None: def _setup_seismogram_table(self) -> None: seis_headers = [ - _TITLE_REMAP.get(t, t) - for f, t in get_title_map(AimbatSeismogramRead).items() + _tui_display_title(AimbatSeismogramRead, f) + for f in AimbatSeismogramRead.model_fields if f not in _SEISMOGRAM_TABLE_EXCLUDE | {"id"} ] t = self.query_one("#seismogram-table", DataTable) @@ -515,8 +440,8 @@ def _setup_seismogram_table(self) -> None: def _setup_snapshot_table(self) -> None: snap_headers = [ - _TITLE_REMAP.get(t, t) - for f, t in get_title_map(AimbatSnapshotRead).items() + _tui_display_title(AimbatSnapshotRead, f) + for f in AimbatSnapshotRead.model_fields if f not in _SNAPSHOT_TABLE_EXCLUDE | {"id"} ] t = self.query_one("#snapshot-table", DataTable) @@ -540,7 +465,7 @@ def _refresh_project(self) -> None: et_saved, st_saved = et.cursor_row, st.cursor_row et.clear() st.clear() - with suppress(Exception): + with suppress(NoResultFound, RuntimeError): with Session(engine) as session: event_rows = dump_event_table( session, @@ -571,19 +496,20 @@ def _refresh_project(self) -> None: None, ) if active is not None: + _sc_key = _tui_display_title(AimbatEventRead, "station_count") self.query_one("#stations-title", Static).update( - f"Stations [dim]{active['Station count']} in active event[/dim]" + f"Stations [dim]{active.get(_sc_key, '?')} in active event[/dim]" ) for row in event_rows: row_id = str(row.pop("ID")) marker = "โ–ถ" if row_id == str(self._current_event_id) else " " - cells = [_tui_fmt(v, field_name=k) for k, v in row.items()] + cells = [_tui_cell(AimbatEventRead, k, v) for k, v in row.items()] et.add_row(marker, *cells, key=row_id) for row in station_rows: row_id = str(row.pop("ID")) - cells = [_tui_fmt(v, field_name=k) for k, v in row.items()] + cells = [_tui_cell(AimbatStationRead, k, v) for k, v in row.items()] st.add_row(*cells, key=row_id) if et.row_count > 0: @@ -650,7 +576,7 @@ def _refresh_seismograms(self) -> None: live_cc_map: dict[str, float] = {} if self._bound_iccs is not None: - with suppress(Exception): + with suppress(AttributeError, ValueError): for iccs_seis, cc in zip( self._bound_iccs.iccs.seismograms, self._bound_iccs.iccs.ccs ): @@ -687,7 +613,7 @@ def _refresh_seismograms(self) -> None: if row.get("Select"): selected_ccs.append(float(cc_val)) row_id = str(row.pop("ID")) - cells = [_tui_fmt(v, field_name=k) for k, v in row.items()] + cells = [_tui_cell(AimbatSeismogramRead, k, v) for k, v in row.items()] table.add_row(*cells, key=row_id) title = self.query_one("#seismogram-title", Static) @@ -717,16 +643,16 @@ def _refresh_snapshots(self) -> None: with suppress(NoResultFound, RuntimeError): with Session(engine) as session: event = self._get_current_event(session) - snap_data = dump_snapshot_tables( + snapshots = dump_snapshot_table( session, from_read_model=True, by_title=True, exclude=_SNAPSHOT_TABLE_EXCLUDE, event_id=event.id, ) - for row in snap_data["snapshots"]: + for row in snapshots: row_id = str(row.pop("ID")) - cells = [_tui_fmt(v, field_name=k) for k, v in row.items()] + cells = [_tui_cell(AimbatSnapshotRead, k, v) for k, v in row.items()] table.add_row(*cells, key=row_id) if table.row_count > 0: table.move_cursor(row=min(saved_row, table.row_count - 1)) diff --git a/src/aimbat/_tui/modals.py b/src/aimbat/_tui/modals.py index ceb14a83..f2166a16 100644 --- a/src/aimbat/_tui/modals.py +++ b/src/aimbat/_tui/modals.py @@ -7,7 +7,7 @@ from pandas import Timedelta from pydantic import ValidationError -from sqlmodel import Session, select +from sqlmodel import Session from textual import on from textual.app import ComposeResult from textual.binding import Binding @@ -15,13 +15,16 @@ from textual.screen import ModalScreen from textual.widgets import DataTable, Input, Label, Static +from aimbat._tui._format import tui_cell, tui_display_title from aimbat._tui._widgets import VimDataTable from aimbat._types import EventParameter -from aimbat.core import delete_event, set_event_parameter +from aimbat.core import delete_event, dump_event_table, set_event_parameter from aimbat.db import engine -from aimbat.models import AimbatEvent +from aimbat.models import AimbatEvent, AimbatEventRead from aimbat.models._parameters import AimbatEventParametersBase +_SWITCHER_EVENT_EXCLUDE: set[str] = {"snapshot_count", "last_modified"} + class _CSS(StrEnum): """CSS class names shared across modal widgets.""" @@ -93,48 +96,28 @@ def compose(self) -> ComposeResult: def on_mount(self) -> None: table = self.query_one(DataTable) table.cursor_type = "row" - table.add_columns( - " ", - "ID", - "Time (UTC)", - "Lat ยฐ", - "Lon ยฐ", - "Depth km", - "Seismograms", - "Stations", - "Completed", - ) + headers = [ + tui_display_title(AimbatEventRead, f) + for f in AimbatEventRead.model_fields + if f not in _SWITCHER_EVENT_EXCLUDE | {"id"} + ] + table.add_columns(" ", *headers) self._populate(table) def _populate(self, table: DataTable) -> None: try: with Session(engine) as session: - events = session.exec(select(AimbatEvent)).all() - - for event in events: - marker = "โ–ถ" if event.id == self._current_event_id else " " - done_marker = "โœ“" if event.parameters.completed else " " - short_id = str(event.id)[:8] - time_str = str(event.time)[:19] if event.time else "โ€”" - lat = f"{event.latitude:.3f}" if event.latitude is not None else "โ€”" - lon = ( - f"{event.longitude:.3f}" if event.longitude is not None else "โ€”" - ) - depth = ( - f"{event.depth / 1000:.1f}" if event.depth is not None else "โ€”" - ) - table.add_row( - marker, - short_id, - time_str, - lat, - lon, - depth, - str(event.seismogram_count), - str(event.station_count), - done_marker, - key=str(event.id), - ) + rows = dump_event_table( + session, + from_read_model=True, + by_title=True, + exclude=_SWITCHER_EVENT_EXCLUDE, + ) + for row in rows: + row_id = str(row.pop("ID")) + marker = "โ–ถ" if row_id == str(self._current_event_id) else " " + cells = [tui_cell(AimbatEventRead, k, v) for k, v in row.items()] + table.add_row(marker, *cells, key=row_id) except RuntimeError as exc: self.notify(str(exc), severity="error") self.dismiss(None) diff --git a/src/aimbat/app.py b/src/aimbat/app.py index a0066d75..0112156d 100644 --- a/src/aimbat/app.py +++ b/src/aimbat/app.py @@ -20,6 +20,8 @@ from cyclopts import App from rich.console import Console +import aimbat._cli as cli + try: __version__ = str(metadata.version("aimbat")) except Exception: @@ -28,19 +30,22 @@ console = Console() app = App(version=__version__, help=__doc__, help_format="markdown", console=console) -app.command("aimbat._cli.align:app", name="align") -app.command("aimbat._cli.data:app", name="data") -app.command("aimbat._cli.event:app", name="event") -app.command("aimbat._cli.pick:app", name="pick") -app.command("aimbat._cli.plot:app", name="plot") -app.command("aimbat._cli.quality:app", name="quality") -app.command("aimbat._cli.project:app", name="project") -app.command("aimbat._cli.station:app", name="station") -app.command("aimbat._cli.seismogram:app", name="seismogram") -app.command("aimbat._cli.snapshot:app", name="snapshot") -app.command("aimbat._cli.utils:app", name="utils") -app.command("aimbat._cli.shell:app", name="shell") -app.command("aimbat._tui.app:main", name="tui") +app.command(cli.align) +app.command(cli.data) +app.command(cli.event) +app.command(cli.pick) +app.command(cli.plot) +app.command(cli.project) +app.command(cli.seismogram) +app.command(cli.snapshot) +app.command(cli.station) +app.command(cli.utils) +app.command(cli.shell) +app.command( + "aimbat._tui.app:main", + name="tui", + help="Launch the AIMBAT terminal user interface", +) if __name__ == "__main__": diff --git a/src/aimbat/core/__init__.py b/src/aimbat/core/__init__.py index 8d254ae8..b4ec0353 100644 --- a/src/aimbat/core/__init__.py +++ b/src/aimbat/core/__init__.py @@ -4,12 +4,10 @@ All functions take a SQLModel `Session` and work with the models in `aimbat.models`. The main areas covered are: -- **Default event** โ€” get and set the default event (`get_default_event`, - `set_default_event`). - **Data** โ€” add data to the project, linking each source to its station, event, and seismogram records (`add_data_to_project`). - **Events, seismograms, stations** โ€” query, update, and delete records; read - and write parameters. + and write parameters; resolve an event from an explicit ID (`resolve_event`). - **ICCS / MCCC** โ€” run the Iterative Cross-Correlation and Stack (`run_iccs`) and Multi-Channel Cross-Correlation (`run_mccc`) algorithms; update picks, time windows, and correlation thresholds. @@ -29,7 +27,6 @@ _internal_names = set(dir()) from ._data import * -from ._default_event import * from ._event import * from ._iccs import * from ._project import * diff --git a/src/aimbat/core/_data.py b/src/aimbat/core/_data.py index 3b5493a4..156301a8 100644 --- a/src/aimbat/core/_data.py +++ b/src/aimbat/core/_data.py @@ -4,7 +4,6 @@ from uuid import UUID from pydantic import TypeAdapter -from rich.console import Console from rich.progress import track from sqlalchemy.exc import NoResultFound from sqlmodel import Session, select @@ -26,7 +25,7 @@ AimbatStation, _AimbatDataSourceCreate, ) -from aimbat.utils import get_title_map, json_to_table +from aimbat.utils import get_title_map __all__ = [ "add_data_to_project", @@ -188,45 +187,6 @@ def _process_datasource( return aimbat_data_source -def _print_dry_run_results( - added_datasources: Sequence[AimbatDataSource], - existing_station_ids: set, - existing_event_ids: set, - existing_seismogram_ids: set, -) -> None: - """Print a summary table showing which entities were added vs skipped.""" - json_to_table( - [ - { - "Source": str(ds.sourcename), - "Station": ds.seismogram.station_id not in existing_station_ids, - "Event": ds.seismogram.event_id not in existing_event_ids, - "Seismogram": ds.seismogram_id not in existing_seismogram_ids, - } - for ds in added_datasources - ], - title="Dry Run: Data to be added", - ) - new_stations = sum( - ds.seismogram.station_id not in existing_station_ids for ds in added_datasources - ) - new_events = sum( - ds.seismogram.event_id not in existing_event_ids for ds in added_datasources - ) - new_seismograms = sum( - ds.seismogram_id not in existing_seismogram_ids for ds in added_datasources - ) - console = Console() - console.print( - f"\n{new_stations} station(s) added, " - f"{len(added_datasources) - new_stations} skipped. " - f"{new_events} event(s) added, " - f"{len(added_datasources) - new_events} skipped. " - f"{new_seismograms} seismogram(s) added, " - f"{len(added_datasources) - new_seismograms} skipped." - ) - - def add_data_to_project( session: Session, data_sources: Sequence[os.PathLike | str], @@ -235,7 +195,7 @@ def add_data_to_project( event_id: UUID | None = None, dry_run: bool = False, disable_progress_bar: bool = True, -) -> None: +) -> tuple[list[AimbatDataSource], set[UUID], set[UUID], set[UUID]] | None: """Add data sources to the AIMBAT database. What gets created depends on which capabilities `data_type` supports: @@ -293,18 +253,18 @@ def add_data_to_project( logger.info("Dry run: displaying data that would be added.") if added_datasources: session.flush() - _print_dry_run_results( - added_datasources, - existing_station_ids, - existing_event_ids, - existing_seismogram_ids, - ) nested.rollback() logger.info("Dry run complete. Rolling back changes.") - return + return ( + added_datasources, + existing_station_ids, + existing_event_ids, + existing_seismogram_ids, + ) session.commit() logger.info("Data added successfully.") + return None except Exception as e: logger.error(f"Failed to add data. Rolling back changes. Error: {e}") diff --git a/src/aimbat/core/_default_event.py b/src/aimbat/core/_default_event.py deleted file mode 100644 index 4f818656..00000000 --- a/src/aimbat/core/_default_event.py +++ /dev/null @@ -1,95 +0,0 @@ -"""Get and set the default event (i.e. the one being processed by default).""" - -from uuid import UUID - -from sqlalchemy.exc import NoResultFound -from sqlmodel import Session, select - -from aimbat.logger import logger -from aimbat.models import AimbatEvent - -__all__ = [ - "get_default_event", - "set_default_event", - "resolve_event", -] - - -def get_default_event(session: Session) -> AimbatEvent | None: - """ - Return the currently default event, or None if no event is set as default. - - Args: - session: SQL session. - - Returns: - Default Event, or None. - """ - - logger.debug("Attempting to determine default event.") - - statement = select(AimbatEvent).where(AimbatEvent.is_default == 1) - default_event = session.exec(statement).one_or_none() - - logger.debug(f"Default event: {default_event.id if default_event else None}") - - return default_event - - -def resolve_event(session: Session, event_id: UUID | None = None) -> AimbatEvent: - """ - Resolve an event from either an explicit ID or the default event. - - Args: - session: SQL session. - event_id: Optional event ID. - - Returns: - The specified event or the default event. - - Raises: - NoResultFound: If an explicit event_id is given but not found. - NoResultFound: If no event_id is given and no default event is set. - """ - if 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}.") - return event - logger.debug("Falling back to default event for resolution.") - event = get_default_event(session) - if event is None: - raise NoResultFound("No event selected.") - return event - - -def set_default_event(session: Session, event_id: UUID) -> None: - """ - Set the default event (i.e. the one being processed). - - Args: - session: SQL session. - event_id: UUID of AIMBAT Event to set as default one. - """ - - logger.debug(f"Setting default {event_id=}") - - new_default_event = session.get(AimbatEvent, event_id) - if new_default_event is None: - raise ValueError(f"No AimbatEvent found with id: {event_id}.") - - current_default_event = get_default_event(session) - - # unset the current default first - if current_default_event is not None: - if new_default_event.id == current_default_event.id: - return - current_default_event.is_default = None - session.add(current_default_event) - session.flush() - - # set new default - new_default_event.is_default = True - session.add(new_default_event) - session.commit() diff --git a/src/aimbat/core/_event.py b/src/aimbat/core/_event.py index c0da7ba7..ca1b0d1c 100644 --- a/src/aimbat/core/_event.py +++ b/src/aimbat/core/_event.py @@ -7,6 +7,7 @@ from pandas import Timedelta from pydantic import TypeAdapter from sqlalchemy.exc import NoResultFound +from sqlalchemy.orm import selectinload from sqlmodel import Session, select from aimbat._types import EventParameter @@ -19,17 +20,44 @@ AimbatStation, ) from aimbat.models._parameters import AimbatEventParametersBase -from aimbat.utils import get_title_map +from aimbat.utils import get_title_map, rel __all__ = [ "delete_event", "get_completed_events", "get_events_using_station", + "resolve_event", "set_event_parameter", "dump_event_table", "dump_event_parameter_table", ] + +def resolve_event(session: Session, event_id: UUID | None = None) -> AimbatEvent: + """ + Resolve an event from an explicit ID. + + Args: + session: SQL session. + event_id: Optional event ID. + + Returns: + The specified event. + + Raises: + NoResultFound: If an explicit event_id is given but not found. + NoResultFound: If no event_id is given. + """ + if 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}.") + return event + + raise NoResultFound("No event specified.") + + type EventParameterBool = Literal[ EventParameter.COMPLETED, EventParameter.BANDPASS_APPLY ] @@ -100,6 +128,13 @@ def get_events_using_station( .join(AimbatSeismogram) .join(AimbatStation) .where(AimbatStation.id == station_id) + .options( + selectinload(rel(AimbatEvent.seismograms)).selectinload( + rel(AimbatSeismogram.parameters) + ), + selectinload(rel(AimbatEvent.parameters)), + selectinload(rel(AimbatEvent.quality)), + ) ) events = session.exec(statement).all() @@ -176,7 +211,16 @@ def set_event_parameter( logger.debug(f"Setting {name=} to {value} for event {event_id=}.") - event = session.get(AimbatEvent, event_id) + event = session.exec( + select(AimbatEvent) + .where(AimbatEvent.id == event_id) + .options( + selectinload(rel(AimbatEvent.parameters)), + selectinload(rel(AimbatEvent.seismograms)).selectinload( + rel(AimbatSeismogram.parameters) + ), + ) + ).one_or_none() if event is None: raise NoResultFound(f"No AimbatEvent found with id: {event_id}.") @@ -247,7 +291,14 @@ def dump_event_table( if exclude is not None: exclude: dict[str, set] = {"__all__": exclude} # type: ignore[no-redef] - events = session.exec(select(AimbatEvent)).all() + statement = select(AimbatEvent).options( + selectinload(rel(AimbatEvent.seismograms)).selectinload( + rel(AimbatSeismogram.parameters) + ), + selectinload(rel(AimbatEvent.parameters)), + selectinload(rel(AimbatEvent.quality)), + ) + events = session.exec(statement).all() if from_read_model: event_reads = [AimbatEventRead.from_event(e, session=session) for e in events] diff --git a/src/aimbat/core/_iccs.py b/src/aimbat/core/_iccs.py index c9b352e9..f238f433 100644 --- a/src/aimbat/core/_iccs.py +++ b/src/aimbat/core/_iccs.py @@ -4,6 +4,7 @@ from uuid import UUID, uuid4 from pandas import Timestamp +from sqlalchemy.orm import selectinload from sqlmodel import Session, col, select from pysmo.tools.iccs import ( @@ -24,6 +25,7 @@ AimbatEventParametersBase, AimbatSeismogramParametersBase, ) +from aimbat.utils import rel __all__ = [ "BoundICCS", @@ -86,6 +88,7 @@ def _build_iccs( Returns: A freshly constructed ICCS instance. + """ p = parameters or event.parameters seismograms = [ @@ -132,12 +135,24 @@ def create_iccs_instance(session: Session, event: AimbatEvent) -> BoundICCS: Returns: BoundICCS instance tied to the given event. + """ cached = _iccs_cache.get(event.id) if cached is not None and not cached.is_stale(event): logger.debug(f"Returning cached BoundICCS for event {event.id}.") return cached + event = session.exec( + select(AimbatEvent) + .where(AimbatEvent.id == event.id) + .options( + selectinload(rel(AimbatEvent.parameters)), + selectinload(rel(AimbatEvent.seismograms)).selectinload( + rel(AimbatSeismogram.parameters) + ), + ) + ).one() + logger.debug(f"Creating ICCS instance for event {event.id}.") bound = BoundICCS( iccs=_build_iccs(event), @@ -320,7 +335,18 @@ def build_iccs_from_snapshot(session: Session, snapshot_id: UUID) -> BoundICCS: """ logger.info(f"Building ICCS from snapshot {snapshot_id}.") - snapshot = session.get(AimbatSnapshot, snapshot_id) + statement = ( + select(AimbatSnapshot) + .where(AimbatSnapshot.id == snapshot_id) + .options( + selectinload(rel(AimbatSnapshot.event)) + .selectinload(rel(AimbatEvent.seismograms)) + .selectinload(rel(AimbatSeismogram.parameters)), + selectinload(rel(AimbatSnapshot.event_parameters_snapshot)), + ) + ) + snapshot = session.exec(statement).one_or_none() + if snapshot is None: raise ValueError(f"Snapshot {snapshot_id} not found.") diff --git a/src/aimbat/core/_project.py b/src/aimbat/core/_project.py index 8b888e5a..87153390 100644 --- a/src/aimbat/core/_project.py +++ b/src/aimbat/core/_project.py @@ -58,33 +58,7 @@ def create_project(engine: Engine) -> None: if engine.name == "sqlite": with engine.begin() as connection: - # Trigger 1: Handle updates to existing rows - connection.execute( - text(""" - CREATE TRIGGER IF NOT EXISTS single_default_event_update - BEFORE UPDATE ON aimbatevent - FOR EACH ROW WHEN NEW.is_default = TRUE - BEGIN - UPDATE aimbatevent SET is_default = NULL - WHERE is_default = TRUE AND id != NEW.id; - END; - """) - ) - - # Trigger 2: Handle brand new default events being inserted - connection.execute( - text(""" - CREATE TRIGGER IF NOT EXISTS single_default_event_insert - BEFORE INSERT ON aimbatevent - FOR EACH ROW WHEN NEW.is_default = TRUE - BEGIN - UPDATE aimbatevent SET is_default = NULL - WHERE is_default = TRUE; - END; - """) - ) - - # Trigger 3: Track last modification time when event parameters change + # Trigger 1: Track last modification time when event parameters change connection.execute( text(""" CREATE TRIGGER IF NOT EXISTS event_modified_on_params_update @@ -96,7 +70,7 @@ def create_project(engine: Engine) -> None: """) ) - # Trigger 4: Track last modification time when seismogram parameters change + # Trigger 2: Track last modification time when seismogram parameters change connection.execute( text(""" CREATE TRIGGER IF NOT EXISTS event_modified_on_seis_params_update @@ -112,7 +86,7 @@ def create_project(engine: Engine) -> None: """) ) - # Trigger 5: Null all quality when event window/bandpass/ramp parameters change. + # Trigger 3: Null all quality when event window/bandpass/ramp parameters change. # These parameters change the signal data used by both ICCS and MCCC. connection.execute( text(""" @@ -137,7 +111,7 @@ def create_project(engine: Engine) -> None: """) ) - # Trigger 6: Null MCCC quality when MCCC-specific event parameters change. + # Trigger 4: Null MCCC quality when MCCC-specific event parameters change. # These parameters affect only the MCCC inversion, not the underlying signal, # so iccs_cc remains valid. connection.execute( @@ -159,7 +133,7 @@ def create_project(engine: Engine) -> None: """) ) - # Trigger 7a: Null quality when flip changes on a seismogram. + # Trigger 5a: Null quality when flip changes on a seismogram. # Flipping a trace only affects the ICCS stack if the seismogram is selected. # MCCC stats are invalidated if the seismogram was included in the last MCCC # run, which is inferred from the presence of live mccc_cc_mean stats โ€” @@ -219,7 +193,7 @@ def create_project(engine: Engine) -> None: """) ) - # Trigger 7b: Null quality when t1 changes on a seismogram. + # Trigger 5b: Null quality when t1 changes on a seismogram. # ICCS: if selected, the stack is affected so iccs_cc is stale for all; # if deselected, only this seismogram's own iccs_cc is stale. # MCCC: invalidated whenever the seismogram was in the last MCCC run, @@ -277,7 +251,7 @@ def create_project(engine: Engine) -> None: """) ) - # Trigger 7c: Null quality when select changes on a seismogram. + # Trigger 5c: Null quality when select changes on a seismogram. # ICCS stack composition changes in both directions (select โ†’ deselect and # vice versa), so iccs_cc is always invalidated for the whole event. # MCCC stats are only invalidated if the seismogram was in the last MCCC run, diff --git a/src/aimbat/core/_quality.py b/src/aimbat/core/_quality.py index 2c334272..d2753eef 100644 --- a/src/aimbat/core/_quality.py +++ b/src/aimbat/core/_quality.py @@ -15,6 +15,8 @@ import pandas as pd from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import func +from sqlalchemy.orm import selectinload from sqlmodel import Session, col, select from aimbat._types import PydanticTimedelta @@ -24,11 +26,12 @@ AimbatEventQualityBase, AimbatEventQualitySnapshot, AimbatSeismogram, + AimbatSeismogramParametersSnapshot, AimbatSeismogramQualityBase, AimbatSeismogramQualitySnapshot, AimbatSnapshot, ) -from aimbat.utils import mean_and_sem, mean_and_sem_timedelta +from aimbat.utils import mean_and_sem, mean_and_sem_timedelta, rel __all__ = [ "FieldSpec", @@ -218,6 +221,13 @@ def get_seismogram_mccc_map( Must be called within an active SQLModel session so that ORM relationships on `event` can lazy-load. + Warning: + This function can cause an N+1 query issue. It iterates over + `event.seismograms` and accesses `seis.quality`, which may trigger + lazy loading. To avoid performance problems, the `AimbatEvent` object + passed to this function should be queried with `selectinload` for the + `seismograms` and their nested `quality` relationships. + Args: event: Default AimbatEvent. @@ -338,8 +348,7 @@ def get_quality_station( """ logger.debug(f"Getting quality for station {station_id}.") - # For each event that has seismograms at this station, get the most - # recent snapshot with quality data and collect its quality records. + # 1. Get all event IDs for the station stmt = ( select(AimbatSeismogram.event_id) .where(col(AimbatSeismogram.station_id) == station_id) @@ -347,24 +356,49 @@ def get_quality_station( ) event_ids = session.exec(stmt).all() - all_records: list[AimbatSeismogramQualitySnapshot] = [] - selected_records: list[AimbatSeismogramQualitySnapshot] = [] + if not event_ids: + return SeismogramQualityStats(count=0), SeismogramQualityStats(count=0) - for event_id in event_ids: - snap_stmt = ( - select(AimbatSnapshot) - .join( - AimbatEventQualitySnapshot, - col(AimbatEventQualitySnapshot.snapshot_id) == col(AimbatSnapshot.id), - ) - .where(col(AimbatSnapshot.event_id) == event_id) - .order_by(col(AimbatSnapshot.time).desc()) - .limit(1) + # 2. Get the latest snapshot for each of these events that has quality data. + # Using a subquery to get the max time for each event_id + subq = ( + select( + AimbatSnapshot.event_id, + func.max(AimbatSnapshot.time).label("max_time"), ) - snap = session.exec(snap_stmt).first() - if snap is None: - continue + .join(AimbatEventQualitySnapshot) + .where(col(AimbatSnapshot.event_id).in_(event_ids)) + .group_by(col(AimbatSnapshot.event_id)) + .subquery() + ) + + # Now join the snapshot table with the subquery to get the latest snapshots + snap_stmt = ( + select(AimbatSnapshot) + .join( + subq, + (col(AimbatSnapshot.event_id) == subq.c.event_id) + & (col(AimbatSnapshot.time) == subq.c.max_time), + ) + .options( + selectinload(rel(AimbatSnapshot.event)).selectinload( + rel(AimbatEvent.seismograms) + ), + selectinload( + rel(AimbatSnapshot.seismogram_parameters_snapshots) + ).selectinload(rel(AimbatSeismogramParametersSnapshot.parameters)), + selectinload(rel(AimbatSnapshot.seismogram_quality_snapshots)).selectinload( + rel(AimbatSeismogramQualitySnapshot.quality) + ), + ) + ) + + snaps = session.exec(snap_stmt).all() + + all_records: list[AimbatSeismogramQualitySnapshot] = [] + selected_records: list[AimbatSeismogramQualitySnapshot] = [] + for snap in snaps: # Seismograms at this station in this snapshot. station_seis_ids = { seis.id for seis in snap.event.seismograms if seis.station_id == station_id diff --git a/src/aimbat/core/_seismogram.py b/src/aimbat/core/_seismogram.py index 283952b3..bdb02f59 100644 --- a/src/aimbat/core/_seismogram.py +++ b/src/aimbat/core/_seismogram.py @@ -5,17 +5,19 @@ from pandas import Timestamp from pydantic import TypeAdapter from sqlalchemy.exc import NoResultFound +from sqlalchemy.orm import selectinload from sqlmodel import Session, select from aimbat._types import SeismogramParameter from aimbat.logger import logger from aimbat.models import ( + AimbatEvent, AimbatSeismogram, AimbatSeismogramParameters, AimbatSeismogramParametersBase, AimbatSeismogramRead, ) -from aimbat.utils import get_title_map +from aimbat.utils import get_title_map, rel __all__ = [ "delete_seismogram", @@ -93,6 +95,13 @@ def dump_seismogram_table( else: statement = select(AimbatSeismogram) + statement = statement.options( + selectinload(rel(AimbatSeismogram.station)), + selectinload(rel(AimbatSeismogram.event)), + selectinload(rel(AimbatSeismogram.parameters)), + selectinload(rel(AimbatSeismogram.quality)), + ) + seismograms = session.exec(statement).all() if from_read_model: @@ -136,7 +145,19 @@ def reset_seismogram_parameters(session: Session, seismogram_id: UUID) -> None: logger.info(f"Resetting parameters for seismogram {seismogram_id}.") - seismogram = session.get(AimbatSeismogram, seismogram_id) + seismogram = session.exec( + select(AimbatSeismogram) + .where(AimbatSeismogram.id == seismogram_id) + .options( + selectinload(rel(AimbatSeismogram.parameters)), + selectinload(rel(AimbatSeismogram.event)).options( + selectinload(rel(AimbatEvent.parameters)), + selectinload(rel(AimbatEvent.seismograms)).selectinload( + rel(AimbatSeismogram.parameters) + ), + ), + ) + ).one_or_none() if seismogram is None: raise NoResultFound(f"No AimbatSeismogram found with {seismogram_id=}") @@ -201,7 +222,19 @@ def set_seismogram_parameter( f"Setting seismogram {name=} to {value=} in seismogram {seismogram_id=}." ) - seismogram = session.get(AimbatSeismogram, seismogram_id) + seismogram = session.exec( + select(AimbatSeismogram) + .where(AimbatSeismogram.id == seismogram_id) + .options( + selectinload(rel(AimbatSeismogram.parameters)), + selectinload(rel(AimbatSeismogram.event)).options( + selectinload(rel(AimbatEvent.parameters)), + selectinload(rel(AimbatEvent.seismograms)).selectinload( + rel(AimbatSeismogram.parameters) + ), + ), + ) + ).one_or_none() if seismogram is None: raise ValueError(f"No AimbatSeismogram found with {seismogram_id=}") @@ -246,6 +279,12 @@ def get_selected_seismograms( .where(AimbatSeismogram.event_id == event_id) ) + statement = statement.options( + selectinload(rel(AimbatSeismogram.station)), + selectinload(rel(AimbatSeismogram.event)), + selectinload(rel(AimbatSeismogram.parameters)), + selectinload(rel(AimbatSeismogram.quality)), + ) seismograms = session.exec(statement).all() logger.debug(f"Found {len(seismograms)} selected seismograms.") diff --git a/src/aimbat/core/_snapshot.py b/src/aimbat/core/_snapshot.py index 2187cd7f..bbf77432 100644 --- a/src/aimbat/core/_snapshot.py +++ b/src/aimbat/core/_snapshot.py @@ -6,6 +6,7 @@ from pydantic import TypeAdapter from sqlalchemy.exc import NoResultFound +from sqlalchemy.orm import selectinload from sqlmodel import Session, select from aimbat.logger import logger @@ -14,6 +15,7 @@ AimbatEventParametersSnapshot, AimbatEventQuality, AimbatEventQualitySnapshot, + AimbatSeismogram, AimbatSeismogramParametersSnapshot, AimbatSeismogramQuality, AimbatSeismogramQualitySnapshot, @@ -28,7 +30,7 @@ AimbatEventQualityBase, AimbatSeismogramQualityBase, ) -from aimbat.utils import get_title_map +from aimbat.utils import get_title_map, rel __all__ = [ "compute_parameters_hash", @@ -37,7 +39,11 @@ "sync_from_matching_hash", "delete_snapshot", "get_snapshots", - "dump_snapshot_tables", + "dump_snapshot_table", + "dump_event_parameter_snapshot_table", + "dump_seismogram_parameter_snapshot_table", + "dump_event_quality_snapshot_table", + "dump_seismogram_quality_snapshot_table", ] @@ -109,6 +115,19 @@ def create_snapshot( logger.info(f"Creating snapshot for event with id={event.id} with {comment=}.") + event = session.exec( + select(AimbatEvent) + .where(AimbatEvent.id == event.id) + .options( + selectinload(rel(AimbatEvent.parameters)), + selectinload(rel(AimbatEvent.quality)), + selectinload(rel(AimbatEvent.seismograms)).options( + selectinload(rel(AimbatSeismogram.parameters)), + selectinload(rel(AimbatSeismogram.quality)), + ), + ) + ).one() + event_parameters_snapshot = AimbatEventParametersSnapshot.model_validate( event.parameters, update={ @@ -191,7 +210,20 @@ def rollback_to_snapshot(session: Session, snapshot_id: UUID) -> None: logger.info(f"Rolling back to snapshot with id={snapshot_id}.") - snapshot = session.get(AimbatSnapshot, snapshot_id) + statement = ( + select(AimbatSnapshot) + .where(AimbatSnapshot.id == snapshot_id) + .options( + selectinload(rel(AimbatSnapshot.event)).selectinload( + rel(AimbatEvent.parameters) + ), + selectinload(rel(AimbatSnapshot.event_parameters_snapshot)), + selectinload( + rel(AimbatSnapshot.seismogram_parameters_snapshots) + ).selectinload(rel(AimbatSeismogramParametersSnapshot.parameters)), + ) + ) + snapshot = session.exec(statement).one_or_none() if snapshot is None: raise ValueError(f"No AimbatSnapshot found with {snapshot_id=}") @@ -361,40 +393,40 @@ def get_snapshots( else: statement = select(AimbatSnapshot).where(AimbatSnapshot.event_id == event_id) + statement = statement.options( + selectinload(rel(AimbatSnapshot.event)), + selectinload(rel(AimbatSnapshot.event_parameters_snapshot)), + selectinload(rel(AimbatSnapshot.seismogram_parameters_snapshots)), + selectinload(rel(AimbatSnapshot.event_quality_snapshot)), + selectinload(rel(AimbatSnapshot.seismogram_quality_snapshots)), + ) + logger.debug(f"Executing statement to get snapshots: {statement}") return session.exec(statement).all() -def dump_snapshot_tables( +def dump_snapshot_table( session: Session, + event_id: UUID | None = None, from_read_model: bool = False, by_alias: bool = False, by_title: bool = False, exclude: set[str] | None = None, - event_id: UUID | None = None, -) -> dict[str, list[dict[str, Any]]]: - """Dump snapshot data as a dict of lists of dicts. - - Returns a structure with three keys: - - - `snapshots`: flat list of snapshot metadata. - - `event_parameters`: flat list of event parameter snapshots. - - `seismogram_parameters`: flat list of seismogram parameter snapshots. - - Each entry includes a `snapshot_id` for cross-referencing. +) -> list[dict[str, Any]]: + """Dump snapshot metadata as a list of dicts. Args: session: Database session. + event_id: Event ID to filter seismograms by (if none is provided, + seismograms 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. by_title: Whether to use titles for the field names in the output (only applicable when from_read_model is True). Mutually exclusive with by_alias. exclude: Set of field names to exclude from the output. - event_id: Event ID to filter seismograms by (if none is provided, - seismograms for all events are dumped). """ - logger.debug("Dumping AimbatSnapshot tables to json.") + logger.debug("Dumping AimbatSnapshot table to json.") if by_alias and by_title: raise ValueError("Arguments 'by_alias' and 'by_title' are mutually exclusive.") @@ -407,22 +439,6 @@ def dump_snapshot_tables( snapshots = get_snapshots(session, event_id) - event_params_adapter: TypeAdapter[Sequence[AimbatEventParametersSnapshot]] = ( - TypeAdapter(Sequence[AimbatEventParametersSnapshot]) - ) - event_snaps = [s.event_parameters_snapshot for s in snapshots] - event_dicts = event_params_adapter.dump_python( - event_snaps, mode="json", by_alias=by_alias - ) - - seis_params_adapter: TypeAdapter[Sequence[AimbatSeismogramParametersSnapshot]] = ( - TypeAdapter(Sequence[AimbatSeismogramParametersSnapshot]) - ) - seis_snaps = [sp for s in snapshots for sp in s.seismogram_parameters_snapshots] - seis_dicts = seis_params_adapter.dump_python( - seis_snaps, mode="json", by_alias=by_alias - ) - if from_read_model: snapshot_read_adapter: TypeAdapter[Sequence[AimbatSnapshotRead]] = TypeAdapter( Sequence[AimbatSnapshotRead] @@ -448,10 +464,144 @@ def dump_snapshot_tables( snapshots, mode="json", by_alias=by_alias, exclude=exclude ) - data: dict[str, list[dict[str, Any]]] = { - "snapshots": snapshot_dicts, - "event_parameters": event_dicts, - "seismogram_parameters": seis_dicts, - } + return snapshot_dicts + + +def dump_event_parameter_snapshot_table( + session: Session, + event_id: UUID | None = None, + by_alias: bool = False, + exclude: set[str] | None = None, +) -> list[dict[str, Any]]: + """Dump event parameter snapshots as a list of dicts. + + Args: + session: Database session. + event_id: Event ID to filter seismograms by (if none is provided, + seismograms 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. + """ + logger.debug("Dumping AimbatEventParametersSnapshot table to json.") + + if exclude is not None: + exclude: dict[str, set] = {"__all__": exclude} # type: ignore[no-redef] + + snapshots = get_snapshots(session, event_id) + + event_params_adapter: TypeAdapter[Sequence[AimbatEventParametersSnapshot]] = ( + TypeAdapter(Sequence[AimbatEventParametersSnapshot]) + ) + event_snaps = [s.event_parameters_snapshot for s in snapshots] + event_dicts = event_params_adapter.dump_python( + event_snaps, mode="json", by_alias=by_alias, exclude=exclude + ) + + return event_dicts + + +def dump_seismogram_parameter_snapshot_table( + session: Session, + event_id: UUID | None = None, + by_alias: bool = False, + exclude: set[str] | None = None, +) -> list[dict[str, Any]]: + """Dump seismogram parameter snapshots as a list of dicts. + + Args: + session: Database session. + event_id: Event ID to filter seismograms by (if none is provided, + seismograms 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. + """ + logger.debug("Dumping AimbatSeismogramParametersSnapshot table to json.") + + if exclude is not None: + exclude: dict[str, set] = {"__all__": exclude} # type: ignore[no-redef] + + snapshots = get_snapshots(session, event_id) + + seis_params_adapter: TypeAdapter[Sequence[AimbatSeismogramParametersSnapshot]] = ( + TypeAdapter(Sequence[AimbatSeismogramParametersSnapshot]) + ) + seis_snaps = [sp for s in snapshots for sp in s.seismogram_parameters_snapshots] + seis_dicts = seis_params_adapter.dump_python( + seis_snaps, mode="json", by_alias=by_alias, exclude=exclude + ) + + return seis_dicts + + +def dump_event_quality_snapshot_table( + session: Session, + event_id: UUID | None = None, + by_alias: bool = False, + exclude: set[str] | None = None, +) -> list[dict[str, Any]]: + """Dump event quality snapshots as a list of dicts. + + Args: + session: Database session. + 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. + """ + logger.debug("Dumping AimbatEventQualitySnapshot table to json.") + + if exclude is not None: + exclude: dict[str, set] = {"__all__": exclude} # type: ignore[no-redef] + + snapshots = get_snapshots(session, event_id) + + event_quality_adapter: TypeAdapter[Sequence[AimbatEventQualitySnapshot]] = ( + TypeAdapter(Sequence[AimbatEventQualitySnapshot]) + ) + # Filter out snapshots that don't have event quality records. + event_quality_snaps = [ + s.event_quality_snapshot + for s in snapshots + if s.event_quality_snapshot is not None + ] + event_quality_dicts = event_quality_adapter.dump_python( + event_quality_snaps, mode="json", by_alias=by_alias, exclude=exclude + ) + + return event_quality_dicts + + +def dump_seismogram_quality_snapshot_table( + session: Session, + event_id: UUID | None = None, + by_alias: bool = False, + exclude: set[str] | None = None, +) -> list[dict[str, Any]]: + """Dump seismogram quality snapshots as a list of dicts. + + Args: + session: Database session. + 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. + """ + logger.debug("Dumping AimbatSeismogramQualitySnapshot table to json.") + + if exclude is not None: + exclude: dict[str, set] = {"__all__": exclude} # type: ignore[no-redef] + + snapshots = get_snapshots(session, event_id) + + seis_quality_adapter: TypeAdapter[Sequence[AimbatSeismogramQualitySnapshot]] = ( + TypeAdapter(Sequence[AimbatSeismogramQualitySnapshot]) + ) + # Collect all seismogram quality records from all snapshots. + seis_quality_snaps = [ + sq for s in snapshots for sq in s.seismogram_quality_snapshots + ] + seis_quality_dicts = seis_quality_adapter.dump_python( + seis_quality_snaps, mode="json", by_alias=by_alias, exclude=exclude + ) - return data + return seis_quality_dicts diff --git a/src/aimbat/core/_station.py b/src/aimbat/core/_station.py index 78cab3ab..a021a39c 100644 --- a/src/aimbat/core/_station.py +++ b/src/aimbat/core/_station.py @@ -4,6 +4,7 @@ from pydantic import TypeAdapter from sqlalchemy.exc import NoResultFound +from sqlalchemy.orm import selectinload from sqlmodel import Session, select from aimbat.logger import logger @@ -14,7 +15,7 @@ AimbatStation, AimbatStationRead, ) -from aimbat.utils import get_title_map +from aimbat.utils import get_title_map, rel __all__ = [ "delete_station", @@ -77,6 +78,17 @@ def get_stations_in_event( .distinct() .join(AimbatSeismogram) .where(AimbatSeismogram.event_id == event.id) + .options( + selectinload(rel(AimbatStation.seismograms)).selectinload( + rel(AimbatSeismogram.parameters) + ), + selectinload(rel(AimbatStation.seismograms)).selectinload( + rel(AimbatSeismogram.quality) + ), + selectinload(rel(AimbatStation.seismograms)).selectinload( + rel(AimbatSeismogram.event) + ), + ) ) logger.debug(f"Executing query: {statement}") @@ -157,6 +169,18 @@ def dump_station_table( else: statement = select(AimbatStation) + statement = statement.options( + selectinload(rel(AimbatStation.seismograms)).selectinload( + rel(AimbatSeismogram.quality) + ), + selectinload(rel(AimbatStation.seismograms)).selectinload( + rel(AimbatSeismogram.parameters) + ), + selectinload(rel(AimbatStation.seismograms)).selectinload( + rel(AimbatSeismogram.event) + ), + ) + stations = session.exec(statement).all() if from_read_model: diff --git a/src/aimbat/models/__init__.py b/src/aimbat/models/__init__.py index 1471e61c..108da5c8 100644 --- a/src/aimbat/models/__init__.py +++ b/src/aimbat/models/__init__.py @@ -7,8 +7,7 @@ The main classes and their relationships are: -- `AimbatEvent` โ€” a seismic event. Only one event can be the default at a time, - enforced by a database constraint on the `is_default` column. +- `AimbatEvent` โ€” a seismic event. - `AimbatStation` โ€” a seismic recording station. - `AimbatSeismogram` โ€” links an `AimbatEvent` to an `AimbatStation` and holds the timing metadata (`begin_time`, `delta`, `t0`). Waveform data is accessed @@ -34,6 +33,7 @@ _internal_names = set(dir()) +from ._format import * from ._models import * from ._parameters import * from ._quality import * diff --git a/src/aimbat/models/_format.py b/src/aimbat/models/_format.py new file mode 100644 index 00000000..bf430114 --- /dev/null +++ b/src/aimbat/models/_format.py @@ -0,0 +1,69 @@ +"""Column specification dataclasses and formatters for model field display metadata.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING, Any, Literal + +from pydantic import BaseModel, ConfigDict + +if TYPE_CHECKING: + from aimbat.utils.formatters import Formatter + +__all__ = ["RichColSpec", "TuiColSpec"] + + +class RichColSpec(BaseModel): + """Display metadata for a model field rendered in a Rich table. + + Attach to a field via `json_schema_extra={"rich": RichColSpec(...)}`. + Only attributes that differ from the field's defaults need to be set. + + Attributes: + display_title: Override for the column header shown in the Rich table. + If `None`, the field's `title` is used instead. + justify: Horizontal alignment for cell values in this column. + Maps to `rich.table.Column.justify`. If `None`, no explicit + alignment is applied. + style: Style string for the column (e.g. "bold magenta"). + no_wrap: If `True`, cell values in this column will not wrap. + highlight: If `True`, enables Rich's automatic syntax highlighting for + values in this column. If `False`, disables it. If `None`, no + explicit setting is applied. + formatter: Custom formatter for cell values. Called with the raw field + value (guaranteed non-`None`) and must return a display string. If + `None`, a generic fallback is used instead. + """ + + model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True) + + display_title: str | None = None + justify: Literal["left", "center", "right"] | None = None + style: str | None = None + no_wrap: bool | None = None + highlight: bool | None = True + formatter: Callable[[Any], str] | None = None + + +@dataclass(frozen=True) +class TuiColSpec: + """Display metadata for a model field rendered in the TUI. + + Attach to a field via `json_schema_extra={"tui": TuiColSpec(...)}`. + Only attributes that differ from the field's defaults need to be set. + + Attributes: + display_title: Override for the column header shown in the TUI table. + If `None`, the field's `title` is used instead. + text_align: Horizontal alignment for cell values in this column. + Maps to `rich.text.Text.justify`. If `None`, no explicit alignment + is applied and the DataTable renders with its default (left). + formatter: Custom formatter for cell values. Called with the raw field + value (guaranteed non-`None`) and must return a display string. If + `None`, the generic `tui_fmt` fallback is used instead. + """ + + display_title: str | None = None + text_align: Literal["left", "center", "right"] | None = None + formatter: Formatter[Any] | None = None diff --git a/src/aimbat/models/_models.py b/src/aimbat/models/_models.py index 45d44934..bfe3a528 100644 --- a/src/aimbat/models/_models.py +++ b/src/aimbat/models/_models.py @@ -25,6 +25,7 @@ ) from aimbat.io import DataType, read_seismogram_data, write_seismogram_data +from ._format import RichColSpec from ._parameters import AimbatEventParametersBase, AimbatSeismogramParametersBase from ._quality import ( AimbatEventQualityBase, @@ -73,6 +74,7 @@ class AimbatDataSource(SQLModel, table=True): primary_key=True, title="ID", description="Unique data source ID.", + schema_extra={"rich": RichColSpec(style="yellow", highlight=False)}, ) sourcename: str = Field( title="Source name", @@ -89,6 +91,7 @@ class AimbatDataSource(SQLModel, table=True): ondelete="CASCADE", title="Seismogram ID", description="Foreign key referencing the parent seismogram.", + schema_extra={"rich": RichColSpec(style="magenta", highlight=False)}, ) seismogram: "AimbatSeismogram" = Relationship(back_populates="datasource") "The seismogram this data source belongs to." @@ -170,6 +173,9 @@ class AimbatEventParameters(AimbatEventParametersBase, table=True): primary_key=True, title="ID", description="Unique ID.", + schema_extra={ + "rich": RichColSpec(style="yellow", no_wrap=True, highlight=False) + }, ) event_id: uuid.UUID = Field( default=None, @@ -177,6 +183,9 @@ class AimbatEventParameters(AimbatEventParametersBase, table=True): ondelete="CASCADE", title="Event ID", description="Foreign key referencing the parent event.", + schema_extra={ + "rich": RichColSpec(style="magenta", no_wrap=True, highlight=False) + }, ) event: "AimbatEvent" = Relationship(back_populates="parameters") "The event these parameters belong to." @@ -572,7 +581,6 @@ class AimbatEvent(SQLModel, table=True): ) id: uuid.UUID = Field(default_factory=uuid.uuid4, primary_key=True) - is_default: bool | None = Field(default=None, unique=True) time: PydanticTimestamp = Field( unique=True, sa_type=SAPandasTimestamp, allow_mutation=False ) diff --git a/src/aimbat/models/_readers.py b/src/aimbat/models/_readers.py index d3f1fd24..582c1f70 100644 --- a/src/aimbat/models/_readers.py +++ b/src/aimbat/models/_readers.py @@ -6,6 +6,9 @@ from aimbat._types import PydanticTimedelta, PydanticTimestamp from aimbat.utils import mean_and_sem +from aimbat.utils.formatters import fmt_depth_km, fmt_flip + +from ._format import RichColSpec, TuiColSpec if TYPE_CHECKING: from sqlmodel import Session @@ -32,19 +35,23 @@ class AimbatEventRead(BaseModel): id: UUID = Field( title="ID", description="Unique identifier for the event", + json_schema_extra={ + "rich": RichColSpec(style="yellow"), # type: ignore[dict-item] + }, ) short_id: str | None = Field( default=None, title="Short ID", description="Shortened unique identifier", - ) - is_default: bool | None = Field( - title="Default", - description="Indicates if this event is the default event", + json_schema_extra={ + "tui": TuiColSpec(display_title="ID"), # type: ignore[dict-item] + "rich": RichColSpec(display_title="ID", style="yellow", highlight=False), # type: ignore[dict-item] + }, ) completed: bool = Field( title="Completed", description="Indicates if the event's parameters are marked as completed", + json_schema_extra={"tui": TuiColSpec(text_align="center")}, # type: ignore[dict-item] ) time: PydanticTimestamp = Field( title="Event time", @@ -61,17 +68,23 @@ class AimbatEventRead(BaseModel): depth: float | None = Field( title="Depth", description="Depth of the event", + json_schema_extra={ + "tui": TuiColSpec(display_title="Depth km", formatter=fmt_depth_km), # type: ignore[dict-item] + "rich": RichColSpec( + display_title=r"Depth \[km]", justify="right", formatter=fmt_depth_km + ), # type: ignore[dict-item] + }, ) seismogram_count: int = Field( - title="Seismogram count", + title="Seismograms", description="Number of seismograms associated with this event", ) station_count: int = Field( - title="Station count", + title="Stations", description="Number of stations associated with this event", ) snapshot_count: int = Field( - title="Snapshot count", + title="Snapshots", description="Number of snapshots associated with this event", ) last_modified: PydanticTimestamp | None = Field( @@ -108,11 +121,23 @@ class AimbatStationRead(BaseModel): frozen=True, alias_generator=to_camel, populate_by_name=True ) - id: UUID = Field(title="ID", description="Unique identifier for the station") + id: UUID = Field( + title="ID", + description="Unique identifier for the station", + json_schema_extra={ + "rich": RichColSpec(style="yellow", no_wrap=True, highlight=False), # type: ignore[dict-item] + }, + ) short_id: str | None = Field( default=None, title="Short ID", description="Shortened unique identifier", + json_schema_extra={ + "tui": TuiColSpec(display_title="ID"), # type: ignore[dict-item] + "rich": RichColSpec( + display_title="ID", style="yellow", no_wrap=True, highlight=False + ), # type: ignore[dict-item] + }, ) network: str = Field(title="Network", description="Station network code") name: str = Field(title="Name", description="Station name") @@ -121,7 +146,10 @@ class AimbatStationRead(BaseModel): latitude: float = Field(title="Latitude", description="Station latitude") longitude: float = Field(title="Longitude", description="Station longitude") elevation: float | None = Field( - default=None, title="Elevation", description="Station elevation" + default=None, + title="Elevation", + description="Station elevation", + json_schema_extra={"tui": TuiColSpec(formatter=lambda x: str(int(x)))}, # type: ignore[dict-item] ) cc_mean: float | None = Field( @@ -155,19 +183,14 @@ def from_station( data = station.model_dump() if session is not None: - from sqlmodel import select - from aimbat.utils import uuid_shortener - from ._models import AimbatSeismogram, AimbatSeismogramQuality - data["short_id"] = uuid_shortener(session, station) - statement = ( - select(AimbatSeismogramQuality.iccs_cc) - .join(AimbatSeismogram) - .where(AimbatSeismogram.station_id == station.id) + iccs_ccs = tuple( + seis.quality.iccs_cc + for seis in station.seismograms + if seis.quality is not None and seis.quality.iccs_cc is not None ) - iccs_ccs = tuple(session.exec(statement).all()) data["cc_mean"], data["cc_sem"] = mean_and_sem(iccs_ccs) data.update( @@ -191,11 +214,20 @@ class AimbatSeismogramRead(BaseModel): id: UUID = Field( title="ID", description="Unique identifier for the seismogram", + json_schema_extra={ + "rich": RichColSpec(style="yellow", no_wrap=True, highlight=False), # type: ignore[dict-item] + }, ) short_id: str | None = Field( default=None, title="Short ID", description="Shortened unique identifier", + json_schema_extra={ + "tui": TuiColSpec(display_title="ID"), # type: ignore[dict-item] + "rich": RichColSpec( + display_title="ID", style="yellow", no_wrap=True, highlight=False + ), # type: ignore[dict-item] + }, ) name: str = Field(title="Name", description="Name of the seismogram.") @@ -207,11 +239,16 @@ class AimbatSeismogramRead(BaseModel): select: bool = Field( title="Select", description="Whether the seismogram is selected for processing.", + json_schema_extra={"tui": TuiColSpec(text_align="center")}, # type: ignore[dict-item] ) flip: bool = Field( title="Flip", description="Whether the seismogram is flipped for processing.", + json_schema_extra={ + "tui": TuiColSpec(text_align="center", formatter=fmt_flip), # type: ignore[dict-item] + "rich": RichColSpec(formatter=fmt_flip), # type: ignore[dict-item] + }, ) delta_t: PydanticTimedelta | None = Field( @@ -242,11 +279,19 @@ class AimbatSeismogramRead(BaseModel): event_id: UUID = Field( title="Event ID", description="ID of the associated event.", + json_schema_extra={ + "rich": RichColSpec(style="magenta", no_wrap=True, highlight=False), # type: ignore[dict-item] + }, ) short_event_id: str | None = Field( title="Short Event ID", description="Shortened unique identifier for the associated event.", + json_schema_extra={ + "rich": RichColSpec( + display_title="Event ID", style="magenta", no_wrap=True, highlight=False + ), # type: ignore[dict-item] + }, ) @classmethod @@ -297,11 +342,20 @@ class AimbatSnapshotRead(BaseModel): id: UUID = Field( title="ID", description="Unique identifier for the snapshot", + json_schema_extra={ + "rich": RichColSpec(style="yellow", no_wrap=True, highlight=False), # type: ignore[dict-item] + }, ) short_id: str | None = Field( default=None, title="Short ID", description="Shortened unique identifier", + json_schema_extra={ + "tui": TuiColSpec(display_title="ID"), # type: ignore[dict-item] + "rich": RichColSpec( + display_title="ID", style="yellow", no_wrap=True, highlight=False + ), # type: ignore[dict-item] + }, ) time: PydanticTimestamp = Field( title="Time", description="Timestamp of the snapshot" @@ -310,15 +364,15 @@ class AimbatSnapshotRead(BaseModel): title="Comment", description="Optional comment for the snapshot" ) seismogram_count: int = Field( - title="# Seismograms", + title="Seismograms", description="Total number of seismograms in the snapshot", ) selected_seismogram_count: int = Field( - title="# Selected", + title="Selected", description="Number of selected seismograms in the snapshot", ) flipped_seismogram_count: int = Field( - title="# Flipped", + title="Flipped", description="Number of flipped seismograms in the snapshot", ) @@ -334,10 +388,27 @@ class AimbatSnapshotRead(BaseModel): description="Standard error of the mean of cross-correlation coefficients for this snapshot", ) - event_id: UUID = Field(title="Event ID", description="ID of the associated event") + mccc: bool | None = Field( + default=None, + title="MCCC", + description="Whether MCCC parameters are included in this snapshot", + ) + + event_id: UUID = Field( + title="Event ID", + description="ID of the associated event", + json_schema_extra={ + "rich": RichColSpec(style="magenta", no_wrap=True, highlight=False), # type: ignore[dict-item] + }, + ) short_event_id: str | None = Field( title="Short Event ID", description="Shortened unique identifier for the associated event", + json_schema_extra={ + "rich": RichColSpec( + display_title="Event ID", style="magenta", no_wrap=True, highlight=False + ), # type: ignore[dict-item] + }, ) @classmethod @@ -360,6 +431,8 @@ def from_snapshot( if q.iccs_cc is not None ] cc_mean, cc_sem = mean_and_sem(iccs_ccs) + mccc = bool(snapshot.event_quality_snapshot) + return cls( id=snapshot.id, short_id=short_id, @@ -370,6 +443,7 @@ def from_snapshot( flipped_seismogram_count=snapshot.flipped_seismogram_count, cc_mean=cc_mean, cc_sem=cc_sem, + mccc=mccc, event_id=snapshot.event_id, short_event_id=short_event_id, ) diff --git a/src/aimbat/plot/_seismograms.py b/src/aimbat/plot/_seismograms.py index d0b080bf..6f7134bb 100644 --- a/src/aimbat/plot/_seismograms.py +++ b/src/aimbat/plot/_seismograms.py @@ -6,6 +6,7 @@ import mplcursors # type: ignore[import-untyped] import pandas as pd from matplotlib import ticker +from matplotlib.backend_bases import MouseEvent from sqlmodel import Session from pysmo.tools.plotutils import time_array @@ -17,6 +18,37 @@ __all__ = ["plot_seismograms"] +_VISIBLE_SEISMOGRAMS = 7 + + +def _add_scroll_pan(ax: plt.Axes) -> None: + """Pan the y-axis on scroll and the x-axis on shift+scroll.""" + y_lo, y_hi = ax.dataLim.y0, ax.dataLim.y1 + + def on_scroll(event: MouseEvent) -> None: + if event.inaxes is not ax: + return + direction = 1 if event.button == "up" else -1 + if event.key == "shift": + xmin, xmax = ax.get_xlim() + step = (xmax - xmin) * 0.1 * direction + ax.set_xlim(xmin + step, xmax + step) + else: + ymin, ymax = ax.get_ylim() + step = (ymax - ymin) * 0.1 * direction + new_ymin = ymin + step + new_ymax = ymax + step + if new_ymin < y_lo: + new_ymin = y_lo + new_ymax = y_lo + (ymax - ymin) + elif new_ymax > y_hi: + new_ymax = y_hi + new_ymin = y_hi - (ymax - ymin) + ax.set_ylim(new_ymin, new_ymax) + ax.figure.canvas.draw_idle() + + ax.figure.canvas.mpl_connect("scroll_event", on_scroll) # type: ignore[arg-type] + @singledispatch def _plot_seis( @@ -34,11 +66,16 @@ def _(event: AimbatEvent, session: Session) -> tuple[plt.Figure, plt.Axes]: logger.debug(f"Found {len(seismograms)} seismograms for event {event.id}.") - fig, ax = plt.subplots(figsize=(10, 6)) + fig, ax = plt.subplots(figsize=(10, 6), layout="tight") distance_min = min(d[2] for d in seismograms) distance_max = max(d[2] for d in seismograms) - scaling_factor = (distance_max - distance_min) / len(seismograms) * 5 + + if len(seismograms) == 1: + scaling_factor = max(distance_min * 0.1, 1.0) + else: + distance_spacing = (distance_max - distance_min) / (len(seismograms) - 1) + scaling_factor = distance_spacing * 0.8 for seismogram, station, distance_km, id in seismograms: data = seismogram.data * scaling_factor + distance_km @@ -55,10 +92,20 @@ def on_add(sel: mplcursors.Selection) -> None: plt.xlabel(xlabel="Time of day") plt.ylabel(ylabel="Epicentral distance [km]") - plt.gcf().autofmt_xdate() + fig.autofmt_xdate() fmt = mdates.DateFormatter("%H:%M:%S") plt.gca().xaxis.set_major_formatter(fmt) plt.title(event.time.strftime("Event %Y-%m-%d %H:%M:%S")) + + if len(seismograms) > _VISIBLE_SEISMOGRAMS: + bottom = ( + seismograms[_VISIBLE_SEISMOGRAMS - 1][2] + - scaling_factor + - distance_spacing * 0.5 + ) + top = seismograms[0][2] + scaling_factor + ax.set_ylim(bottom, top) + return fig, ax @@ -70,10 +117,10 @@ def _(station: AimbatStation, session: Session) -> tuple[plt.Figure, plt.Axes]: logger.debug(f"Found {len(seismograms)} seismograms for station {station.id}.") - fig, ax = plt.subplots(figsize=(10, 6)) + fig, ax = plt.subplots(figsize=(10, 6), layout="tight") for i, (seismogram, event, pick, id) in enumerate(seismograms): - data = seismogram.data + i + data = seismogram.data * 0.4 + i start = seismogram.begin_time - pick end = seismogram.end_time - pick td_index = pd.timedelta_range(start=start, end=end, periods=len(data)) @@ -91,6 +138,10 @@ def on_add(sel: mplcursors.Selection) -> None: ax.yaxis.set_visible(False) plt.xlabel(xlabel="Time relative to pick") plt.title(f"Station {station.network} -- {station.name}") + + if len(seismograms) > _VISIBLE_SEISMOGRAMS: + ax.set_ylim(-0.5, _VISIBLE_SEISMOGRAMS - 0.1) + return fig, ax @@ -128,6 +179,7 @@ def plot_seismograms( logger.info(f"Plotting seismograms for {type(plot_for).__name__}: {plot_for.id}.") fig, ax = _plot_seis(plot_for, session) + _add_scroll_pan(ax) if return_fig: return fig, ax diff --git a/src/aimbat/utils/__init__.py b/src/aimbat/utils/__init__.py index 9bb92433..d7accb0c 100644 --- a/src/aimbat/utils/__init__.py +++ b/src/aimbat/utils/__init__.py @@ -17,7 +17,7 @@ from ._maths import * from ._pydantic import * from ._sampledata import * -from ._table import * +from ._sqlalchemy import * from ._uuid import * __all__ = [s for s in dir() if not s.startswith("_") and s not in _internal_names] diff --git a/src/aimbat/utils/_sqlalchemy.py b/src/aimbat/utils/_sqlalchemy.py new file mode 100644 index 00000000..111f25a9 --- /dev/null +++ b/src/aimbat/utils/_sqlalchemy.py @@ -0,0 +1,15 @@ +from typing import Any, cast + +from sqlalchemy.orm import QueryableAttribute + +__all__ = ["rel"] + + +def rel(attr: Any) -> QueryableAttribute[Any]: + """Cast a SQLModel relationship attribute to `QueryableAttribute` for use with `selectinload`. + + SQLModel types relationship fields as their Python collection type (e.g. `list[Foo]`), + but SQLAlchemy's `selectinload` expects a `QueryableAttribute`. This helper performs + the cast to satisfy mypy without requiring per-call `# type: ignore` comments. + """ + return cast(QueryableAttribute[Any], attr) diff --git a/src/aimbat/utils/_table.py b/src/aimbat/utils/_table.py deleted file mode 100644 index 083e3e8d..00000000 --- a/src/aimbat/utils/_table.py +++ /dev/null @@ -1,250 +0,0 @@ -import math -from dataclasses import dataclass, fields -from datetime import datetime -from typing import Any, Callable, Iterable, Mapping, NamedTuple - -from pandas import NaT, Timestamp, to_datetime -from rich.console import Console -from rich.table import Table - -__all__ = "FormatResult", "TABLE_STYLING", "json_to_table" - - -class FormatResult(NamedTuple): - """Container for a formatted value and its display metadata.""" - - text: str - justify: str = "left" - style: str | None = None - - -@dataclass(frozen=True) -class TableStyling: - """Class to set the colour and formatting of table elements.""" - - ID: str = "yellow" - linked: str = "magenta" - mine: str = "cyan" - parameters: str = "green" - - @staticmethod - def flip_formatter(val: bool | Any) -> FormatResult: - if val is True: - return FormatResult("[bold yellow]:up-down_arrow:[/]", justify="center") - elif val is False: - return FormatResult("", justify="center") - return FormatResult(str(val)) - - @staticmethod - def bool_formatter(val: bool | Any) -> FormatResult: - if val is None: - return FormatResult("", justify="center") - if val is True: - return FormatResult("[bold green]:heavy_check_mark:[/]", justify="center") - elif val is False: - return FormatResult( - "[bold red]:heavy_multiplication_x:[/]", justify="center" - ) - return FormatResult(str(val)) - - @staticmethod - def float_formatter(value: float | Any, short: bool = True) -> FormatResult: - if value is None or (isinstance(value, float) and math.isnan(value)): - return FormatResult("โ€”", justify="right") - text = f"{value:.3f}" if short and isinstance(value, float) else str(value) - return FormatResult(text, justify="right") - - @staticmethod - def timestamp_formatter(dt: Any, short: bool = True) -> FormatResult: - if isinstance(dt, str) and dt.strip(): - try: - dt = to_datetime(dt) - except (ValueError, TypeError): - return FormatResult(str(dt), justify="left") - - if dt is NaT or dt is None or dt == "": - return FormatResult("โ€”", justify="left") - - if not hasattr(dt, "strftime"): - return FormatResult(str(dt) if dt is not None else "โ€”") - - text = dt.strftime("%Y-%m-%d %H:%M:%S") if short else str(dt) - return FormatResult(text, justify="left", style="italic") - - @staticmethod - def default_formatter(val: Any) -> FormatResult: - """Fallback formatter for strings and other types.""" - if val is None or val is NaT or val == "": - return FormatResult("โ€”") - - text = str(val) - justify = "right" if text.isdigit() else "left" - return FormatResult(text, justify=justify) - - @staticmethod - def short_id_formatter(val: Any) -> FormatResult: - """Formatter for short ID columns.""" - if val is None or val is NaT or val == "": - return FormatResult("โ€”") - return FormatResult(str(val), style="yellow") - - -TABLE_STYLING = TableStyling() - - -def json_to_table( - data: dict[str, Any] | list[dict[str, Any]], - title: str | None = None, - formatters: Mapping[str, Callable[[Any], str | FormatResult]] | None = None, - skip_keys: list[str] | None = None, - column_order: list[str] | None = None, - column_kwargs: Mapping[str, Mapping[str, Any]] | None = None, - common_column_kwargs: Mapping[str, Any] | None = None, - short: bool = True, -) -> None: - """ - Print a JSON dict or list of dicts as a rich table. - Headers are kept as-is from keys unless renamed via column_kwargs. - """ - formatters = formatters or {} - skip = set(skip_keys or []) - column_kwargs = column_kwargs or {} - common_column_kwargs = common_column_kwargs or {} - console = Console() - table = Table(title=title) - - styling_keys = {f.name for f in fields(TableStyling)} - - def _sorted_keys(keys: Iterable[str]) -> list[str]: - keys_list = list(keys) - if not column_order: - return keys_list - ordered = [k for k in column_order if k in keys_list] - rest = [k for k in keys_list if k not in set(column_order)] - return ordered + rest - - def _get_formatted(key: str, val: Any) -> FormatResult: - """Resolves formatting and ensures a FormatResult is always returned.""" - raw_result: str | FormatResult | None = None - - if key in formatters: - raw_result = formatters[key](val) - - if raw_result is None: - low_key = key.lower() - - if isinstance(val, bool): - raw_result = TABLE_STYLING.bool_formatter(val) - elif isinstance(val, float): - raw_result = TABLE_STYLING.float_formatter(val, short=short) - elif isinstance(val, (Timestamp, datetime)): - raw_result = TABLE_STYLING.timestamp_formatter(val, short=short) - elif ( - isinstance(val, str) - and val.strip() - and any(k in low_key for k in ("time", "date", "modified")) - ): - raw_result = TABLE_STYLING.timestamp_formatter(val, short=short) - else: - raw_result = TABLE_STYLING.default_formatter(val) - - return ( - raw_result - if isinstance(raw_result, FormatResult) - else FormatResult(str(raw_result)) - ) - - def _add_column_to_table(key: str, default_header: str) -> None: - specific_kwargs = dict(column_kwargs.get(key, {})) - kwargs = {**common_column_kwargs, **specific_kwargs} - header = kwargs.pop("header", default_header) - - low_key = key.lower() - is_short_id = "short" in low_key and ("_id" in low_key or " id" in low_key) - is_exact_id = low_key == "id" - - if is_short_id or is_exact_id: - for sep in ("_", " "): - search = f"short{sep}id" - if low_key == search: - header = "ID" - kwargs.setdefault("style", "yellow") - break - sep_idx = low_key.find(f"short{sep}") - id_idx = low_key.find("id") - if sep_idx >= 0 and id_idx > sep_idx + 6: - middle = key[sep_idx + 6 : id_idx].strip("_").strip() - if middle: - header = f"{middle.title()} ID" - kwargs.setdefault("style", "magenta") - else: - header = "ID" - kwargs.setdefault("style", "yellow") - break - else: - header = "ID" - kwargs.setdefault("style", "yellow") - kwargs.setdefault("highlight", False) - kwargs.setdefault("no_wrap", True) - elif low_key.endswith("_id") or low_key.endswith(" id"): - middle = "" - for suffix in ("_id", " id"): - if low_key.endswith(suffix): - middle = key[: -len(suffix)].strip("_").strip() - break - if middle: - header = f"{middle.title()} ID" - kwargs.setdefault("style", "magenta") - else: - header = "ID" - kwargs.setdefault("style", "yellow") - kwargs.setdefault("highlight", False) - kwargs.setdefault("no_wrap", True) - - hints = FormatResult("") - - if isinstance(data, dict): - hints = _get_formatted(key, data.get(key)) - elif data: - for item in data: - val = item.get(key) - if ( - val is not None - and val is not NaT - and (not isinstance(val, str) or val.strip() != "") - ): - hints = _get_formatted(key, val) - break - - if "style" not in kwargs and key in styling_keys: - kwargs["style"] = getattr(TABLE_STYLING, key) - - if "highlight" not in kwargs: - kwargs["highlight"] = not is_short_id - - if "justify" not in kwargs: - kwargs["justify"] = hints.justify - if "style" not in kwargs and hints.style: - kwargs["style"] = hints.style - - table.add_column(header, **kwargs) - - if isinstance(data, dict): - _add_column_to_table("Key", "Key") - _add_column_to_table("Value", "Value") - keys = _sorted_keys([k for k in data if k not in skip]) - for key in keys: - res = _get_formatted(key, data[key]) - table.add_row(str(key), res.text) - else: - if not data: - console.print(table) - return - columns = _sorted_keys([k for k in data[0].keys() if k not in skip]) - for col in columns: - _add_column_to_table(col, str(col)) - for item in data: - row_cells = [_get_formatted(col, item.get(col)).text for col in columns] - table.add_row(*row_cells) - - console.print(table) diff --git a/src/aimbat/utils/formatters.py b/src/aimbat/utils/formatters.py new file mode 100644 index 00000000..1c68f8db --- /dev/null +++ b/src/aimbat/utils/formatters.py @@ -0,0 +1,68 @@ +import math +from collections.abc import Callable +from typing import Any + +from pandas import NaT, Timedelta, to_datetime + +__all__ = [ + "Formatter", + "fmt_bool", + "fmt_depth_km", + "fmt_flip", + "fmt_float", + "fmt_timedelta", + "fmt_timestamp", +] + +type Formatter[T] = Callable[[T], str] + + +def fmt_depth_km(val: int | float | object) -> str: + """Format a depth value in metres as kilometres with one decimal place.""" + if isinstance(val, (int, float)): + return f"{val / 1000:.1f}" + return str(val) + + +def fmt_bool(val: bool | object) -> str: + """Format a boolean as `โœ“` (True) or empty string (False/None).""" + return "โœ“" if val is True else "" + + +def fmt_float(val: float | object) -> str: + """Format a float to 3 decimal places, or `โ€”` for None/NaN.""" + if val is None or (isinstance(val, float) and math.isnan(val)): + return "โ€” " + if isinstance(val, float): + return f"{val:.3f}" + return str(val) + + +def fmt_timestamp(val: Any) -> str: + """Format a timestamp as `YYYY-MM-DD HH:MM:SS`, or `โ€”` for missing values.""" + if isinstance(val, str) and val.strip(): + try: + val = to_datetime(val) + except (ValueError, TypeError): + return str(val) + if val is None or val is NaT or val == "": + return "โ€” " + if hasattr(val, "strftime"): + return val.strftime("%Y-%m-%d %H:%M:%S") + return str(val) + + +def fmt_timedelta(val: Timedelta | object) -> str: + """Format a Timedelta as total seconds to 5 decimal places, or `โ€”` for None.""" + if val is None: + return "โ€” " + if isinstance(val, Timedelta): + return f"{val.total_seconds():.5f} s" + return str(val) + + +def fmt_flip(val: bool | object) -> str: + """Format a boolean flip flag as `โ†•` (True) or empty string (False).""" + if isinstance(val, bool): + return "โ†•" if val else "" + return str(val) diff --git a/tests/conftest.py b/tests/conftest.py index 6f257b03..f45d3072 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,14 +12,13 @@ import matplotlib.pyplot as plt import pytest from sqlalchemy import Engine, event -from sqlmodel import Session, create_engine, select +from sqlmodel import Session, create_engine import aimbat.db from aimbat.app import app -from aimbat.core import add_data_to_project, create_project, set_default_event +from aimbat.core import add_data_to_project, create_project from aimbat.io import DataType from aimbat.logger import configure_logging -from aimbat.models import AimbatEvent # --------------------------------------------------------------------------- # Constants @@ -260,9 +259,6 @@ def loaded_engine(patched_engine: Engine, multi_event_data: Sequence[Path]) -> E datasources = multi_event_data with Session(patched_engine) as session: add_data_to_project(session, datasources, DataType.SAC) - events = session.exec(select(AimbatEvent)).all() - lengths = [len(e.seismograms) for e in events] - set_default_event(session, events[lengths.index(max(lengths))].id) return patched_engine @@ -341,6 +337,21 @@ def _run(command: str) -> list | dict: return _run +@pytest.fixture() +def event_id(loaded_engine: Engine, cli_json: Callable[[str], list | dict]) -> str: + """Returns the ID of the first event from the loaded engine. + + Args: + loaded_engine: The monkeypatched SQLAlchemy Engine with data loaded. + cli_json: The CLI JSON callable to query event dump. + + Returns: + The ID string of the first event. + """ + events = cli_json("event dump") + return events[0]["id"] + + @pytest.fixture() def aimbat_subprocess( db_path: Path, diff --git a/tests/functional/test_cli_basic_ops.py b/tests/functional/test_cli_basic_ops.py index 14f102a1..d27fea02 100644 --- a/tests/functional/test_cli_basic_ops.py +++ b/tests/functional/test_cli_basic_ops.py @@ -1,6 +1,6 @@ """Functional tests exercising the AIMBAT CLI. -All commands are invoked in-process via ``app()`` with ``aimbat.db.engine`` +All commands are invoked in-process via `app()` with `aimbat.db.engine` monkeypatched to the test fixture's in-memory database. """ @@ -109,7 +109,7 @@ def test_add_data_idempotent( def test_data_list(self, loaded_engine: Engine, cli: Callable[[str], None]) -> None: """Verifies that data list command runs successfully.""" - cli("data list --all") + cli("data list --event-id all") def test_data_dump( self, @@ -228,39 +228,21 @@ def test_default_event( self, loaded_engine: Engine, cli: Callable[[str], None], - cli_json: Callable[[str], list | dict], ) -> None: - """Verifies that an event can be set as default.""" - events = cli_json("event dump") - - non_default_events = [e for e in events if e["is_default"] is None] - assert len(non_default_events) > 0 - target_id = non_default_events[0]["id"] - - cli(f"event default {target_id}") - - events_after = cli_json("event dump") - default_events = [e for e in events_after if e["is_default"] is True] - assert len(default_events) == 1 - assert default_events[0]["id"] == target_id + """Verifies that event default command was removed.""" + # The default event concept was removed; this test verifies the command is gone + with pytest.raises((SystemExit, RuntimeError)): + cli("event default") def test_default_switches_previous( self, loaded_engine: Engine, cli: Callable[[str], None], - cli_json: Callable[[str], list | dict], ) -> None: - """Setting a different default event replaces the previous one.""" - events = cli_json("event dump") - ids = [e["id"] for e in events] - - cli(f"event default {ids[0]}") - cli(f"event default {ids[1]}") - - events_after = cli_json("event dump") - default_events = [e for e in events_after if e["is_default"] is True] - assert len(default_events) == 1 - assert default_events[0]["id"] == ids[1] + """Verifies that event default command was removed.""" + # The default event concept was removed; this test verifies the command is gone + with pytest.raises((SystemExit, RuntimeError)): + cli("event default") def test_delete_event( self, @@ -283,27 +265,11 @@ def test_default_event_with_short_id( self, loaded_engine: Engine, cli: Callable[[str], None], - cli_json: Callable[[str], list | dict], ) -> None: - """Verifies that an event can be set as default using a shortened ID. - - Args: - loaded_engine: The monkeypatched engine with data loaded. - cli: The in-process CLI callable. - cli_json: The in-process CLI JSON dump callable. - """ - events = cli_json("event dump") - non_default_events = [e for e in events if e["is_default"] is None] - assert len(non_default_events) > 0 - target_id = non_default_events[0]["id"] - short_id = target_id[:8] - - cli(f"event default {short_id}") - - events_after = cli_json("event dump") - default_events = [e for e in events_after if e["is_default"] is True] - assert len(default_events) == 1 - assert default_events[0]["id"] == target_id + """Verifies that event default command with short ID was removed.""" + # The default event concept was removed; this test verifies the command is gone + with pytest.raises((SystemExit, RuntimeError)): + cli("event default 00000000") def test_delete_event_with_short_id( self, @@ -356,24 +322,31 @@ class TestEventParameters: """Tests for event parameter CLI commands.""" def test_parameter_list( - self, loaded_engine: Engine, cli: Callable[[str], None] + self, + loaded_engine: Engine, + cli: Callable[[str], None], + cli_json: Callable[[str], list | dict], ) -> None: """Verifies that parameter list command runs successfully.""" - cli("event parameter list") + event_id = cli_json("event dump")[0]["id"] + cli(f"event parameter list --event-id {event_id}") def test_parameter_get_and_set( self, loaded_engine: Engine, cli: Callable[[str], None], + cli_json: Callable[[str], list | dict], capsys: pytest.CaptureFixture[str], ) -> None: """Verifies getting and setting event parameters.""" - cli("event parameter get completed") + event_id = cli_json("event dump")[0]["id"] + + cli(f"event parameter get completed --event-id {event_id}") assert "False" in capsys.readouterr().out - cli("event parameter set completed true") + cli(f"event parameter set completed true --event-id {event_id}") - cli("event parameter get completed") + cli(f"event parameter get completed --event-id {event_id}") assert "True" in capsys.readouterr().out def test_parameter_dump( @@ -383,7 +356,9 @@ def test_parameter_dump( ) -> None: """Verifies that parameter dump returns parameter data.""" data = cli_json("event parameter dump") - assert "completed" in data + assert isinstance(data, list) + assert len(data) > 0 + assert "completed" in data[0] # =================================================================== @@ -399,7 +374,7 @@ def test_station_list( self, loaded_engine: Engine, cli: Callable[[str], None] ) -> None: """Verifies that station list command runs successfully.""" - cli("station list --all") + cli("station list --event-id all") def test_station_dump( self, @@ -458,10 +433,14 @@ class TestSeismogramOperations: """Tests for seismogram-related CLI commands.""" def test_seismogram_list( - self, loaded_engine: Engine, cli: Callable[[str], None] + self, + loaded_engine: Engine, + cli: Callable[[str], None], + cli_json: Callable[[str], list | dict], ) -> None: """Verifies that seismogram list command runs successfully.""" - cli("seismogram list") + event_id = cli_json("event dump")[0]["id"] + cli(f"seismogram list --event-id {event_id}") def test_seismogram_dump( self, @@ -526,7 +505,8 @@ def test_create_snapshot( cli_json: Callable[[str], list | dict], ) -> None: """Verifies that a snapshot can be created.""" - cli("snapshot create initial") + event_id = cli_json("event dump")[0]["id"] + cli(f"snapshot create --event-id {event_id} --comment initial") data = cli_json("snapshot dump") assert isinstance(data, dict) snapshots = data["snapshots"] @@ -542,8 +522,9 @@ def test_create_multiple_snapshots( cli_json: Callable[[str], list | dict], ) -> None: """Verifies that multiple snapshots can be created.""" - cli("snapshot create first") - cli("snapshot create second") + event_id = cli_json("event dump")[0]["id"] + cli(f"snapshot create --event-id {event_id} --comment first") + cli(f"snapshot create --event-id {event_id} --comment second") data = cli_json("snapshot dump") assert isinstance(data, dict) snapshots = data["snapshots"] @@ -558,7 +539,8 @@ def test_delete_snapshot( cli_json: Callable[[str], list | dict], ) -> None: """Verifies that a snapshot can be deleted.""" - cli("snapshot create to-delete") + event_id = cli_json("event dump")[0]["id"] + cli(f"snapshot create --event-id {event_id} --comment to-delete") data = cli_json("snapshot dump") assert isinstance(data, dict) snapshots = data["snapshots"] @@ -583,7 +565,8 @@ def test_delete_snapshot_with_short_id( cli: The in-process CLI callable. cli_json: The in-process CLI JSON dump callable. """ - cli("snapshot create to-delete") + event_id = cli_json("event dump")[0]["id"] + cli(f"snapshot create --event-id {event_id} --comment to-delete") data = cli_json("snapshot dump") assert isinstance(data, dict) snapshots = data["snapshots"] @@ -596,13 +579,6 @@ def test_delete_snapshot_with_short_id( assert isinstance(data_after, dict) assert len(data_after["snapshots"]) == 0 - def test_snapshot_list( - self, loaded_engine: Engine, cli: Callable[[str], None] - ) -> None: - """Verifies that snapshot list command runs successfully.""" - cli("snapshot create") - cli("snapshot list") - def test_rollback_snapshot( self, loaded_engine: Engine, @@ -611,17 +587,18 @@ def test_rollback_snapshot( capsys: pytest.CaptureFixture[str], ) -> None: """Rollback restores parameter values from a snapshot.""" - cli("snapshot create before-change") + event_id = cli_json("event dump")[0]["id"] + cli(f"snapshot create --event-id {event_id} --comment before-change") - cli("event parameter set completed true") - cli("event parameter get completed") + cli(f"event parameter set completed true --event-id {event_id}") + cli(f"event parameter get completed --event-id {event_id}") assert "True" in capsys.readouterr().out data = cli_json("snapshot dump") assert isinstance(data, dict) cli(f"snapshot rollback {data['snapshots'][0]['id']}") - cli("event parameter get completed") + cli(f"event parameter get completed --event-id {event_id}") assert "False" in capsys.readouterr().out def test_rollback_snapshot_with_short_id( @@ -639,10 +616,11 @@ def test_rollback_snapshot_with_short_id( cli_json: The in-process CLI JSON dump callable. capsys: The pytest capsys fixture. """ - cli("snapshot create before-change") + event_id = cli_json("event dump")[0]["id"] + cli(f"snapshot create --event-id {event_id} --comment before-change") - cli("event parameter set completed true") - cli("event parameter get completed") + cli(f"event parameter set completed true --event-id {event_id}") + cli(f"event parameter get completed --event-id {event_id}") assert "True" in capsys.readouterr().out data = cli_json("snapshot dump") @@ -650,7 +628,7 @@ def test_rollback_snapshot_with_short_id( short_id = data["snapshots"][0]["id"][:8] cli(f"snapshot rollback {short_id}") - cli("event parameter get completed") + cli(f"event parameter get completed --event-id {event_id}") assert "False" in capsys.readouterr().out diff --git a/tests/functional/test_cli_parameters.py b/tests/functional/test_cli_parameters.py index 44cb8de2..6e576595 100644 --- a/tests/functional/test_cli_parameters.py +++ b/tests/functional/test_cli_parameters.py @@ -1,7 +1,7 @@ """Functional tests for CLI commands that read and write event and seismogram parameters. -All commands are invoked in-process via ``app()`` with ``aimbat.db.engine`` -monkeypatched to the test fixture's in-memory database. The ``dump`` +All commands are invoked in-process via `app()` with `aimbat.db.engine` +monkeypatched to the test fixture's in-memory database. The `dump` sub-commands are used as the source of truth for verifying parameter changes. """ @@ -17,13 +17,14 @@ @pytest.mark.cli class TestEventParameterGet: - """Tests for ``event parameter get``.""" + """Tests for `event parameter get`.""" def test_get_bool_parameter( self, loaded_engine: Engine, cli: Callable[[str], None], capsys: pytest.CaptureFixture[str], + event_id: str, ) -> None: """Verifies that getting a bool parameter prints its current value. @@ -32,7 +33,7 @@ def test_get_bool_parameter( cli: The in-process CLI callable. capsys: The pytest capsys fixture. """ - cli("event parameter get completed") + cli(f"event parameter get --event-id {event_id} completed") assert "False" in capsys.readouterr().out, "'completed' should default to False" def test_get_float_parameter( @@ -40,6 +41,7 @@ def test_get_float_parameter( loaded_engine: Engine, cli: Callable[[str], None], capsys: pytest.CaptureFixture[str], + event_id: str, ) -> None: """Verifies that getting a float parameter prints a numeric value. @@ -48,7 +50,7 @@ def test_get_float_parameter( cli: The in-process CLI callable. capsys: The pytest capsys fixture. """ - cli("event parameter get min_cc") + cli(f"event parameter get --event-id {event_id} min_cc") output = capsys.readouterr().out.strip() assert output, "Expected a non-empty output for min_cc" assert float(output) >= 0.0, "min_cc should be a non-negative float" @@ -58,6 +60,7 @@ def test_get_timedelta_parameter( loaded_engine: Engine, cli: Callable[[str], None], capsys: pytest.CaptureFixture[str], + event_id: str, ) -> None: """Verifies that getting a timedelta parameter prints a value ending in 's'. @@ -66,7 +69,7 @@ def test_get_timedelta_parameter( cli: The in-process CLI callable. capsys: The pytest capsys fixture. """ - cli("event parameter get window_pre") + cli(f"event parameter get --event-id {event_id} window_pre") output = capsys.readouterr().out.strip() assert output, "Expected a non-empty output for window_pre" assert float(output.rstrip("s")) <= 0.0, ( @@ -78,6 +81,7 @@ def test_get_bandpass_bool_parameter( loaded_engine: Engine, cli: Callable[[str], None], capsys: pytest.CaptureFixture[str], + event_id: str, ) -> None: """Verifies that getting bandpass_apply prints a bool value. @@ -86,7 +90,7 @@ def test_get_bandpass_bool_parameter( cli: The in-process CLI callable. capsys: The pytest capsys fixture. """ - cli("event parameter get bandpass_apply") + cli(f"event parameter get --event-id {event_id} bandpass_apply") output = capsys.readouterr().out.strip() assert output in ( "True", @@ -108,6 +112,7 @@ def test_set_completed_true( loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that setting completed=true is reflected in the dump. @@ -116,14 +121,14 @@ def test_set_completed_true( cli: The in-process CLI callable. cli_json: The in-process CLI JSON dump callable. """ - before = cli_json("event parameter dump") - assert isinstance(before, dict), "Dump should return a dict for default event" + all_params = cli_json("event parameter dump") + before = all_params[0] assert before["completed"] is False, "'completed' should default to False" - cli("event parameter set completed true") + cli(f"event parameter set --event-id {event_id} completed true") - after = cli_json("event parameter dump") - assert isinstance(after, dict), "Dump should return a dict for default event" + all_params = cli_json("event parameter dump") + after = all_params[0] assert after["completed"] is True, "'completed' should be True after being set" def test_set_completed_false( @@ -131,6 +136,7 @@ def test_set_completed_false( loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that setting completed=false is reflected in the dump. @@ -139,10 +145,10 @@ def test_set_completed_false( cli: The in-process CLI callable. cli_json: The in-process CLI JSON dump callable. """ - cli("event parameter set completed true") - cli("event parameter set completed false") - after = cli_json("event parameter dump") - assert isinstance(after, dict), "Dump should return a dict for default event" + cli(f"event parameter set --event-id {event_id} completed true") + cli(f"event parameter set --event-id {event_id} completed false") + all_params = cli_json("event parameter dump") + after = all_params[0] assert after["completed"] is False, ( "'completed' should be False after being set back" ) @@ -152,6 +158,7 @@ def test_set_bandpass_apply( loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that setting bandpass_apply is reflected in the dump. @@ -160,15 +167,17 @@ def test_set_bandpass_apply( cli: The in-process CLI callable. cli_json: The in-process CLI JSON dump callable. """ - before = cli_json("event parameter dump") - assert isinstance(before, dict), "Dump should return a dict for default event" - original = before["bandpassApply"] + all_params = cli_json("event parameter dump") + before = all_params[0] + original = before["bandpass_apply"] - cli(f"event parameter set bandpass_apply {not original}".lower()) + cli( + f"event parameter set bandpass_apply {not original} --event-id {event_id}".lower() + ) - after = cli_json("event parameter dump") - assert isinstance(after, dict), "Dump should return a dict for default event" - assert after["bandpassApply"] is not original, ( + all_params = cli_json("event parameter dump") + after = all_params[0] + assert after["bandpass_apply"] is not original, ( "'bandpassApply' should have toggled after set" ) @@ -182,6 +191,7 @@ def test_set_min_cc( loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that setting min_cc is reflected in the dump. @@ -190,10 +200,10 @@ def test_set_min_cc( cli: The in-process CLI callable. cli_json: The in-process CLI JSON dump callable. """ - cli("event parameter set min_cc 0.42") - after = cli_json("event parameter dump") - assert isinstance(after, dict), "Dump should return a dict for default event" - assert after["minCc"] == pytest.approx(0.42), ( + cli(f"event parameter set --event-id {event_id} min_cc 0.42") + all_params = cli_json("event parameter dump") + after = all_params[0] + assert after["min_cc"] == pytest.approx(0.42), ( "'minCc' should be 0.42 after being set" ) @@ -202,6 +212,7 @@ def test_set_bandpass_fmin( loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that setting bandpass_fmin is reflected in the dump. @@ -210,10 +221,10 @@ def test_set_bandpass_fmin( cli: The in-process CLI callable. cli_json: The in-process CLI JSON dump callable. """ - cli("event parameter set bandpass_fmin 0.1") - after = cli_json("event parameter dump") - assert isinstance(after, dict), "Dump should return a dict for default event" - assert after["bandpassFmin"] == pytest.approx(0.1), ( + cli(f"event parameter set --event-id {event_id} bandpass_fmin 0.1") + all_params = cli_json("event parameter dump") + after = all_params[0] + assert after["bandpass_fmin"] == pytest.approx(0.1), ( "'bandpassFmin' should be 0.1 after being set" ) @@ -222,6 +233,7 @@ def test_set_bandpass_fmax( loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that setting bandpass_fmax is reflected in the dump. @@ -230,10 +242,10 @@ def test_set_bandpass_fmax( cli: The in-process CLI callable. cli_json: The in-process CLI JSON dump callable. """ - cli("event parameter set bandpass_fmax 2.0") - after = cli_json("event parameter dump") - assert isinstance(after, dict), "Dump should return a dict for default event" - assert after["bandpassFmax"] == pytest.approx(2.0), ( + cli(f"event parameter set --event-id {event_id} bandpass_fmax 2.0") + all_params = cli_json("event parameter dump") + after = all_params[0] + assert after["bandpass_fmax"] == pytest.approx(2.0), ( "'bandpassFmax' should be 2.0 after being set" ) @@ -252,6 +264,7 @@ def test_set_window_pre_as_bare_number( loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that a bare number is interpreted as seconds for window_pre. @@ -260,10 +273,10 @@ def test_set_window_pre_as_bare_number( cli: The in-process CLI callable. cli_json: The in-process CLI JSON dump callable. """ - cli("event parameter set window_pre -20") - after = cli_json("event parameter dump") - assert isinstance(after, dict), "Dump should return a dict for default event" - assert after["windowPre"] == pytest.approx(-20.0), ( + cli(f"event parameter set --event-id {event_id} window_pre -20") + all_params = cli_json("event parameter dump") + after = all_params[0] + assert after["window_pre"] == pytest.approx(-20.0), ( "'windowPre' should be -20.0 seconds after being set with a bare number" ) @@ -272,6 +285,7 @@ def test_set_window_post_as_bare_number( loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that a bare number is interpreted as seconds for window_post. @@ -280,10 +294,10 @@ def test_set_window_post_as_bare_number( cli: The in-process CLI callable. cli_json: The in-process CLI JSON dump callable. """ - cli("event parameter set window_post 30") - after = cli_json("event parameter dump") - assert isinstance(after, dict), "Dump should return a dict for default event" - assert after["windowPost"] == pytest.approx(30.0), ( + cli(f"event parameter set --event-id {event_id} window_post 30") + all_params = cli_json("event parameter dump") + after = all_params[0] + assert after["window_post"] == pytest.approx(30.0), ( "'windowPost' should be 30.0 seconds after being set with a bare number" ) @@ -292,6 +306,7 @@ def test_set_window_pre_with_unit_string( loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that a pandas-style unit string (e.g. '10s') is accepted for window_post. @@ -300,10 +315,10 @@ def test_set_window_pre_with_unit_string( cli: The in-process CLI callable. cli_json: The in-process CLI JSON dump callable. """ - cli("event parameter set window_post 20s") - after = cli_json("event parameter dump") - assert isinstance(after, dict), "Dump should return a dict for default event" - assert after["windowPost"] == pytest.approx(20.0), ( + cli(f"event parameter set --event-id {event_id} window_post 20s") + all_params = cli_json("event parameter dump") + after = all_params[0] + assert after["window_post"] == pytest.approx(20.0), ( "'windowPost' should be 20.0 seconds after being set with '20s'" ) @@ -315,12 +330,13 @@ def test_set_window_pre_with_unit_string( @pytest.mark.cli class TestEventParameterDump: - """Tests for ``event parameter dump``.""" + """Tests for `event parameter dump`.""" def test_default_event_returns_dict( self, loaded_engine: Engine, cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that the default-event dump returns a dict. @@ -329,12 +345,14 @@ def test_default_event_returns_dict( cli_json: The in-process CLI JSON dump callable. """ data = cli_json("event parameter dump") - assert isinstance(data, dict), "Default-event dump should be a dict" + assert isinstance(data, list), "Dump should return a list" + assert len(data) > 0, "Expected at least one event parameter" def test_default_event_contains_all_parameter_keys( self, loaded_engine: Engine, cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that all expected parameter keys are present in the dump. @@ -342,16 +360,16 @@ def test_default_event_contains_all_parameter_keys( loaded_engine: The monkeypatched engine with data loaded. cli_json: The in-process CLI JSON dump callable. """ - data = cli_json("event parameter dump") - assert isinstance(data, dict), "Default-event dump should be a dict" + all_data = cli_json("event parameter dump") + data = all_data[0] for key in ( "completed", - "minCc", - "windowPre", - "windowPost", - "bandpassApply", - "bandpassFmin", - "bandpassFmax", + "min_cc", + "window_pre", + "window_post", + "bandpass_apply", + "bandpass_fmin", + "bandpass_fmax", ): assert key in data, f"Expected key '{key}' in event parameter dump" @@ -359,14 +377,15 @@ def test_all_events_returns_list( self, loaded_engine: Engine, cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: - """Verifies that ``--all`` returns a list of parameter dicts. + """Verifies that `--all` returns a list of parameter dicts. Args: loaded_engine: The monkeypatched engine with data loaded. cli_json: The in-process CLI JSON dump callable. """ - data = cli_json("event parameter dump --all") + data = cli_json("event parameter dump") assert isinstance(data, list), "All-events dump should be a list" assert len(data) > 1, "Expected parameters for more than one event" @@ -374,6 +393,7 @@ def test_all_events_entries_contain_parameter_keys( self, loaded_engine: Engine, cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that each entry in the all-events dump has the expected keys. @@ -381,17 +401,18 @@ def test_all_events_entries_contain_parameter_keys( loaded_engine: The monkeypatched engine with data loaded. cli_json: The in-process CLI JSON dump callable. """ - data = cli_json("event parameter dump --all") + data = cli_json("event parameter dump") assert isinstance(data, list), "All-events dump should be a list" for entry in data: assert "completed" in entry, "Each entry should have 'completed' key" - assert "minCc" in entry, "Each entry should have 'minCc' key" + assert "min_cc" in entry, "Each entry should have 'minCc' key" def test_set_visible_in_all_events_dump( self, loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that a parameter change to the default event appears in the all-events dump. @@ -400,8 +421,8 @@ def test_set_visible_in_all_events_dump( cli: The in-process CLI callable. cli_json: The in-process CLI JSON dump callable. """ - cli("event parameter set completed true") - all_data = cli_json("event parameter dump --all") + cli(f"event parameter set --event-id {event_id} completed true") + all_data = cli_json("event parameter dump") assert isinstance(all_data, list), "All-events dump should be a list" default_entries = [e for e in all_data if e.get("completed") is True] assert len(default_entries) == 1, ( @@ -416,13 +437,14 @@ def test_set_visible_in_all_events_dump( @pytest.mark.cli class TestEventParameterList: - """Tests for ``event parameter list``.""" + """Tests for `event parameter list`.""" def test_list_produces_output( self, loaded_engine: Engine, cli: Callable[[str], None], capsys: pytest.CaptureFixture[str], + event_id: str, ) -> None: """Verifies that the list command produces output. @@ -431,27 +453,28 @@ def test_list_produces_output( cli: The in-process CLI callable. capsys: The pytest capsys fixture. """ - cli("event parameter list") + cli(f"event parameter list --event-id {event_id}") assert len(capsys.readouterr().out) > 0, ( "Expected output from event parameter list" ) - def test_list_short_produces_output( + def test_list_raw_produces_output( self, loaded_engine: Engine, cli: Callable[[str], None], capsys: pytest.CaptureFixture[str], + event_id: str, ) -> None: - """Verifies that ``--short`` produces output. + """Verifies that `--raw` produces output. Args: loaded_engine: The monkeypatched engine with data loaded. cli: The in-process CLI callable. capsys: The pytest capsys fixture. """ - cli("event parameter list --short") + cli(f"event parameter list --raw --event-id {event_id}") assert len(capsys.readouterr().out) > 0, ( - "Expected output from event parameter list --short" + "Expected output from event parameter list --raw" ) def test_list_all_events_produces_output( @@ -459,15 +482,16 @@ def test_list_all_events_produces_output( loaded_engine: Engine, cli: Callable[[str], None], capsys: pytest.CaptureFixture[str], + event_id: str, ) -> None: - """Verifies that ``--all`` produces output covering all events. + """Verifies that `--all` produces output covering all events. Args: loaded_engine: The monkeypatched engine with data loaded. cli: The in-process CLI callable. capsys: The pytest capsys fixture. """ - cli("event parameter list --all") + cli("event parameter list --event-id all") assert len(capsys.readouterr().out) > 0, ( "Expected output from event parameter list --all" ) @@ -480,7 +504,7 @@ def test_list_all_events_produces_output( @pytest.mark.cli class TestSeismogramParameterGet: - """Tests for ``seismogram parameter get``.""" + """Tests for `seismogram parameter get`.""" def test_get_select_with_full_id( self, @@ -488,6 +512,7 @@ def test_get_select_with_full_id( cli: Callable[[str], None], cli_json: Callable[[str], list | dict], capsys: pytest.CaptureFixture[str], + event_id: str, ) -> None: """Verifies that 'select' can be retrieved using the full seismogram ID. @@ -503,7 +528,7 @@ def test_get_select_with_full_id( ) target_id = seis[0]["id"] - cli(f"seismogram parameter get {target_id} select") + cli(f"seismogram parameter get select --id {target_id}") output = capsys.readouterr().out.strip() assert output in ( "True", @@ -516,6 +541,7 @@ def test_get_select_with_short_id( cli: Callable[[str], None], cli_json: Callable[[str], list | dict], capsys: pytest.CaptureFixture[str], + event_id: str, ) -> None: """Verifies that 'select' can be retrieved using a shortened seismogram ID. @@ -531,7 +557,7 @@ def test_get_select_with_short_id( ) short_id = seis[0]["id"][:8] - cli(f"seismogram parameter get {short_id} select") + cli(f"seismogram parameter get select --id {short_id}") output = capsys.readouterr().out.strip() assert output in ( "True", @@ -544,6 +570,7 @@ def test_get_flip_default_is_false( cli: Callable[[str], None], cli_json: Callable[[str], list | dict], capsys: pytest.CaptureFixture[str], + event_id: str, ) -> None: """Verifies that 'flip' defaults to False. @@ -556,7 +583,7 @@ def test_get_flip_default_is_false( seis = cli_json("seismogram dump") target_id = seis[0]["id"] - cli(f"seismogram parameter get {target_id} flip") + cli(f"seismogram parameter get flip --id {target_id}") assert "False" in capsys.readouterr().out, "'flip' should default to False" def test_get_select_default_is_true( @@ -565,6 +592,7 @@ def test_get_select_default_is_true( cli: Callable[[str], None], cli_json: Callable[[str], list | dict], capsys: pytest.CaptureFixture[str], + event_id: str, ) -> None: """Verifies that 'select' defaults to True. @@ -577,7 +605,7 @@ def test_get_select_default_is_true( seis = cli_json("seismogram dump") target_id = seis[0]["id"] - cli(f"seismogram parameter get {target_id} select") + cli(f"seismogram parameter get select --id {target_id}") assert "True" in capsys.readouterr().out, "'select' should default to True" @@ -595,6 +623,7 @@ def test_set_select_false_with_full_id( loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that setting select=false is reflected in the dump. @@ -609,7 +638,7 @@ def test_set_select_false_with_full_id( ) target_id = seis[0]["id"] - cli(f"seismogram parameter set {target_id} select false") + cli(f"seismogram parameter set select false --id {target_id}") params = cli_json("seismogram parameter dump") assert isinstance(params, list), "Seismogram parameter dump should be a list" @@ -623,6 +652,7 @@ def test_set_select_false_with_short_id( loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that setting select=false via a shortened ID is reflected in the dump. @@ -635,7 +665,7 @@ def test_set_select_false_with_short_id( target_id = seis[0]["id"] short_id = target_id[:8] - cli(f"seismogram parameter set {short_id} select false") + cli(f"seismogram parameter set select false --id {short_id}") params = cli_json("seismogram parameter dump") assert isinstance(params, list), "Seismogram parameter dump should be a list" @@ -649,6 +679,7 @@ def test_set_flip_true_with_full_id( loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that setting flip=true is reflected in the dump. @@ -660,7 +691,7 @@ def test_set_flip_true_with_full_id( seis = cli_json("seismogram dump") target_id = seis[0]["id"] - cli(f"seismogram parameter set {target_id} flip true") + cli(f"seismogram parameter set flip true --id {target_id}") params = cli_json("seismogram parameter dump") assert isinstance(params, list), "Seismogram parameter dump should be a list" @@ -674,6 +705,7 @@ def test_set_flip_true_with_short_id( loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that setting flip=true via a shortened ID is reflected in the dump. @@ -686,7 +718,7 @@ def test_set_flip_true_with_short_id( target_id = seis[0]["id"] short_id = target_id[:8] - cli(f"seismogram parameter set {short_id} flip true") + cli(f"seismogram parameter set flip true --id {short_id}") params = cli_json("seismogram parameter dump") assert isinstance(params, list), "Seismogram parameter dump should be a list" @@ -700,6 +732,7 @@ def test_set_does_not_affect_other_seismograms( loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that changing one seismogram's parameter does not affect others. @@ -719,7 +752,7 @@ def test_set_does_not_affect_other_seismograms( other_id = params_before[1]["seismogram_id"] other_select_before = params_before[1]["select"] - cli(f"seismogram parameter set {target_id} select false") + cli(f"seismogram parameter set select false --id {target_id}") params_after = cli_json("seismogram parameter dump") assert isinstance(params_after, list), ( @@ -740,12 +773,13 @@ def test_set_does_not_affect_other_seismograms( @pytest.mark.cli class TestSeismogramParameterDump: - """Tests for ``seismogram parameter dump``.""" + """Tests for `seismogram parameter dump`.""" def test_returns_list( self, loaded_engine: Engine, cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that the dump returns a list of parameter dicts. @@ -761,6 +795,7 @@ def test_entries_contain_expected_keys( self, loaded_engine: Engine, cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that each entry contains the expected parameter keys. @@ -780,6 +815,7 @@ def test_count_matches_seismogram_dump( self, loaded_engine: Engine, cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that the parameter dump entry count matches the seismogram count. @@ -803,13 +839,14 @@ def test_count_matches_seismogram_dump( @pytest.mark.cli class TestSeismogramParameterList: - """Tests for ``seismogram parameter list``.""" + """Tests for `seismogram parameter list`.""" def test_list_produces_output( self, loaded_engine: Engine, cli: Callable[[str], None], capsys: pytest.CaptureFixture[str], + event_id: str, ) -> None: """Verifies that the list command produces output. @@ -818,25 +855,26 @@ def test_list_produces_output( cli: The in-process CLI callable. capsys: The pytest capsys fixture. """ - cli("seismogram parameter list") + cli(f"seismogram parameter list --event-id {event_id}") assert len(capsys.readouterr().out) > 0, ( "Expected output from seismogram parameter list" ) - def test_list_short_produces_output( + def test_list_raw_produces_output( self, loaded_engine: Engine, cli: Callable[[str], None], capsys: pytest.CaptureFixture[str], + event_id: str, ) -> None: - """Verifies that ``--short`` produces output. + """Verifies that `--raw` produces output. Args: loaded_engine: The monkeypatched engine with data loaded. cli: The in-process CLI callable. capsys: The pytest capsys fixture. """ - cli("seismogram parameter list --short") + cli(f"seismogram parameter list --raw --event-id {event_id}") assert len(capsys.readouterr().out) > 0, ( - "Expected output from seismogram parameter list --short" + "Expected output from seismogram parameter list --raw" ) diff --git a/tests/functional/test_cli_snapshots.py b/tests/functional/test_cli_snapshots.py index 544f2034..0f7afa26 100644 --- a/tests/functional/test_cli_snapshots.py +++ b/tests/functional/test_cli_snapshots.py @@ -1,7 +1,7 @@ """Functional tests for the AIMBAT snapshot CLI commands. -All commands are invoked in-process via ``app()`` with ``aimbat.db.engine`` -monkeypatched to the test fixture's in-memory database. The ``snapshot dump`` +All commands are invoked in-process via `app()` with `aimbat.db.engine` +monkeypatched to the test fixture's in-memory database. The `snapshot dump` JSON output is used as the ground truth for ID verification after mutations. """ @@ -22,13 +22,14 @@ @pytest.mark.cli class TestSnapshotCreate: - """Tests for the ``snapshot create`` CLI command.""" + """Tests for the `snapshot create` CLI command.""" def test_create_without_comment( self, loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that a snapshot is created with a null comment by default. @@ -37,7 +38,7 @@ def test_create_without_comment( cli: The in-process CLI callable. cli_json: The in-process CLI JSON dump callable. """ - cli("snapshot create") + cli(f"snapshot create --event-id {event_id}") data = cli_json("snapshot dump") assert isinstance(data, dict), "Dump should return a dict" assert len(data["snapshots"]) == 1, "Expected exactly one snapshot" @@ -50,6 +51,7 @@ def test_create_with_comment( loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that the comment is stored when provided. @@ -58,7 +60,7 @@ def test_create_with_comment( cli: The in-process CLI callable. cli_json: The in-process CLI JSON dump callable. """ - cli("snapshot create my-comment") + cli(f"snapshot create --event-id {event_id} --comment my-comment") data = cli_json("snapshot dump") assert isinstance(data, dict), "Dump should return a dict" assert data["snapshots"][0]["comment"] == "my-comment", ( @@ -70,6 +72,7 @@ def test_create_captures_event_parameters( loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that one event parameter snapshot is created per snapshot. @@ -78,7 +81,7 @@ def test_create_captures_event_parameters( cli: The in-process CLI callable. cli_json: The in-process CLI JSON dump callable. """ - cli("snapshot create") + cli(f"snapshot create --event-id {event_id}") data = cli_json("snapshot dump") assert isinstance(data, dict), "Dump should return a dict" assert len(data["event_parameters"]) == 1, ( @@ -90,6 +93,7 @@ def test_create_captures_seismogram_parameters( loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that seismogram parameter snapshots are created. @@ -98,7 +102,7 @@ def test_create_captures_seismogram_parameters( cli: The in-process CLI callable. cli_json: The in-process CLI JSON dump callable. """ - cli("snapshot create") + cli(f"snapshot create --event-id {event_id}") data = cli_json("snapshot dump") assert isinstance(data, dict), "Dump should return a dict" assert len(data["seismogram_parameters"]) > 0, ( @@ -110,6 +114,7 @@ def test_create_multiple_snapshots( loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that multiple snapshots accumulate correctly. @@ -118,8 +123,8 @@ def test_create_multiple_snapshots( cli: The in-process CLI callable. cli_json: The in-process CLI JSON dump callable. """ - cli("snapshot create first") - cli("snapshot create second") + cli(f"snapshot create --event-id {event_id} --comment first") + cli(f"snapshot create --event-id {event_id} --comment second") data = cli_json("snapshot dump") assert isinstance(data, dict), "Dump should return a dict" assert len(data["snapshots"]) == 2, "Expected two snapshots" @@ -140,9 +145,9 @@ def test_create_multiple_snapshots( @pytest.mark.cli class TestSnapshotDelete: - """Tests for the ``snapshot delete`` CLI command. + """Tests for the `snapshot delete` CLI command. - Uses IDs obtained from ``snapshot dump`` to verify complete removal of + Uses IDs obtained from `snapshot dump` to verify complete removal of the snapshot and all related child records. """ @@ -151,6 +156,7 @@ def test_delete_removes_snapshot( loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that the snapshot ID is absent from the dump after deletion. @@ -159,7 +165,7 @@ def test_delete_removes_snapshot( cli: The in-process CLI callable. cli_json: The in-process CLI JSON dump callable. """ - cli("snapshot create") + cli(f"snapshot create --event-id {event_id}") data_before = cli_json("snapshot dump") assert isinstance(data_before, dict), "Dump should return a dict" snapshot_id = data_before["snapshots"][0]["id"] @@ -178,6 +184,7 @@ def test_delete_removes_event_parameter_snapshot( loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that the related event parameter snapshot is removed after deletion. @@ -186,7 +193,7 @@ def test_delete_removes_event_parameter_snapshot( cli: The in-process CLI callable. cli_json: The in-process CLI JSON dump callable. """ - cli("snapshot create") + cli(f"snapshot create --event-id {event_id}") data_before = cli_json("snapshot dump") assert isinstance(data_before, dict), "Dump should return a dict" snapshot_id = data_before["snapshots"][0]["id"] @@ -206,6 +213,7 @@ def test_delete_removes_seismogram_parameter_snapshots( loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that all related seismogram parameter snapshots are removed after deletion. @@ -214,7 +222,7 @@ def test_delete_removes_seismogram_parameter_snapshots( cli: The in-process CLI callable. cli_json: The in-process CLI JSON dump callable. """ - cli("snapshot create") + cli(f"snapshot create --event-id {event_id}") data_before = cli_json("snapshot dump") assert isinstance(data_before, dict), "Dump should return a dict" snapshot_id = data_before["snapshots"][0]["id"] @@ -239,6 +247,7 @@ def test_delete_one_of_two_snapshots_leaves_other_intact( loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that deleting one snapshot does not affect the other. @@ -247,8 +256,8 @@ def test_delete_one_of_two_snapshots_leaves_other_intact( cli: The in-process CLI callable. cli_json: The in-process CLI JSON dump callable. """ - cli("snapshot create first") - cli("snapshot create second") + cli(f"snapshot create --event-id {event_id} --comment first") + cli(f"snapshot create --event-id {event_id} --comment second") data_before = cli_json("snapshot dump") assert isinstance(data_before, dict), "Dump should return a dict" first_id = next( @@ -275,6 +284,7 @@ def test_delete_snapshot_with_short_id_removes_all_related( loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies deletion via short ID removes the snapshot and all related records. @@ -283,7 +293,7 @@ def test_delete_snapshot_with_short_id_removes_all_related( cli: The in-process CLI callable. cli_json: The in-process CLI JSON dump callable. """ - cli("snapshot create") + cli(f"snapshot create --event-id {event_id}") data_before = cli_json("snapshot dump") assert isinstance(data_before, dict), "Dump should return a dict" snapshot_id = data_before["snapshots"][0]["id"] @@ -315,6 +325,7 @@ def test_delete_non_existent_id_fails( loaded_engine: Engine, cli: Callable[[str], None], capsys: pytest.CaptureFixture[str], + event_id: str, monkeypatch: pytest.MonkeyPatch, ) -> None: """Verifies that deleting a non-existent snapshot ID fails gracefully. @@ -344,7 +355,7 @@ def test_delete_non_existent_id_fails( @pytest.mark.cli class TestSnapshotRollback: - """Tests for the ``snapshot rollback`` CLI command.""" + """Tests for the `snapshot rollback` CLI command.""" def test_rollback_restores_event_parameter( self, @@ -352,6 +363,7 @@ def test_rollback_restores_event_parameter( cli: Callable[[str], None], cli_json: Callable[[str], list | dict], capsys: pytest.CaptureFixture[str], + event_id: str, ) -> None: """Verifies that rollback restores a previously changed event parameter. @@ -361,10 +373,10 @@ def test_rollback_restores_event_parameter( cli_json: The in-process CLI JSON dump callable. capsys: The pytest capsys fixture. """ - cli("snapshot create before-change") + cli(f"snapshot create --event-id {event_id} --comment before-change") - cli("event parameter set completed true") - cli("event parameter get completed") + cli(f"event parameter set completed true --event-id {event_id}") + cli(f"event parameter get completed --event-id {event_id}") assert "True" in capsys.readouterr().out, ( "Parameter should read True after being set" ) @@ -375,7 +387,7 @@ def test_rollback_restores_event_parameter( cli(f"snapshot rollback {snapshot_id}") - cli("event parameter get completed") + cli(f"event parameter get completed --event-id {event_id}") assert "False" in capsys.readouterr().out, ( "Parameter should be restored to False after rollback" ) @@ -386,6 +398,7 @@ def test_rollback_restores_seismogram_parameter( cli: Callable[[str], None], cli_json: Callable[[str], list | dict], capsys: pytest.CaptureFixture[str], + event_id: str, ) -> None: """Verifies that rollback restores a previously changed seismogram parameter. @@ -401,11 +414,11 @@ def test_rollback_restores_seismogram_parameter( assert seis is not None seis_id = seis.id - cli("snapshot create before-seis-change") + cli(f"snapshot create --event-id {event_id} --comment before-seis-change") # Flip the seismogram - cli(f"seismogram parameter set {seis_id} flip true") - cli(f"seismogram parameter get {seis_id} flip") + cli(f"seismogram parameter set flip true --id {seis_id}") + cli(f"seismogram parameter get flip --id {seis_id}") assert "True" in capsys.readouterr().out, "Seismogram should be flipped" data = cli_json("snapshot dump") @@ -414,7 +427,7 @@ def test_rollback_restores_seismogram_parameter( cli(f"snapshot rollback {snapshot_id}") - cli(f"seismogram parameter get {seis_id} flip") + cli(f"seismogram parameter get flip --id {seis_id}") assert "False" in capsys.readouterr().out, ( "Seismogram flip should be restored to False after rollback" ) @@ -425,6 +438,7 @@ def test_rollback_restores_event_parameter_with_short_id( cli: Callable[[str], None], cli_json: Callable[[str], list | dict], capsys: pytest.CaptureFixture[str], + event_id: str, ) -> None: """Verifies that rollback restores a parameter when given a shortened snapshot ID. @@ -434,10 +448,10 @@ def test_rollback_restores_event_parameter_with_short_id( cli_json: The in-process CLI JSON dump callable. capsys: The pytest capsys fixture. """ - cli("snapshot create before-change") + cli(f"snapshot create --event-id {event_id} --comment before-change") - cli("event parameter set completed true") - cli("event parameter get completed") + cli(f"event parameter set completed true --event-id {event_id}") + cli(f"event parameter get completed --event-id {event_id}") assert "True" in capsys.readouterr().out, ( "Parameter should read True after being set" ) @@ -448,7 +462,7 @@ def test_rollback_restores_event_parameter_with_short_id( cli(f"snapshot rollback {short_id}") - cli("event parameter get completed") + cli(f"event parameter get completed --event-id {event_id}") assert "False" in capsys.readouterr().out, ( "Parameter should be restored to False after rollback via short ID" ) @@ -458,6 +472,7 @@ def test_rollback_does_not_delete_snapshot( loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that rolling back leaves the snapshot itself in place. @@ -466,7 +481,7 @@ def test_rollback_does_not_delete_snapshot( cli: The in-process CLI callable. cli_json: The in-process CLI JSON dump callable. """ - cli("snapshot create") + cli(f"snapshot create --event-id {event_id}") data_before = cli_json("snapshot dump") assert isinstance(data_before, dict), "Dump should return a dict" snapshot_id = data_before["snapshots"][0]["id"] @@ -488,12 +503,13 @@ def test_rollback_does_not_delete_snapshot( @pytest.mark.cli class TestSnapshotDump: - """Tests for the ``snapshot dump`` CLI command.""" + """Tests for the `snapshot dump` CLI command.""" def test_dump_empty_returns_empty_lists( self, loaded_engine: Engine, cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that the dump is empty when no snapshots have been created. @@ -514,6 +530,7 @@ def test_dump_contains_expected_keys( loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that the dump dict contains the three expected top-level keys. @@ -522,7 +539,7 @@ def test_dump_contains_expected_keys( cli: The in-process CLI callable. cli_json: The in-process CLI JSON dump callable. """ - cli("snapshot create") + cli(f"snapshot create --event-id {event_id}") data = cli_json("snapshot dump") assert isinstance(data, dict), "Dump should return a dict" assert "snapshots" in data, "Dump should contain 'snapshots' key" @@ -536,6 +553,7 @@ def test_dump_all_events_includes_default( loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that dump returns snapshots for all events by default. @@ -544,7 +562,7 @@ def test_dump_all_events_includes_default( cli: The in-process CLI callable. cli_json: The in-process CLI JSON dump callable. """ - cli("snapshot create") + cli(f"snapshot create --event-id {event_id}") data = cli_json("snapshot dump") assert isinstance(data, dict), "Dump should return a dict" assert len(data["snapshots"]) >= 1, ( @@ -556,6 +574,7 @@ def test_dump_snapshot_ids_are_consistent( loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that snapshot IDs referenced in event/seismogram params match the snapshots list. @@ -564,7 +583,7 @@ def test_dump_snapshot_ids_are_consistent( cli: The in-process CLI callable. cli_json: The in-process CLI JSON dump callable. """ - cli("snapshot create") + cli(f"snapshot create --event-id {event_id}") data = cli_json("snapshot dump") assert isinstance(data, dict), "Dump should return a dict" snapshot_ids = {s["id"] for s in data["snapshots"]} @@ -585,13 +604,14 @@ def test_dump_snapshot_ids_are_consistent( @pytest.mark.cli class TestSnapshotList: - """Tests for the ``snapshot list`` CLI command.""" + """Tests for the `snapshot list` CLI command.""" def test_list_default_event( self, loaded_engine: Engine, cli: Callable[[str], None], capsys: pytest.CaptureFixture[str], + event_id: str, ) -> None: """Verifies that the list command produces output for the default event. @@ -600,8 +620,8 @@ def test_list_default_event( cli: The in-process CLI callable. capsys: The pytest capsys fixture. """ - cli("snapshot create test-comment") - cli("snapshot list") + cli(f"snapshot create --event-id {event_id} --comment test-comment") + cli(f"snapshot list --event-id {event_id}") output = capsys.readouterr().out assert "AIMBAT snapshots for event" in output assert "test-comment" in output @@ -611,35 +631,37 @@ def test_list_all_events( loaded_engine: Engine, cli: Callable[[str], None], capsys: pytest.CaptureFixture[str], + event_id: str, ) -> None: - """Verifies that `--all` produces output for all events. + """Verifies that `--event-id all` produces output for all events. Args: loaded_engine: The monkeypatched engine with data loaded. cli: The in-process CLI callable. capsys: The pytest capsys fixture. """ - cli("snapshot create test-comment-all") - cli("snapshot list --all") + cli(f"snapshot create --event-id {event_id} --comment test-comment-all") + cli("snapshot list --event-id all") output = capsys.readouterr().out assert "AIMBAT snapshots for all events" in output assert "test-comment-all" in output - def test_list_short( + def test_list_raw( self, loaded_engine: Engine, cli: Callable[[str], None], capsys: pytest.CaptureFixture[str], + event_id: str, ) -> None: - """Verifies that `--short` produces output. + """Verifies that `--raw` produces output. Args: loaded_engine: The monkeypatched engine with data loaded. cli: The in-process CLI callable. capsys: The pytest capsys fixture. """ - cli("snapshot create") - cli("snapshot list --short") + cli(f"snapshot create --event-id {event_id}") + cli(f"snapshot list --raw --event-id {event_id}") output = capsys.readouterr().out assert "ID" in output @@ -651,7 +673,7 @@ def test_list_short( @pytest.mark.cli class TestSnapshotDetails: - """Tests for the ``snapshot details`` CLI command.""" + """Tests for the `snapshot details` CLI command.""" def test_details_produces_output( self, @@ -659,6 +681,7 @@ def test_details_produces_output( cli: Callable[[str], None], cli_json: Callable[[str], list | dict], capsys: pytest.CaptureFixture[str], + event_id: str, ) -> None: """Verifies that the details command produces output for a valid snapshot ID. @@ -668,7 +691,7 @@ def test_details_produces_output( cli_json: The in-process CLI JSON dump callable. capsys: The pytest capsys fixture. """ - cli("snapshot create") + cli(f"snapshot create --event-id {event_id}") data = cli_json("snapshot dump") assert isinstance(data, dict), "Dump should return a dict" snapshot_id = data["snapshots"][0]["id"] @@ -676,7 +699,7 @@ def test_details_produces_output( cli(f"snapshot details {snapshot_id}") output = capsys.readouterr().out assert "Saved event parameters" in output - assert "window_pre" in output + assert "Window pre" in output def test_details_produces_output_with_short_id( self, @@ -684,6 +707,7 @@ def test_details_produces_output_with_short_id( cli: Callable[[str], None], cli_json: Callable[[str], list | dict], capsys: pytest.CaptureFixture[str], + event_id: str, ) -> None: """Verifies that the details command works with a shortened snapshot ID. @@ -693,7 +717,7 @@ def test_details_produces_output_with_short_id( cli_json: The in-process CLI JSON dump callable. capsys: The pytest capsys fixture. """ - cli("snapshot create") + cli(f"snapshot create --event-id {event_id}") data = cli_json("snapshot dump") assert isinstance(data, dict), "Dump should return a dict" short_id = data["snapshots"][0]["id"][:8] @@ -702,14 +726,15 @@ def test_details_produces_output_with_short_id( output = capsys.readouterr().out assert "Saved event parameters" in output - def test_details_short_flag( + def test_details_raw_flag( self, loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], capsys: pytest.CaptureFixture[str], + event_id: str, ) -> None: - """Verifies that ``--short`` produces output. + """Verifies that `--raw` produces output. Args: loaded_engine: The monkeypatched engine with data loaded. @@ -717,12 +742,12 @@ def test_details_short_flag( cli_json: The in-process CLI JSON dump callable. capsys: The pytest capsys fixture. """ - cli("snapshot create") + cli(f"snapshot create --event-id {event_id}") data = cli_json("snapshot dump") assert isinstance(data, dict), "Dump should return a dict" snapshot_id = data["snapshots"][0]["id"] - cli(f"snapshot details {snapshot_id} --short") + cli(f"snapshot details {snapshot_id} --raw") assert "Saved event parameters" in capsys.readouterr().out @@ -733,7 +758,7 @@ def test_details_short_flag( @pytest.mark.cli class TestSnapshotPreview: - """Tests for the ``snapshot preview`` CLI command.""" + """Tests for the `snapshot preview` CLI command.""" @patch("aimbat.plot.plot_stack") def test_preview_stack_is_called( @@ -742,6 +767,7 @@ def test_preview_stack_is_called( loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that plot_stack is called when previewing without --matrix. @@ -751,7 +777,7 @@ def test_preview_stack_is_called( cli: The in-process CLI callable. cli_json: The in-process CLI JSON dump callable. """ - cli("snapshot create") + cli(f"snapshot create --event-id {event_id}") data = cli_json("snapshot dump") assert isinstance(data, dict), "Dump should return a dict" snapshot_id = data["snapshots"][0]["id"] @@ -766,6 +792,7 @@ def test_preview_matrix_is_called( loaded_engine: Engine, cli: Callable[[str], None], cli_json: Callable[[str], list | dict], + event_id: str, ) -> None: """Verifies that plot_matrix_image is called when previewing with --matrix. @@ -775,7 +802,7 @@ def test_preview_matrix_is_called( cli: The in-process CLI callable. cli_json: The in-process CLI JSON dump callable. """ - cli("snapshot create") + cli(f"snapshot create --event-id {event_id}") data = cli_json("snapshot dump") assert isinstance(data, dict), "Dump should return a dict" snapshot_id = data["snapshots"][0]["id"] diff --git a/tests/functional/test_shell.py b/tests/functional/test_shell.py index 832f6be6..22b8b35c 100644 --- a/tests/functional/test_shell.py +++ b/tests/functional/test_shell.py @@ -6,13 +6,12 @@ import os import subprocess -import uuid from collections.abc import Callable, Sequence from pathlib import Path import pytest -from aimbat._cli.shell import _build_completion_dict, _extract_event_flag, _inject_event +from aimbat._cli.shell import _build_completion_dict, _extract_event_flag from aimbat.app import app as aimbat_app _AIMBAT_LOGFILE = "aimbat_test.log" @@ -89,43 +88,6 @@ def test_other_flags_ignored(self) -> None: assert _extract_event_flag(["--all", "--json", "--verbose"]) is None -# =========================================================================== -# _inject_event -# =========================================================================== - - -class TestInjectEvent: - """Tests for the ``_inject_event`` helper.""" - - _UID = uuid.UUID("12345678-1234-5678-1234-567812345678") - - def test_appends_event_flag_when_absent(self) -> None: - result = _inject_event(["event", "list"], self._UID) - assert "--event" in result - assert str(self._UID) in result - - def test_no_change_when_event_already_present(self) -> None: - tokens = ["event", "dump", "--event", "other-id"] - result = _inject_event(tokens, self._UID) - assert result == tokens - - def test_no_change_when_event_id_already_present(self) -> None: - tokens = ["event", "dump", "--event-id", "other-id"] - result = _inject_event(tokens, self._UID) - assert result == tokens - - def test_original_tokens_not_mutated(self) -> None: - tokens = ["event", "list"] - original = tokens[:] - _inject_event(tokens, self._UID) - assert tokens == original - - def test_returns_new_list(self) -> None: - tokens = ["event", "list"] - result = _inject_event(tokens, self._UID) - assert result is not tokens - - # =========================================================================== # _build_completion_dict # =========================================================================== @@ -146,7 +108,7 @@ def test_event_subcommands_present(self) -> None: result = _build_completion_dict(aimbat_app) event_cmds = result.get("event") assert isinstance(event_cmds, dict) - for sub in ("list", "dump", "default", "delete"): + for sub in ("list", "dump", "delete"): assert sub in event_cmds, f"'event {sub}' missing from completion dict" def test_help_flags_excluded(self) -> None: diff --git a/tests/integration/core/test_data.py b/tests/integration/core/test_data.py index 2314449f..ac9b8ab4 100644 --- a/tests/integration/core/test_data.py +++ b/tests/integration/core/test_data.py @@ -17,7 +17,6 @@ add_data_to_project, dump_data_table, get_data_for_event, - get_default_event, ) from aimbat.io import DataType from aimbat.models import ( @@ -194,16 +193,14 @@ def test_dry_run_all_new( self, multi_event_data: list[Path], patched_session: Session, - capsys: pytest.CaptureFixture, ) -> None: """Verifies dry run behaviour when all data is new. Args: multi_event_data (list[Path]): List of paths to SAC files. patched_session (Session): Database session. - capsys (pytest.CaptureFixture): Fixture to capture stdout/stderr. """ - add_data_to_project( + result = add_data_to_project( patched_session, multi_event_data, data_type=DataType.SAC, @@ -213,58 +210,81 @@ def test_dry_run_all_new( datasource = patched_session.exec(select(AimbatDataSource.sourcename)).all() assert len(datasource) == 0, "Expected no data sources after dry run." - captured = capsys.readouterr() - assert "Dry Run: Data to be added" in captured.out + assert result is not None + ( + added_datasources, + existing_station_ids, + existing_event_ids, + existing_seismogram_ids, + ) = result n = len(multi_event_data) - assert f"{n} seismogram(s) added, 0 skipped" in captured.out - assert "0 skipped" in captured.out + assert len(added_datasources) == n + assert all( + ds.seismogram.station_id not in existing_station_ids + for ds in added_datasources + ) + assert all( + ds.seismogram.event_id not in existing_event_ids for ds in added_datasources + ) + assert all( + ds.seismogram_id not in existing_seismogram_ids for ds in added_datasources + ) def test_dry_run_all_skipped( self, multi_event_data: list[Path], patched_session: Session, - capsys: pytest.CaptureFixture, ) -> None: """Verifies dry run behaviour when all data already exists (should be skipped). Args: multi_event_data (list[Path]): List of paths to SAC files. patched_session (Session): Database session. - capsys (pytest.CaptureFixture): Fixture to capture stdout/stderr. """ add_data_to_project( patched_session, multi_event_data, data_type=DataType.SAC, ) - capsys.readouterr() - add_data_to_project( + result = add_data_to_project( patched_session, multi_event_data, data_type=DataType.SAC, dry_run=True, ) - captured = capsys.readouterr() - assert "Dry Run: Data to be added" in captured.out + assert result is not None + ( + added_datasources, + existing_station_ids, + existing_event_ids, + existing_seismogram_ids, + ) = result n = len(multi_event_data) - assert f"0 station(s) added, {n} skipped" in captured.out - assert f"0 event(s) added, {n} skipped" in captured.out - assert f"0 seismogram(s) added, {n} skipped" in captured.out + assert len(added_datasources) == n + assert all( + ds.seismogram.station_id in existing_station_ids for ds in added_datasources + ) + assert all( + ds.seismogram.event_id in existing_event_ids for ds in added_datasources + ) + assert all( + ds.seismogram_id in existing_seismogram_ids for ds in added_datasources + ) class TestGetDataSources: - def test_get_data_sources_for_default_event(self, loaded_session: Session) -> None: + def test_get_data_sources_for_event(self, loaded_session: Session) -> None: """Verifies that get_data_sources returns the expected data sources. Args: loaded_session (Session): Database session. """ - default_event = get_default_event(loaded_session) - assert default_event is not None - data_sources = get_data_for_event(loaded_session, default_event.id) - assert len(data_sources) != 0, "Expected data sources for the default event." + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + data_sources = get_data_for_event(loaded_session, event.id) + assert len(data_sources) != 0, "Expected data sources for the event." assert all(isinstance(ds, AimbatDataSource) for ds in data_sources), ( "expected all items to be AimbatDataSource instances" ) diff --git a/tests/integration/core/test_event.py b/tests/integration/core/test_event.py index ff34261d..ab9ca3a8 100644 --- a/tests/integration/core/test_event.py +++ b/tests/integration/core/test_event.py @@ -13,9 +13,7 @@ dump_event_parameter_table, dump_event_table, get_completed_events, - get_default_event, get_events_using_station, - set_default_event, set_event_parameter, ) from aimbat.models import AimbatEvent, AimbatStation @@ -25,81 +23,6 @@ # =================================================================== -class TestDefaultEvent: - """Tests for retrieving and switching the default event.""" - - def test_get(self, loaded_session: Session) -> None: - """Verifies that `get_default_event` returns the event marked as default in the DB. - - Args: - loaded_session (Session): The database session. - """ - default_event = loaded_session.exec( - select(AimbatEvent).where(AimbatEvent.is_default == 1) - ).one() - assert default_event == get_default_event(loaded_session) - - def test_switch(self, loaded_session: Session) -> None: - """Verifies switching the default event using an event object. - - Args: - loaded_session (Session): The database session. - """ - default_event = get_default_event(loaded_session) - assert default_event is not None, "expected a default event in the test data" - - all_events = list(loaded_session.exec(select(AimbatEvent)).all()) - assert len(all_events) > 1, "expected multiple events in the test data" - - all_events.remove(default_event) - new_default_event = all_events.pop() - assert new_default_event != default_event, ( - "expected a different event to switch to" - ) - - set_default_event(loaded_session, new_default_event.id) - assert get_default_event(loaded_session) == new_default_event - - def test_switch_by_id_invalid(self, loaded_session: Session) -> None: - """Verifies that switching the default event using an invalid event ID raises an error.""" - - new_uuid = uuid.uuid4() - assert ( - len( - loaded_session.exec( - select(AimbatEvent).where(AimbatEvent.id == new_uuid) - ).all() - ) - == 0 - ), "expected no event with the generated UUID in the test data" - - with pytest.raises(ValueError): - set_default_event(loaded_session, uuid.uuid4()) - - def test_get_default_event_no_default(self, loaded_session: Session) -> None: - """Verifies that `get_default_event` returns None if no event is marked as default. - - Args: - loaded_session (Session): The database session. - """ - default_event = get_default_event(loaded_session) - assert default_event is not None, "expected a default event in the test data" - default_event.is_default = None - assert ( - loaded_session.exec( - select(AimbatEvent).where(AimbatEvent.is_default == 1) - ).first() - is None - ), "expected no default event in the database after deactivating" - - assert get_default_event(loaded_session) is None - - -# =================================================================== -# Delete event -# =================================================================== - - class TestDeleteEvent: """Tests for deleting events from the database.""" @@ -111,13 +34,13 @@ def test_delete_event(self, loaded_session: Session) -> None: """ events = loaded_session.exec(select(AimbatEvent)).all() count_before = len(events) - non_default = next(e for e in events if not e.is_default) + to_delete = events[0] - delete_event(loaded_session, non_default.id) + delete_event(loaded_session, to_delete.id) remaining = loaded_session.exec(select(AimbatEvent)).all() assert len(remaining) == count_before - 1 - assert non_default not in remaining + assert to_delete not in remaining def test_delete_event_by_id_not_found(self, loaded_session: Session) -> None: """Verifies that deleting a non-existent event ID raises NoResultFound. @@ -216,13 +139,13 @@ def test_set_timedelta_parameter(self, loaded_session: Session) -> None: Args: loaded_session: The database session. """ - default_event = get_default_event(loaded_session) - assert default_event is not None + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None new_value = Timedelta(seconds=20) set_event_parameter( - loaded_session, default_event.id, EventParameter.WINDOW_POST, new_value + loaded_session, event.id, EventParameter.WINDOW_POST, new_value ) - assert default_event.parameters.window_post == new_value + assert event.parameters.window_post == new_value def test_set_float_parameter(self, loaded_session: Session) -> None: """Verifies that a float parameter is persisted correctly. @@ -230,13 +153,11 @@ def test_set_float_parameter(self, loaded_session: Session) -> None: Args: loaded_session: The database session. """ - default_event = get_default_event(loaded_session) - assert default_event is not None + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None new_value = 0.75 - set_event_parameter( - loaded_session, default_event.id, EventParameter.MIN_CC, new_value - ) - assert default_event.parameters.min_cc == new_value + set_event_parameter(loaded_session, event.id, EventParameter.MIN_CC, new_value) + assert event.parameters.min_cc == new_value def test_set_bool_parameter(self, loaded_session: Session) -> None: """Verifies that a bool parameter is persisted correctly. @@ -244,12 +165,10 @@ def test_set_bool_parameter(self, loaded_session: Session) -> None: Args: loaded_session: The database session. """ - default_event = get_default_event(loaded_session) - assert default_event is not None - set_event_parameter( - loaded_session, default_event.id, EventParameter.COMPLETED, True - ) - assert default_event.parameters.completed is True + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + set_event_parameter(loaded_session, event.id, EventParameter.COMPLETED, True) + assert event.parameters.completed is True def test_set_parameter_with_validate_iccs(self, loaded_session: Session) -> None: """Verifies that validate_iccs=True triggers ICCS validation. @@ -257,26 +176,26 @@ def test_set_parameter_with_validate_iccs(self, loaded_session: Session) -> None Args: loaded_session: The database session. """ - default_event = get_default_event(loaded_session) - assert default_event is not None + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None # Test valid change new_value = Timedelta(seconds=1.5) set_event_parameter( loaded_session, - default_event.id, + event.id, EventParameter.WINDOW_POST, new_value, validate_iccs=True, ) - assert default_event.parameters.window_post == new_value + assert event.parameters.window_post == new_value # Test invalid change (e.g., window that would result in no data) # Very large window might fail construction if it exceeds data bounds with pytest.raises(ValueError, match="ICCS validation failed"): set_event_parameter( loaded_session, - default_event.id, + event.id, EventParameter.WINDOW_POST, Timedelta(seconds=10000), validate_iccs=True, @@ -311,7 +230,7 @@ def test_from_read_model_returns_list(self, loaded_session: Session) -> None: assert isinstance(result, list) assert len(result) > 0 assert "id" in result[0] - assert "is_default" in result[0] + assert "last_modified" in result[0] def test_from_read_model_with_alias(self, loaded_session: Session) -> None: """Verifies that aliases are used when by_alias=True. @@ -322,8 +241,8 @@ def test_from_read_model_with_alias(self, loaded_session: Session) -> None: result = dump_event_table(loaded_session, from_read_model=True, by_alias=True) assert isinstance(result, list) assert len(result) > 0 - assert "isDefault" in result[0] - assert "is_default" not in result[0] + assert "lastModified" in result[0] + assert "last_modified" not in result[0] class TestDumpEventParameterTableToJson: diff --git a/tests/integration/core/test_iccs.py b/tests/integration/core/test_iccs.py index 20e574b9..ba236884 100644 --- a/tests/integration/core/test_iccs.py +++ b/tests/integration/core/test_iccs.py @@ -4,11 +4,10 @@ from aimbat.core import ( create_iccs_instance, - get_default_event, run_iccs, run_mccc, ) -from aimbat.models import AimbatSeismogramQuality +from aimbat.models import AimbatEvent, AimbatSeismogramQuality class TestIccsMcccInterplay: @@ -18,7 +17,7 @@ def test_run_iccs_nulls_mccc_stats_on_change(self, loaded_session: Session) -> N """Verifies that running ICCS nulls MCCC stats if t1 changed.""" from pandas import Timedelta - event = get_default_event(loaded_session) + event = loaded_session.exec(select(AimbatEvent)).first() assert event is not None # 1. Run MCCC to populate quality stats @@ -71,7 +70,7 @@ def test_run_iccs_preserves_mccc_stats_on_no_change( from aimbat.models import AimbatEventQuality - event = get_default_event(loaded_session) + event = loaded_session.exec(select(AimbatEvent)).first() assert event is not None # 1. Run ICCS first to ensure alignment @@ -128,7 +127,7 @@ def test_run_iccs_t1_change_nulls_all_iccs_cc( """ from pandas import Timedelta - event = get_default_event(loaded_session) + event = loaded_session.exec(select(AimbatEvent)).first() assert event is not None # 1. Run ICCS to populate iccs_cc diff --git a/tests/integration/core/test_mccc.py b/tests/integration/core/test_mccc.py index ed4eb840..a3cff134 100644 --- a/tests/integration/core/test_mccc.py +++ b/tests/integration/core/test_mccc.py @@ -5,10 +5,9 @@ from aimbat.core import ( create_iccs_instance, - get_default_event, run_mccc, ) -from aimbat.models import AimbatSeismogramQuality +from aimbat.models import AimbatEvent, AimbatSeismogramQuality class TestMccc: @@ -16,7 +15,7 @@ class TestMccc: def test_run_mccc_populates_quality_stats(self, loaded_session: Session) -> None: """Verifies that running MCCC populates quality metrics in the database.""" - event = get_default_event(loaded_session) + event = loaded_session.exec(select(AimbatEvent)).first() assert event is not None # Ensure no MCCC stats initially @@ -47,7 +46,7 @@ def test_run_mccc_populates_quality_stats(self, loaded_session: Session) -> None def test_run_mccc_all_seismograms(self, loaded_session: Session) -> None: """Verifies that MCCC can be run on all seismograms, including deselected ones.""" - event = get_default_event(loaded_session) + event = loaded_session.exec(select(AimbatEvent)).first() assert event is not None # Deselect one seismogram @@ -68,7 +67,7 @@ def test_run_mccc_all_seismograms(self, loaded_session: Session) -> None: def test_run_mccc_selected_only(self, loaded_session: Session) -> None: """Verifies that MCCC only processes selected seismograms by default.""" - event = get_default_event(loaded_session) + event = loaded_session.exec(select(AimbatEvent)).first() assert event is not None # Deselect one seismogram diff --git a/tests/integration/core/test_seismogram.py b/tests/integration/core/test_seismogram.py index fa20f9a7..36d2502a 100644 --- a/tests/integration/core/test_seismogram.py +++ b/tests/integration/core/test_seismogram.py @@ -12,25 +12,24 @@ delete_seismogram, dump_seismogram_parameter_table, dump_seismogram_table, - get_default_event, get_selected_seismograms, reset_seismogram_parameters, set_seismogram_parameter, ) -from aimbat.models import AimbatSeismogram, AimbatStation +from aimbat.models import AimbatEvent, AimbatSeismogram, AimbatStation from aimbat.models._parameters import AimbatSeismogramParametersBase from aimbat.plot import plot_seismograms @pytest.fixture def seismogram(loaded_session: Session) -> AimbatSeismogram: - """Provides the first seismogram from the default event. + """Provides the first seismogram from the database. Args: loaded_session: The database session. Returns: - An AimbatSeismogram from the default event. + An AimbatSeismogram. """ seismogram = loaded_session.exec(select(AimbatSeismogram)).first() assert seismogram is not None @@ -169,16 +168,14 @@ class TestGetSelectedSeismograms: """Tests for retrieving selected seismograms.""" def test_all_selected_by_default(self, loaded_session: Session) -> None: - """Verifies that all seismograms in the default event are selected by default. + """Verifies that all seismograms in an event are selected by default. Args: loaded_session: The database session. """ - default_event = get_default_event(loaded_session) - assert default_event is not None, ( - "expected a default event to be present in the database" - ) - selected = get_selected_seismograms(loaded_session, event_id=default_event.id) + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None, "expected an event to be present in the database" + selected = get_selected_seismograms(loaded_session, event_id=event.id) assert len(selected) > 0 def test_after_deselecting_one( @@ -190,18 +187,13 @@ def test_after_deselecting_one( loaded_session: The database session. seismogram: An AimbatSeismogram to deselect. """ - default_event = get_default_event(loaded_session) - assert default_event is not None, ( - "expected a default event to be present in the database" - ) - count_before = len( - get_selected_seismograms(loaded_session, event_id=default_event.id) - ) + event = seismogram.event + count_before = len(get_selected_seismograms(loaded_session, event_id=event.id)) set_seismogram_parameter( loaded_session, seismogram.id, SeismogramParameter.SELECT, False ) assert ( - len(get_selected_seismograms(loaded_session, event_id=default_event.id)) + len(get_selected_seismograms(loaded_session, event_id=event.id)) == count_before - 1 ) @@ -211,15 +203,13 @@ def test_all_events(self, loaded_session: Session) -> None: Args: loaded_session: The database session. """ - default_event = get_default_event(loaded_session) - assert default_event is not None, ( - "expected a default event to be present in the database" - ) - selected_default = get_selected_seismograms( - loaded_session, event_id=default_event.id, all_events=False + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None, "expected an event to be present in the database" + selected_single = get_selected_seismograms( + loaded_session, event_id=event.id, all_events=False ) selected_all = get_selected_seismograms(loaded_session, all_events=True) - assert len(selected_all) >= len(selected_default) + assert len(selected_all) >= len(selected_single) class TestDumpSeismogramTableToJson: @@ -239,17 +229,15 @@ def test_returns_list(self, loaded_session: Session) -> None: class TestDumpSeismogramParameterTableToJson: """Tests for serialising the seismogram parameter table to JSON.""" - def test_default_event_as_list(self, loaded_session: Session) -> None: - """Verifies that a list of dicts of the default event's parameters is returned. + def test_single_event_as_list(self, loaded_session: Session) -> None: + """Verifies that a list of dicts of an event's parameters is returned. Args: loaded_session: The database session. """ - default_event = get_default_event(loaded_session) - assert default_event is not None - result = dump_seismogram_parameter_table( - loaded_session, event_id=default_event.id - ) + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + result = dump_seismogram_parameter_table(loaded_session, event_id=event.id) assert isinstance(result, list) assert len(result) > 0 assert "select" in result[0] @@ -275,11 +263,9 @@ def test_plot_event_returns_figure(self, loaded_session: Session) -> None: Args: loaded_session: The database session. """ - default_event = get_default_event(loaded_session) - assert default_event is not None - fig, _ = plot_seismograms( - loaded_session, plot_for=default_event, return_fig=True - ) + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + fig, _ = plot_seismograms(loaded_session, plot_for=event, return_fig=True) assert isinstance(fig, Figure) def test_plot_station_returns_figure( diff --git a/tests/integration/core/test_snapshots.py b/tests/integration/core/test_snapshots.py index fbfb07af..b731adf7 100644 --- a/tests/integration/core/test_snapshots.py +++ b/tests/integration/core/test_snapshots.py @@ -8,15 +8,21 @@ from sqlalchemy.exc import NoResultFound from sqlmodel import Session, col, select -from aimbat.core import get_default_event from aimbat.core._snapshot import ( + compute_parameters_hash, create_snapshot, delete_snapshot, - dump_snapshot_tables, + dump_event_parameter_snapshot_table, + dump_event_quality_snapshot_table, + dump_seismogram_parameter_snapshot_table, + dump_seismogram_quality_snapshot_table, + dump_snapshot_table, get_snapshots, rollback_to_snapshot, + sync_from_matching_hash, ) from aimbat.models import ( + AimbatEvent, AimbatEventQuality, AimbatSeismogram, AimbatSeismogramQuality, @@ -90,17 +96,17 @@ def _write_mock_mccc_quality( @pytest.fixture def snapshot(loaded_session: Session) -> AimbatSnapshot: - """Provides a snapshot of the default event's current parameters. + """Provides a snapshot of the event's current parameters. Args: loaded_session: The database session. Returns: - An AimbatSnapshot for the default event. + An AimbatSnapshot for the event. """ - default_event = get_default_event(loaded_session) - assert default_event is not None - create_snapshot(loaded_session, default_event) + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + create_snapshot(loaded_session, event) snapshot = loaded_session.exec(select(AimbatSnapshot)).one_or_none() assert snapshot is not None return snapshot @@ -116,22 +122,22 @@ def test_creates_snapshot(self, loaded_session: Session) -> None: loaded_session: The database session. """ assert len(loaded_session.exec(select(AimbatSnapshot)).all()) == 0 - default_event = get_default_event(loaded_session) - assert default_event is not None - create_snapshot(loaded_session, default_event) + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + create_snapshot(loaded_session, event) assert len(loaded_session.exec(select(AimbatSnapshot)).all()) == 1 - def test_snapshot_linked_to_default_event(self, loaded_session: Session) -> None: - """Verifies that the snapshot is associated with the default event. + def test_snapshot_linked_to_event(self, loaded_session: Session) -> None: + """Verifies that the snapshot is associated with the event. Args: loaded_session: The database session. """ - default_event = get_default_event(loaded_session) - assert default_event is not None - create_snapshot(loaded_session, default_event) + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + create_snapshot(loaded_session, event) snapshot = loaded_session.exec(select(AimbatSnapshot)).one() - assert snapshot.event_id == default_event.id + assert snapshot.event_id == event.id def test_snapshot_with_comment(self, loaded_session: Session) -> None: """Verifies that the optional comment is stored on the snapshot. @@ -139,9 +145,9 @@ def test_snapshot_with_comment(self, loaded_session: Session) -> None: Args: loaded_session: The database session. """ - default_event = get_default_event(loaded_session) - assert default_event is not None - create_snapshot(loaded_session, default_event, comment="test comment") + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + create_snapshot(loaded_session, event, comment="test comment") snapshot = loaded_session.exec(select(AimbatSnapshot)).one() assert snapshot.comment == "test comment" @@ -151,9 +157,9 @@ def test_snapshot_without_comment(self, loaded_session: Session) -> None: Args: loaded_session: The database session. """ - default_event = get_default_event(loaded_session) - assert default_event is not None - create_snapshot(loaded_session, default_event) + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + create_snapshot(loaded_session, event) snapshot = loaded_session.exec(select(AimbatSnapshot)).one() assert snapshot.comment is None @@ -165,11 +171,11 @@ def test_snapshot_captures_seismogram_parameters( Args: loaded_session: The database session. """ - default_event = get_default_event(loaded_session) - assert default_event is not None - n_seismograms = len(default_event.seismograms) + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + n_seismograms = len(event.seismograms) - create_snapshot(loaded_session, default_event) + create_snapshot(loaded_session, event) snapshot = loaded_session.exec(select(AimbatSnapshot)).one() assert len(snapshot.seismogram_parameters_snapshots) == n_seismograms @@ -180,14 +186,11 @@ def test_snapshot_captures_event_parameters( Args: loaded_session: The database session. - snapshot: An AimbatSnapshot for the default event. + snapshot: An AimbatSnapshot for the event. """ - default_event = get_default_event(loaded_session) - assert default_event is not None - assert ( - snapshot.event_parameters_snapshot.parameters_id - == default_event.parameters.id - ) + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + assert snapshot.event_parameters_snapshot.parameters_id == event.parameters.id class TestDeleteSnapshot: @@ -227,19 +230,19 @@ def test_rollback_restores_event_parameters( loaded_session: The database session. snapshot: An AimbatSnapshot capturing the original parameters. """ - default_event = get_default_event(loaded_session) - assert default_event is not None + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None original_min_cc = snapshot.event_parameters_snapshot.min_cc # Mutate the parameter after taking the snapshot - default_event.parameters.min_cc = 0.0 - loaded_session.add(default_event) + event.parameters.min_cc = 0.0 + loaded_session.add(event) loaded_session.commit() - assert default_event.parameters.min_cc == 0.0 + assert event.parameters.min_cc == 0.0 rollback_to_snapshot(loaded_session, snapshot.id) - loaded_session.refresh(default_event) - assert default_event.parameters.min_cc == original_min_cc + loaded_session.refresh(event) + assert event.parameters.min_cc == original_min_cc def test_rollback_restores_seismogram_parameters( self, loaded_session: Session, snapshot: AimbatSnapshot @@ -250,9 +253,9 @@ def test_rollback_restores_seismogram_parameters( loaded_session: The database session. snapshot: An AimbatSnapshot capturing the original parameters. """ - default_event = get_default_event(loaded_session) - assert default_event is not None - seismogram = default_event.seismograms[0] + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + seismogram = event.seismograms[0] original_select = snapshot.seismogram_parameters_snapshots[0].select # Mutate the parameter after taking the snapshot @@ -273,9 +276,9 @@ def test_rollback_restores_all_event_parameters( loaded_session: The database session. snapshot: An AimbatSnapshot capturing the original parameters. """ - default_event = get_default_event(loaded_session) - assert default_event is not None - params = default_event.parameters + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + params = event.parameters snap = snapshot.event_parameters_snapshot # Mutate every event parameter to a value distinct from the snapshot @@ -313,9 +316,9 @@ def test_rollback_restores_all_seismogram_parameters( loaded_session: The database session. snapshot: An AimbatSnapshot capturing the original parameters. """ - default_event = get_default_event(loaded_session) - assert default_event is not None - seismogram = default_event.seismograms[0] + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + seismogram = event.seismograms[0] params = seismogram.parameters snap = next( s @@ -343,27 +346,27 @@ def test_rollback_restores_event_quality(self, loaded_session: Session) -> None: Args: loaded_session: The database session. """ - default_event = get_default_event(loaded_session) - assert default_event is not None + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None - seis_ids = [s.id for s in default_event.seismograms] - select_flags = [s.select for s in default_event.seismograms] + seis_ids = [s.id for s in event.seismograms] + select_flags = [s.select for s in event.seismograms] _write_mock_mccc_quality( loaded_session, - default_event.id, + event.id, seis_ids, select_flags, all_seismograms=True, ) - loaded_session.refresh(default_event) - create_snapshot(loaded_session, default_event) + loaded_session.refresh(event) + create_snapshot(loaded_session, event) snapshot = loaded_session.exec(select(AimbatSnapshot)).one() # Mutate a parameter (changes the hash) and overwrite quality with a different value. - default_event.parameters.min_cc = 0.0 + event.parameters.min_cc = 0.0 eq = loaded_session.exec( select(AimbatEventQuality).where( - col(AimbatEventQuality.event_id) == default_event.id + col(AimbatEventQuality.event_id) == event.id ) ).one() eq.mccc_rmse = pd.Timedelta(seconds=99) @@ -384,24 +387,24 @@ def test_rollback_restores_seismogram_quality( Args: loaded_session: The database session. """ - default_event = get_default_event(loaded_session) - assert default_event is not None + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None - seis_ids = [s.id for s in default_event.seismograms] - select_flags = [s.select for s in default_event.seismograms] + seis_ids = [s.id for s in event.seismograms] + select_flags = [s.select for s in event.seismograms] _write_mock_mccc_quality( loaded_session, - default_event.id, + event.id, seis_ids, select_flags, all_seismograms=True, ) - loaded_session.refresh(default_event) - create_snapshot(loaded_session, default_event) + loaded_session.refresh(event) + create_snapshot(loaded_session, event) snapshot = loaded_session.exec(select(AimbatSnapshot)).one() # Mutate a parameter (changes the hash) and overwrite quality with different values. - default_event.parameters.min_cc = 0.0 + event.parameters.min_cc = 0.0 for seis_id in seis_ids: sq = loaded_session.exec( select(AimbatSeismogramQuality).where( @@ -447,22 +450,22 @@ def test_no_snapshots_initially(self, loaded_session: Session) -> None: Args: loaded_session: The database session. """ - default_event = get_default_event(loaded_session) - assert default_event is not None - assert len(get_snapshots(loaded_session, event_id=default_event.id)) == 0 + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + assert len(get_snapshots(loaded_session, event_id=event.id)) == 0 - def test_get_snapshots_for_default_event( + def test_get_snapshots_for_event( self, loaded_session: Session, snapshot: AimbatSnapshot ) -> None: - """Verifies that snapshots for the default event are returned. + """Verifies that snapshots for the event are returned. Args: loaded_session: The database session. - snapshot: An AimbatSnapshot for the default event. + snapshot: An AimbatSnapshot for the event. """ - default_event = get_default_event(loaded_session) - assert default_event is not None - snapshots = get_snapshots(loaded_session, event_id=default_event.id) + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + snapshots = get_snapshots(loaded_session, event_id=event.id) assert len(snapshots) == 1 assert snapshots[0].id == snapshot.id @@ -473,7 +476,7 @@ def test_get_snapshots_all_events( Args: loaded_session: The database session. - snapshot: An AimbatSnapshot for the default event. + snapshot: An AimbatSnapshot for the event. """ all_snapshots = get_snapshots(loaded_session) assert len(all_snapshots) >= 1 @@ -484,59 +487,326 @@ def test_multiple_snapshots(self, loaded_session: Session) -> None: Args: loaded_session: The database session. """ - default_event = get_default_event(loaded_session) - assert default_event is not None - create_snapshot(loaded_session, default_event, comment="first") - create_snapshot(loaded_session, default_event, comment="second") - assert len(get_snapshots(loaded_session, event_id=default_event.id)) == 2 + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + create_snapshot(loaded_session, event, comment="first") + create_snapshot(loaded_session, event, comment="second") + assert len(get_snapshots(loaded_session, event_id=event.id)) == 2 + + +class TestComputeParametersHash: + """Tests for the parameter hashing logic.""" + + def test_hash_is_deterministic(self, loaded_session: Session) -> None: + """Verifies that the same parameters produce the same hash.""" + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + h1 = compute_parameters_hash(event) + h2 = compute_parameters_hash(event) + assert h1 == h2 + + def test_hash_changes_with_event_parameters(self, loaded_session: Session) -> None: + """Verifies that changing an event parameter changes the hash.""" + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + h1 = compute_parameters_hash(event) + event.parameters.min_cc += 0.1 + h2 = compute_parameters_hash(event) + assert h1 != h2 + + def test_hash_changes_with_seismogram_parameters( + self, loaded_session: Session + ) -> None: + """Verifies that changing a seismogram parameter changes the hash.""" + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + h1 = compute_parameters_hash(event) + event.seismograms[0].parameters.flip = not event.seismograms[0].parameters.flip + h2 = compute_parameters_hash(event) + assert h1 != h2 + + def test_hash_ignores_excluded_fields(self, loaded_session: Session) -> None: + """Verifies that changing completed or select does not change the hash.""" + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + h1 = compute_parameters_hash(event) + event.parameters.completed = not event.parameters.completed + event.seismograms[0].parameters.select = not event.seismograms[ + 0 + ].parameters.select + h2 = compute_parameters_hash(event) + assert h1 == h2 + + +class TestSyncFromMatchingHash: + """Tests for syncing quality metrics from matching hashes.""" + + def test_sync_from_matching_hash(self, loaded_session: Session) -> None: + """Verifies that quality is synced when the hash matches.""" + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + + # Write quality data and take snapshot + seis_ids = [s.id for s in event.seismograms] + select_flags = [s.select for s in event.seismograms] + _write_mock_mccc_quality( + loaded_session, event.id, seis_ids, select_flags, all_seismograms=True + ) + loaded_session.refresh(event) + create_snapshot(loaded_session, event) + h = compute_parameters_hash(event) + + # Clear live quality + eq = loaded_session.exec( + select(AimbatEventQuality).where( + col(AimbatEventQuality.event_id) == event.id + ) + ).one() + eq.mccc_rmse = None + loaded_session.add(eq) + loaded_session.commit() + # Sync from hash + assert sync_from_matching_hash(loaded_session, parameters_hash=h) is True + loaded_session.refresh(eq) + assert eq.mccc_rmse is not None -class TestDumpSnapshotTablesToJson: - """Tests for serialising snapshot data to JSON.""" + def test_sync_no_match(self, loaded_session: Session) -> None: + """Verifies return False when no match is found.""" + assert ( + sync_from_matching_hash(loaded_session, parameters_hash="no-such-hash") + is False + ) - def test_as_dict(self, loaded_session: Session, snapshot: AimbatSnapshot) -> None: - """Verifies that a dict is returned when as_string=False. - Args: - loaded_session: The database session. - snapshot: An AimbatSnapshot to include in the dump. - """ - default_event = get_default_event(loaded_session) - assert default_event is not None - result = dump_snapshot_tables(loaded_session, event_id=default_event.id) - assert isinstance(result, dict) - assert "snapshots" in result - assert len(result["snapshots"]) == 1 +class TestDumpSnapshotTable: + """Tests for dump_snapshot_table.""" - def test_all_events_includes_more_snapshots( + def test_dump_snapshot_table( self, loaded_session: Session, snapshot: AimbatSnapshot ) -> None: - """Verifies that all_events=True returns at least as many snapshots as default only. + """Verifies that a list is returned.""" + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + result = dump_snapshot_table(loaded_session, event_id=event.id) + assert isinstance(result, list) + assert len(result) == 1 + + def test_dump_snapshot_table_read_model( + self, loaded_session: Session, snapshot: AimbatSnapshot + ) -> None: + """Test dump_snapshot_table with from_read_model=True.""" + result = dump_snapshot_table(loaded_session, from_read_model=True) + assert isinstance(result, list) + assert "seismogram_count" in result[0] - Args: - loaded_session: The database session. - snapshot: An AimbatSnapshot to include in the dump. - """ - default_event = get_default_event(loaded_session) - assert default_event is not None - default_only = dump_snapshot_tables(loaded_session, event_id=default_event.id) - all_events = dump_snapshot_tables( - loaded_session, + def test_dump_snapshot_table_by_title( + self, loaded_session: Session, snapshot: AimbatSnapshot + ) -> None: + """Test dump_snapshot_table with by_title=True.""" + result = dump_snapshot_table( + loaded_session, from_read_model=True, by_title=True ) - assert len(all_events["snapshots"]) >= len(default_only["snapshots"]) + assert isinstance(result, list) + assert "Time" in result[0] - def test_seismogram_parameters_count( + def test_dump_snapshot_table_exclude( self, loaded_session: Session, snapshot: AimbatSnapshot ) -> None: - """Verifies that seismogram_parameters count matches the default event's seismograms. + """Test dump_snapshot_table with exclude.""" + result = dump_snapshot_table(loaded_session, exclude={"id"}) + assert "id" not in result[0] - Args: - loaded_session: The database session. - snapshot: An AimbatSnapshot to include in the dump. - """ + def test_all_events_includes_more_snapshots( + self, loaded_session: Session, snapshot: AimbatSnapshot + ) -> None: + """Verifies that all events returns at least as many snapshots as single event only.""" + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + default_only = dump_snapshot_table(loaded_session, event_id=event.id) + all_events = dump_snapshot_table(loaded_session) + assert len(all_events) >= len(default_only) + + +class TestDumpEventParameterSnapshotTable: + """Tests for dump_event_parameter_snapshot_table.""" + + def test_dump_event_parameter_snapshot_table( + self, loaded_session: Session, snapshot: AimbatSnapshot + ) -> None: + """Test dump_event_parameter_snapshot_table.""" + result = dump_event_parameter_snapshot_table(loaded_session) + assert isinstance(result, list) + assert len(result) >= 1 + assert "min_cc" in result[0] + assert "mccc_damp" in result[0] + assert "snapshot_id" in result[0] + + def test_dump_event_parameter_snapshot_table_by_alias( + self, loaded_session: Session, snapshot: AimbatSnapshot + ) -> None: + """Test dump_event_parameter_snapshot_table with by_alias=True.""" + result = dump_event_parameter_snapshot_table(loaded_session, by_alias=True) + assert isinstance(result, list) + assert len(result) >= 1 + + def test_dump_event_parameter_snapshot_table_event_id( + self, loaded_session: Session, snapshot: AimbatSnapshot + ) -> None: + """Test dump_event_parameter_snapshot_table filtering by event_id.""" + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + result = dump_event_parameter_snapshot_table(loaded_session, event_id=event.id) + assert len(result) == 1 + assert result[0]["parameters_id"] == str(event.parameters.id) + + def test_dump_event_parameter_snapshot_table_exclude( + self, loaded_session: Session, snapshot: AimbatSnapshot + ) -> None: + """Test dump_event_parameter_snapshot_table with exclude.""" + result = dump_event_parameter_snapshot_table(loaded_session, exclude={"id"}) + assert "id" not in result[0] + + +class TestDumpSeismogramParameterSnapshotTable: + """Tests for dump_seismogram_parameter_snapshot_table.""" + + def test_seismogram_parameters_count( + self, loaded_session: Session, snapshot: AimbatSnapshot + ) -> None: + """Verifies that seismogram_parameters count matches the event's seismograms.""" n_seismograms = len(loaded_session.exec(select(AimbatSeismogram)).all()) - result = dump_snapshot_tables(loaded_session) - assert len(result["seismogram_parameters"]) <= n_seismograms + result = dump_seismogram_parameter_snapshot_table(loaded_session) + assert len(result) <= n_seismograms + + def test_dump_seismogram_parameter_snapshot_table_by_alias( + self, loaded_session: Session, snapshot: AimbatSnapshot + ) -> None: + """Test dump_seismogram_parameter_snapshot_table with by_alias=True.""" + result = dump_seismogram_parameter_snapshot_table(loaded_session, by_alias=True) + assert isinstance(result, list) + assert len(result) >= 1 + + def test_dump_seismogram_parameter_snapshot_table_exclude( + self, loaded_session: Session, snapshot: AimbatSnapshot + ) -> None: + """Test dump_seismogram_parameter_snapshot_table with exclude.""" + result = dump_seismogram_parameter_snapshot_table( + loaded_session, exclude={"id"} + ) + assert "id" not in result[0] + + +class TestDumpEventQualitySnapshotTable: + """Tests for dump_event_quality_snapshot_table.""" + + def test_dump_event_quality_snapshot_table_event_id( + self, loaded_session: Session + ) -> None: + """Test dump_event_quality_snapshot_table filtering by event_id.""" + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + _write_mock_mccc_quality( + loaded_session, + event.id, + [s.id for s in event.seismograms], + [True] * len(event.seismograms), + all_seismograms=True, + ) + loaded_session.refresh(event) + create_snapshot(loaded_session, event) + + result = dump_event_quality_snapshot_table(loaded_session, event_id=event.id) + assert len(result) == 1 + assert "event_quality_id" in result[0] + assert isinstance(result[0]["event_quality_id"], str) + assert result[0]["event_quality_id"] is not None + + def test_dump_event_quality_snapshot_table(self, loaded_session: Session) -> None: + """Test dump_event_quality_snapshot_table with quality data.""" + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + _write_mock_mccc_quality( + loaded_session, + event.id, + [s.id for s in event.seismograms], + [True] * len(event.seismograms), + all_seismograms=True, + ) + loaded_session.refresh(event) + create_snapshot(loaded_session, event) + + result = dump_event_quality_snapshot_table(loaded_session) + assert isinstance(result, list) + assert len(result) >= 1 + assert "mccc_rmse" in result[0] + + +class TestDumpSeismogramQualitySnapshotTable: + """Tests for dump_seismogram_quality_snapshot_table.""" + + def test_dump_seismogram_quality_snapshot_table_event_id_with_mccc( + self, loaded_session: Session + ) -> None: + """Test dump_seismogram_quality_snapshot_table filtering by event_id.""" + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + _write_mock_mccc_quality( + loaded_session, + event.id, + [s.id for s in event.seismograms], + [True] * len(event.seismograms), + all_seismograms=True, + ) + loaded_session.refresh(event) + create_snapshot(loaded_session, event) + + result = dump_seismogram_quality_snapshot_table( + loaded_session, event_id=event.id + ) + assert len(result) == len(event.seismograms) + assert "snapshot_id" in result[0] + assert "seismogram_quality_id" in result[0] + + def test_dump_seismogram_quality_snapshot_table_exclude( + self, loaded_session: Session + ) -> None: + """Test dump_seismogram_quality_snapshot_table with exclude.""" + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + _write_mock_mccc_quality( + loaded_session, + event.id, + [s.id for s in event.seismograms], + [True] * len(event.seismograms), + all_seismograms=True, + ) + loaded_session.refresh(event) + create_snapshot(loaded_session, event) + + result = dump_seismogram_quality_snapshot_table(loaded_session, exclude={"id"}) + assert "id" not in result[0] + + def test_dump_seismogram_quality_snapshot_table( + self, loaded_session: Session + ) -> None: + """Test dump_seismogram_quality_snapshot_table with quality data.""" + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + _write_mock_mccc_quality( + loaded_session, + event.id, + [s.id for s in event.seismograms], + [True] * len(event.seismograms), + all_seismograms=True, + ) + loaded_session.refresh(event) + create_snapshot(loaded_session, event) + + result = dump_seismogram_quality_snapshot_table(loaded_session) + assert isinstance(result, list) + assert len(result) == len(event.seismograms) + assert "mccc_cc_mean" in result[0] class TestSnapshotMcccQualityRecords: @@ -550,23 +820,23 @@ def test_quality_records_written_for_all_when_true( Args: loaded_session: The database session. """ - default_event = get_default_event(loaded_session) - assert default_event is not None + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None # Deselect one seismogram so the distinction between modes is meaningful. - default_event.seismograms[0].parameters.select = False + event.seismograms[0].parameters.select = False loaded_session.commit() - seis_ids = [s.id for s in default_event.seismograms] - select_flags = [s.select for s in default_event.seismograms] + seis_ids = [s.id for s in event.seismograms] + select_flags = [s.select for s in event.seismograms] _write_mock_mccc_quality( loaded_session, - default_event.id, + event.id, seis_ids, select_flags, all_seismograms=True, ) - loaded_session.refresh(default_event) - create_snapshot(loaded_session, default_event) + loaded_session.refresh(event) + create_snapshot(loaded_session, event) snapshot = loaded_session.exec(select(AimbatSnapshot)).one() assert len(snapshot.seismogram_quality_snapshots) == len(seis_ids) @@ -578,24 +848,24 @@ def test_quality_records_written_for_selected_only_when_false( Args: loaded_session: The database session. """ - default_event = get_default_event(loaded_session) - assert default_event is not None + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None # Deselect one seismogram. - default_event.seismograms[0].parameters.select = False + event.seismograms[0].parameters.select = False loaded_session.commit() - seis_ids = [s.id for s in default_event.seismograms] - select_flags = [s.select for s in default_event.seismograms] + seis_ids = [s.id for s in event.seismograms] + select_flags = [s.select for s in event.seismograms] n_selected = sum(select_flags) _write_mock_mccc_quality( loaded_session, - default_event.id, + event.id, seis_ids, select_flags, all_seismograms=False, ) - loaded_session.refresh(default_event) - create_snapshot(loaded_session, default_event) + loaded_session.refresh(event) + create_snapshot(loaded_session, event) snapshot = loaded_session.exec(select(AimbatSnapshot)).one() assert len(snapshot.seismogram_quality_snapshots) == n_selected @@ -612,11 +882,11 @@ def test_create_iccs_instance_writes_iccs_cc(self, loaded_session: Session) -> N from aimbat.core._iccs import clear_iccs_cache, create_iccs_instance clear_iccs_cache() - default_event = get_default_event(loaded_session) - assert default_event is not None - create_iccs_instance(loaded_session, default_event) + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + create_iccs_instance(loaded_session, event) - for seis in default_event.seismograms: + for seis in event.seismograms: loaded_session.refresh(seis) assert seis.quality is not None assert seis.quality.iccs_cc is not None @@ -633,18 +903,18 @@ def test_create_iccs_instance_overwrites_on_rebuild( from aimbat.core._iccs import clear_iccs_cache, create_iccs_instance clear_iccs_cache() - default_event = get_default_event(loaded_session) - assert default_event is not None + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None - create_iccs_instance(loaded_session, default_event) - seis = default_event.seismograms[0] + create_iccs_instance(loaded_session, event) + seis = event.seismograms[0] loaded_session.refresh(seis) assert seis.quality is not None first_iccs_cc = seis.quality.iccs_cc # Force a rebuild by invalidating the cache. clear_iccs_cache() - create_iccs_instance(loaded_session, default_event) + create_iccs_instance(loaded_session, event) loaded_session.refresh(seis) assert seis.quality is not None assert seis.quality.iccs_cc == first_iccs_cc @@ -658,19 +928,19 @@ def test_snapshot_captures_iccs_cc(self, loaded_session: Session) -> None: from aimbat.core._iccs import clear_iccs_cache, create_iccs_instance clear_iccs_cache() - default_event = get_default_event(loaded_session) - assert default_event is not None + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None # Write ICCS stats to the live quality table first. - create_iccs_instance(loaded_session, default_event) - loaded_session.refresh(default_event) + create_iccs_instance(loaded_session, event) + loaded_session.refresh(event) # Create a snapshot (no MCCC has been run). - create_snapshot(loaded_session, default_event) + create_snapshot(loaded_session, event) snapshot = loaded_session.exec(select(AimbatSnapshot)).one() # Every seismogram should have an iccs_cc in the quality snapshot. - n = len(default_event.seismograms) + n = len(event.seismograms) assert len(snapshot.seismogram_quality_snapshots) == n for q in snapshot.seismogram_quality_snapshots: assert q.iccs_cc is not None @@ -690,10 +960,10 @@ def test_snapshot_without_iccs_stats_has_no_quality_records( from aimbat.core._iccs import clear_iccs_cache clear_iccs_cache() - default_event = get_default_event(loaded_session) - assert default_event is not None + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None # Create snapshot with no prior ICCS run (live table is empty). - create_snapshot(loaded_session, default_event) + create_snapshot(loaded_session, event) snapshot = loaded_session.exec(select(AimbatSnapshot)).one() assert len(snapshot.seismogram_quality_snapshots) == 0 diff --git a/tests/integration/core/test_station.py b/tests/integration/core/test_station.py index 079b4ca4..f71c6c1c 100644 --- a/tests/integration/core/test_station.py +++ b/tests/integration/core/test_station.py @@ -6,28 +6,27 @@ from sqlalchemy.exc import NoResultFound from sqlmodel import Session, select -from aimbat.core import get_default_event from aimbat.core._station import ( delete_station, dump_station_table, get_stations_in_event, ) -from aimbat.models import AimbatStation +from aimbat.models import AimbatEvent, AimbatStation @pytest.fixture def station(loaded_session: Session) -> AimbatStation: - """Provides the first station associated with the default event. + """Provides the first station from the database. Args: loaded_session: The database session. Returns: - The first AimbatStation in the default event. + The first AimbatStation in the database. """ - default_event = get_default_event(loaded_session) - assert default_event is not None - return default_event.seismograms[0].station + station = loaded_session.exec(select(AimbatStation)).first() + assert station is not None + return station class TestDeleteStation: @@ -58,21 +57,19 @@ def test_delete_station_id_not_found(self, loaded_session: Session) -> None: delete_station(loaded_session, uuid.uuid4()) -class TestGetStationsInDefaultEvent: - """Tests for retrieving stations in the default event.""" +class TestGetStationsInEvent: + """Tests for retrieving stations in a specific event.""" - def test_returns_stations(self, loaded_session: Session) -> None: - """Verifies that stations for the default event are returned. + def test_returns_stations_for_event(self, loaded_session: Session) -> None: + """Verifies that stations for the given event are returned. Args: loaded_session: The database session. """ - default_event = get_default_event(loaded_session) - assert default_event is not None - stations = get_stations_in_event( - loaded_session, default_event.id, as_json=False - ) - assert len(stations) > 0, "Expected at least one station for the default event" + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + stations = get_stations_in_event(loaded_session, event.id) + assert len(stations) > 0, "Expected at least one station for the given event" def test_returns_aimbat_station_instances(self, loaded_session: Session) -> None: """Verifies that all returned items are AimbatStation instances. @@ -80,11 +77,9 @@ def test_returns_aimbat_station_instances(self, loaded_session: Session) -> None Args: loaded_session: The database session. """ - default_event = get_default_event(loaded_session) - assert default_event is not None - stations = get_stations_in_event( - loaded_session, default_event.id, as_json=False - ) + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + stations = get_stations_in_event(loaded_session, event.id) assert all(isinstance(s, AimbatStation) for s in stations), ( "All returned items should be AimbatStation instances" ) @@ -95,9 +90,9 @@ def test_as_json_returns_list_of_dicts(self, loaded_session: Session) -> None: Args: loaded_session: The database session. """ - default_event = get_default_event(loaded_session) - assert default_event is not None - result = get_stations_in_event(loaded_session, default_event.id, as_json=True) + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + result = get_stations_in_event(loaded_session, event.id, as_json=True) assert isinstance(result, list), "Expected a list when as_json=True" assert all(isinstance(item, dict) for item in result), ( "Each element should be a dict when as_json=True" @@ -109,75 +104,27 @@ def test_as_json_count_matches_objects(self, loaded_session: Session) -> None: Args: loaded_session: The database session. """ - default_event = get_default_event(loaded_session) - assert default_event is not None - objects = get_stations_in_event(loaded_session, default_event.id, as_json=False) - json_list = get_stations_in_event( - loaded_session, default_event.id, as_json=True - ) + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + objects = get_stations_in_event(loaded_session, event.id, as_json=False) + json_list = get_stations_in_event(loaded_session, event.id, as_json=True) assert len(objects) == len(json_list), ( "Object and JSON representations should have the same length" ) - def test_stations_belong_to_default_event(self, loaded_session: Session) -> None: - """Verifies that the returned stations are associated with the default event. + def test_stations_belong_to_event(self, loaded_session: Session) -> None: + """Verifies that the returned stations are associated with the event. Args: loaded_session: The database session. """ - default_event = get_default_event(loaded_session) - assert default_event is not None - default_station_ids = {s.station_id for s in default_event.seismograms} - stations = get_stations_in_event( - loaded_session, default_event.id, as_json=False - ) + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + station_ids = {s.station_id for s in event.seismograms} + stations = get_stations_in_event(loaded_session, event.id, as_json=False) returned_ids = {s.id for s in stations} - assert returned_ids == default_station_ids, ( - "Returned station IDs should match those linked to the default event" - ) - - -class TestGetStationsInEvent: - """Tests for retrieving stations in a specific event.""" - - def test_returns_stations_for_event(self, loaded_session: Session) -> None: - """Verifies that stations for the given event are returned. - - Args: - loaded_session: The database session. - """ - default_event = get_default_event(loaded_session) - assert default_event is not None - stations = get_stations_in_event(loaded_session, default_event.id) - assert len(stations) > 0, "Expected at least one station for the given event" - - def test_returns_aimbat_station_instances(self, loaded_session: Session) -> None: - """Verifies that all returned items are AimbatStation instances. - - Args: - loaded_session: The database session. - """ - default_event = get_default_event(loaded_session) - assert default_event is not None - stations = get_stations_in_event(loaded_session, default_event.id) - assert all(isinstance(s, AimbatStation) for s in stations), ( - "All returned items should be AimbatStation instances" - ) - - def test_station_ids_match_event_seismograms(self, loaded_session: Session) -> None: - """Verifies that station IDs match those linked to the event's seismograms. - - Args: - loaded_session: The database session. - """ - default_event = get_default_event(loaded_session) - assert default_event is not None - expected_ids = {s.station_id for s in default_event.seismograms} - returned_ids = { - s.id for s in get_stations_in_event(loaded_session, default_event.id) - } - assert returned_ids == expected_ids, ( - "Station IDs should match those linked to the event's seismograms" + assert returned_ids == station_ids, ( + "Returned station IDs should match those linked to the event" ) diff --git a/tests/integration/core/test_views.py b/tests/integration/core/test_views.py index 564713a7..40690d3f 100644 --- a/tests/integration/core/test_views.py +++ b/tests/integration/core/test_views.py @@ -6,9 +6,9 @@ import pytest from sqlmodel import Session, col, select -from aimbat.core import get_default_event from aimbat.core._quality import get_quality_event, get_quality_seismogram from aimbat.core._snapshot import create_snapshot +from aimbat.models import AimbatEvent def _write_mock_mccc_quality( @@ -84,9 +84,9 @@ def test_returns_none_when_no_mccc_run(self, loaded_session: Session) -> None: Args: loaded_session: The database session. """ - default_event = get_default_event(loaded_session) - assert default_event is not None - seis = default_event.seismograms[0] + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + seis = event.seismograms[0] assert get_quality_seismogram(loaded_session, seis.id) is None def test_returns_quality_for_selected_seismogram( @@ -97,19 +97,19 @@ def test_returns_quality_for_selected_seismogram( Args: loaded_session: The database session. """ - default_event = get_default_event(loaded_session) - assert default_event is not None - seis_ids = [s.id for s in default_event.seismograms] + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + seis_ids = [s.id for s in event.seismograms] select_flags = [True] * len(seis_ids) _write_mock_mccc_quality( loaded_session, - default_event.id, + event.id, seis_ids, select_flags, all_seismograms=False, ) - loaded_session.refresh(default_event) - create_snapshot(loaded_session, default_event) + loaded_session.refresh(event) + create_snapshot(loaded_session, event) result = get_quality_seismogram(loaded_session, seis_ids[0]) assert result is not None @@ -126,35 +126,35 @@ def test_returns_none_for_deselected_seismogram_when_selected_only( Args: loaded_session: The database session. """ - default_event = get_default_event(loaded_session) - assert default_event is not None + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None - seis_ids = [s.id for s in default_event.seismograms] + seis_ids = [s.id for s in event.seismograms] # Snapshot 1: all_seismograms=True โ€” deselected seismogram gets quality data. select_flags_all_deselected = [False] + [True] * (len(seis_ids) - 1) - for i, seis in enumerate(default_event.seismograms): + for i, seis in enumerate(event.seismograms): seis.parameters.select = select_flags_all_deselected[i] loaded_session.commit() _write_mock_mccc_quality( loaded_session, - default_event.id, + event.id, seis_ids, select_flags_all_deselected, all_seismograms=True, ) - loaded_session.refresh(default_event) - create_snapshot(loaded_session, default_event) + loaded_session.refresh(event) + create_snapshot(loaded_session, event) # Snapshot 2 (most recent): all_seismograms=False โ€” deselected seismogram is excluded. _write_mock_mccc_quality( loaded_session, - default_event.id, + event.id, seis_ids, select_flags_all_deselected, all_seismograms=False, ) - loaded_session.refresh(default_event) - create_snapshot(loaded_session, default_event) + loaded_session.refresh(event) + create_snapshot(loaded_session, event) # The deselected seismogram should return None despite having data in snapshot 1. deselected_id = seis_ids[0] @@ -168,24 +168,24 @@ def test_returns_quality_for_deselected_seismogram_when_all_seismograms( Args: loaded_session: The database session. """ - default_event = get_default_event(loaded_session) - assert default_event is not None + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None - seis_ids = [s.id for s in default_event.seismograms] + seis_ids = [s.id for s in event.seismograms] select_flags = [False] + [True] * (len(seis_ids) - 1) - for i, seis in enumerate(default_event.seismograms): + for i, seis in enumerate(event.seismograms): seis.parameters.select = select_flags[i] loaded_session.commit() _write_mock_mccc_quality( loaded_session, - default_event.id, + event.id, seis_ids, select_flags, all_seismograms=True, ) - loaded_session.refresh(default_event) - create_snapshot(loaded_session, default_event) + loaded_session.refresh(event) + create_snapshot(loaded_session, event) deselected_id = seis_ids[0] result = get_quality_seismogram(loaded_session, deselected_id) @@ -202,9 +202,9 @@ def test_returns_none_when_no_mccc(self, loaded_session: Session) -> None: Args: loaded_session: The database session. """ - default_event = get_default_event(loaded_session) - assert default_event is not None - event_quality, stats = get_quality_event(loaded_session, default_event.id) + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None + event_quality, stats = get_quality_event(loaded_session, event.id) assert event_quality is None assert stats.count == 0 @@ -216,22 +216,22 @@ def test_includes_all_quality_records_from_snapshot( Args: loaded_session: The database session. """ - default_event = get_default_event(loaded_session) - assert default_event is not None + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None - seis_ids = [s.id for s in default_event.seismograms] - select_flags = [s.select for s in default_event.seismograms] + seis_ids = [s.id for s in event.seismograms] + select_flags = [s.select for s in event.seismograms] _write_mock_mccc_quality( loaded_session, - default_event.id, + event.id, seis_ids, select_flags, all_seismograms=False, ) - loaded_session.refresh(default_event) - create_snapshot(loaded_session, default_event) + loaded_session.refresh(event) + create_snapshot(loaded_session, event) - _, stats = get_quality_event(loaded_session, default_event.id) + _, stats = get_quality_event(loaded_session, event.id) assert stats.count == sum(select_flags) def test_includes_deselected_seismograms_when_present_in_snapshot( @@ -245,23 +245,23 @@ def test_includes_deselected_seismograms_when_present_in_snapshot( Args: loaded_session: The database session. """ - default_event = get_default_event(loaded_session) - assert default_event is not None + event = loaded_session.exec(select(AimbatEvent)).first() + assert event is not None - default_event.seismograms[0].parameters.select = False + event.seismograms[0].parameters.select = False loaded_session.commit() - seis_ids = [s.id for s in default_event.seismograms] - select_flags = [s.select for s in default_event.seismograms] + seis_ids = [s.id for s in event.seismograms] + select_flags = [s.select for s in event.seismograms] _write_mock_mccc_quality( loaded_session, - default_event.id, + event.id, seis_ids, select_flags, all_seismograms=True, ) - loaded_session.refresh(default_event) - create_snapshot(loaded_session, default_event) + loaded_session.refresh(event) + create_snapshot(loaded_session, event) - _, stats = get_quality_event(loaded_session, default_event.id) + _, stats = get_quality_event(loaded_session, event.id) assert stats.count == len(seis_ids) diff --git a/tests/integration/models/test_models.py b/tests/integration/models/test_models.py index 225c4102..1f11bb91 100644 --- a/tests/integration/models/test_models.py +++ b/tests/integration/models/test_models.py @@ -57,14 +57,12 @@ def _make_event( session: Session, *, time: str = "2010-02-27T06:34:14", - is_default: bool | None = None, ) -> AimbatEvent: """Insert and return an event together with its mandatory parameters. Args: session (Session): Database session. time (str): Event time string (default: "2010-02-27T06:34:14"). - is_default (bool | None): Whether the event is is_default (default: None). Returns: AimbatEvent: The created event. @@ -74,7 +72,6 @@ def _make_event( latitude=-36.12, longitude=-72.90, depth=22.9, - is_default=is_default, ) session.add(ev) session.flush() @@ -212,44 +209,6 @@ def test_delete_event_cascades_to_seismogram_parameters( assert patched_session.exec(select(AimbatSeismogramParameters)).first() is None - def test_delete_event_cascades_to_snapshots(self, patched_session: Session) -> None: - """Verifies that deleting an event deletes related snapshots and their parameter copies. - - Args: - patched_session (Session): Database session. - """ - ev = _make_event(patched_session, is_default=True) - sta = _make_station(patched_session) - _make_seismogram(patched_session, ev, sta) - patched_session.commit() - - # Create a snapshot via the core helper (uses the default event). - from aimbat.core import create_snapshot, get_default_event - - default_event = get_default_event(patched_session) - assert default_event is not None - create_snapshot(patched_session, default_event, comment="before delete") - assert len(patched_session.exec(select(AimbatSnapshot)).all()) == 1 - assert ( - len(patched_session.exec(select(AimbatEventParametersSnapshot)).all()) == 1 - ) - assert ( - len(patched_session.exec(select(AimbatSeismogramParametersSnapshot)).all()) - == 1 - ) - - patched_session.delete(ev) - patched_session.commit() - - assert len(patched_session.exec(select(AimbatSnapshot)).all()) == 0 - assert ( - len(patched_session.exec(select(AimbatEventParametersSnapshot)).all()) == 0 - ) - assert ( - len(patched_session.exec(select(AimbatSeismogramParametersSnapshot)).all()) - == 0 - ) - def test_delete_event_does_not_delete_station( self, patched_session: Session ) -> None: @@ -306,16 +265,14 @@ def test_delete_snapshot_cascades_to_parameter_snapshots( Args: patched_session (Session): Database session. """ - ev = _make_event(patched_session, is_default=True) + ev = _make_event(patched_session) sta = _make_station(patched_session) _make_seismogram(patched_session, ev, sta) patched_session.commit() - from aimbat.core import create_snapshot, get_default_event + from aimbat.core import create_snapshot - default_event = get_default_event(patched_session) - assert default_event is not None - create_snapshot(patched_session, default_event) + create_snapshot(patched_session, ev) snapshot = patched_session.exec(select(AimbatSnapshot)).one() patched_session.delete(snapshot) @@ -330,96 +287,6 @@ def test_delete_snapshot_cascades_to_parameter_snapshots( ) -# =================================================================== -# Single default event constraint -# =================================================================== - - -class TestSingleDefaultEvent: - """The DB trigger ensures at most one event has is_default=True.""" - - def test_only_one_default_event_via_insert(self, patched_session: Session) -> None: - """Inserting a new default event deactivates the previous one. - - Args: - patched_session (Session): Database session. - """ - ev1 = _make_event(patched_session, is_default=True) - patched_session.commit() - patched_session.refresh(ev1) - assert ev1.is_default is True - - ev2 = _make_event(patched_session, time="2011-03-11T05:46:24", is_default=True) - patched_session.commit() - - patched_session.refresh(ev1) - patched_session.refresh(ev2) - assert ev1.is_default is None - assert ev2.is_default is True - - def test_only_one_default_event_via_update(self, patched_session: Session) -> None: - """Updating an event to the default event replaces the previous one. - - Args: - patched_session (Session): Database session. - """ - ev1 = _make_event(patched_session, is_default=True) - ev2 = _make_event(patched_session, time="2011-03-11T05:46:24") - patched_session.commit() - - ev2.is_default = True - patched_session.add(ev2) - patched_session.commit() - - patched_session.refresh(ev1) - patched_session.refresh(ev2) - assert ev1.is_default is None - assert ev2.is_default is True - - def test_multiple_non_default_events_allowed( - self, patched_session: Session - ) -> None: - """Multiple events may exist without any being the default. - - Args: - patched_session (Session): Database session. - """ - _make_event(patched_session, time="2010-01-01T00:00:00") - _make_event(patched_session, time="2011-01-01T00:00:00") - _make_event(patched_session, time="2012-01-01T00:00:00") - patched_session.commit() - - is_default_events = patched_session.exec( - select(AimbatEvent).where(AimbatEvent.is_default == True) # noqa: E712 - ).all() - assert len(is_default_events) == 0 - - def test_cycling_default_through_three_events( - self, patched_session: Session - ) -> None: - """Verifies cycling default status through multiple events ensures only one is the default at a time. - - Args: - patched_session (Session): Database session. - """ - ev1 = _make_event(patched_session, time="2010-01-01T00:00:00", is_default=True) - ev2 = _make_event(patched_session, time="2011-01-01T00:00:00") - ev3 = _make_event(patched_session, time="2012-01-01T00:00:00") - patched_session.commit() - - for target in [ev2, ev3, ev1]: - target.is_default = True - patched_session.add(target) - patched_session.commit() - - is_default_events = patched_session.exec( - select(AimbatEvent).where(AimbatEvent.is_default == True) # noqa: E712 - ).all() - assert len(is_default_events) == 1 - patched_session.refresh(target) - assert target.is_default is True - - # =================================================================== # Type validation # =================================================================== diff --git a/tests/integration/models/test_operations.py b/tests/integration/models/test_operations.py index 71f9c3f3..38d45881 100644 --- a/tests/integration/models/test_operations.py +++ b/tests/integration/models/test_operations.py @@ -3,7 +3,6 @@ import pytest from sqlmodel import Session, select -from aimbat.core import get_default_event from aimbat.core._snapshot import create_snapshot from aimbat.models import ( AimbatDataSource, @@ -209,31 +208,31 @@ def test_seismogram_has_station(self, seismogram: AimbatSeismogram) -> None: class TestSnapshotRelationships: """Tests for navigating relationships on AimbatSnapshot.""" - def test_snapshot_has_event_parameters_snapshot(self, session: Session) -> None: + def test_snapshot_has_event_parameters_snapshot( + self, session: Session, event: AimbatEvent + ) -> None: """Verifies that a snapshot exposes its event parameter snapshot. Args: session: The database session. + event: An AimbatEvent instance. """ - default_event = get_default_event(session) - assert default_event is not None - create_snapshot(session, default_event) + create_snapshot(session, event) snapshot = session.exec(select(AimbatSnapshot)).one() assert isinstance( snapshot.event_parameters_snapshot, AimbatEventParametersSnapshot ) def test_snapshot_has_seismogram_parameter_snapshots( - self, session: Session + self, session: Session, event: AimbatEvent ) -> None: """Verifies that a snapshot exposes its seismogram parameter snapshots. Args: session: The database session. + event: An AimbatEvent instance. """ - default_event = get_default_event(session) - assert default_event is not None - create_snapshot(session, default_event) + create_snapshot(session, event) snapshot = session.exec(select(AimbatSnapshot)).one() assert len(snapshot.seismogram_parameters_snapshots) > 0 assert all( @@ -241,63 +240,67 @@ def test_snapshot_has_seismogram_parameter_snapshots( for s in snapshot.seismogram_parameters_snapshots ) - def test_snapshot_back_reference_to_event(self, session: Session) -> None: + def test_snapshot_back_reference_to_event( + self, session: Session, event: AimbatEvent + ) -> None: """Verifies that a snapshot links back to its parent event. Args: session: The database session. + event: An AimbatEvent instance. """ - default_event = get_default_event(session) - assert default_event is not None - create_snapshot(session, default_event) + create_snapshot(session, event) snapshot = session.exec(select(AimbatSnapshot)).one() assert isinstance(snapshot.event, AimbatEvent) - def test_snapshot_seismogram_count(self, session: Session) -> None: + def test_snapshot_seismogram_count( + self, session: Session, event: AimbatEvent + ) -> None: """Verifies that seismogram_count matches the number of seismogram parameter snapshots. Args: session: The database session. + event: An AimbatEvent instance. """ - default_event = get_default_event(session) - assert default_event is not None - create_snapshot(session, default_event) + create_snapshot(session, event) snapshot = session.exec(select(AimbatSnapshot)).one() session.refresh(snapshot) assert snapshot.seismogram_count == len( snapshot.seismogram_parameters_snapshots ) - def test_snapshot_selected_seismogram_count(self, session: Session) -> None: + def test_snapshot_selected_seismogram_count( + self, session: Session, event: AimbatEvent + ) -> None: """Verifies that selected_seismogram_count reflects snapshots marked as selected. Args: session: The database session. + event: An AimbatEvent instance. """ - default_event = get_default_event(session) - assert default_event is not None - create_snapshot(session, default_event) + create_snapshot(session, event) snapshot = session.exec(select(AimbatSnapshot)).one() session.refresh(snapshot) expected = sum(1 for s in snapshot.seismogram_parameters_snapshots if s.select) assert snapshot.selected_seismogram_count == expected - def test_snapshot_flipped_seismogram_count(self, session: Session) -> None: + def test_snapshot_flipped_seismogram_count( + self, session: Session, event: AimbatEvent + ) -> None: """Verifies that flipped_seismogram_count reflects snapshots marked as flipped. Args: session: The database session. + event: An AimbatEvent instance. """ - default_event = get_default_event(session) - assert default_event is not None - create_snapshot(session, default_event) + create_snapshot(session, event) snapshot = session.exec(select(AimbatSnapshot)).one() session.refresh(snapshot) expected = sum(1 for s in snapshot.seismogram_parameters_snapshots if s.flip) assert snapshot.flipped_seismogram_count == expected def test_snapshot_counts_reflect_toggled_flip_and_select( - self, session: Session + self, session: Session, event: AimbatEvent ) -> None: """Verifies snapshot counts reflect toggled flip and select on seismograms. @@ -307,10 +310,9 @@ def test_snapshot_counts_reflect_toggled_flip_and_select( Args: session: The database session. + event: An AimbatEvent instance. """ - default_event = get_default_event(session) - assert default_event is not None - seismograms = default_event.seismograms + seismograms = event.seismograms assert len(seismograms) >= 2 to_flip = seismograms[0] @@ -322,7 +324,7 @@ def test_snapshot_counts_reflect_toggled_flip_and_select( session.add(to_deselect.parameters) session.commit() - create_snapshot(session, default_event) + create_snapshot(session, event) snapshot = session.exec(select(AimbatSnapshot)).one() session.refresh(snapshot) @@ -511,9 +513,8 @@ def test_parameter_snapshots_deleted( session: The database session. seismogram: An AimbatSeismogram to delete. """ - default_event = get_default_event(session) - assert default_event is not None - create_snapshot(session, default_event) + event = seismogram.event + create_snapshot(session, event) parameters_id = seismogram.parameters.id session.delete(seismogram) @@ -527,15 +528,16 @@ def test_parameter_snapshots_deleted( class TestCascadeDeleteSnapshot: """Tests that deleting a snapshot cascades to all its dependants.""" - def test_event_parameters_snapshot_deleted(self, session: Session) -> None: + def test_event_parameters_snapshot_deleted( + self, session: Session, event: AimbatEvent + ) -> None: """Verifies that deleting a snapshot removes its event parameter snapshot. Args: session: The database session. + event: An AimbatEvent instance. """ - default_event = get_default_event(session) - assert default_event is not None - create_snapshot(session, default_event) + create_snapshot(session, event) snapshot = session.exec(select(AimbatSnapshot)).one() ep_snapshot_id = snapshot.event_parameters_snapshot.id @@ -544,15 +546,16 @@ def test_event_parameters_snapshot_deleted(self, session: Session) -> None: assert session.get(AimbatEventParametersSnapshot, ep_snapshot_id) is None - def test_seismogram_parameters_snapshots_deleted(self, session: Session) -> None: + def test_seismogram_parameters_snapshots_deleted( + self, session: Session, event: AimbatEvent + ) -> None: """Verifies that deleting a snapshot removes all its seismogram parameter snapshots. Args: session: The database session. + event: An AimbatEvent instance. """ - default_event = get_default_event(session) - assert default_event is not None - create_snapshot(session, default_event) + create_snapshot(session, event) snapshot = session.exec(select(AimbatSnapshot)).one() sp_snapshot_ids = [s.id for s in snapshot.seismogram_parameters_snapshots] assert len(sp_snapshot_ids) > 0 diff --git a/tests/unit/_cli/test_common.py b/tests/unit/_cli/common/test_parameters.py similarity index 78% rename from tests/unit/_cli/test_common.py rename to tests/unit/_cli/common/test_parameters.py index 6e650fae..9e000831 100644 --- a/tests/unit/_cli/test_common.py +++ b/tests/unit/_cli/common/test_parameters.py @@ -4,33 +4,12 @@ from aimbat import settings from aimbat._cli.common import ( - GlobalParameters, IccsPlotParameters, TableParameters, simple_exception, ) -class TestGlobalParameters: - """Tests for the GlobalParameters dataclass.""" - - def test_default_debug_is_false(self) -> None: - """Verifies that debug defaults to False.""" - params = GlobalParameters() - assert params.debug is False - - def test_debug_true_sets_log_level(self) -> None: - """Verifies that setting debug=True changes the log level to DEBUG.""" - GlobalParameters(debug=True) - assert settings.log_level == "DEBUG" - - def test_debug_false_does_not_change_log_level(self) -> None: - """Verifies that debug=False does not alter the log level.""" - original = settings.log_level - GlobalParameters(debug=False) - assert settings.log_level == original - - class TestIccsPlotParameters: """Tests for the IccsPlotParameters dataclass.""" @@ -58,15 +37,15 @@ def test_all_seismograms_can_be_set_true(self) -> None: class TestTableParameters: """Tests for the TableParameters dataclass.""" - def test_default_short_is_true(self) -> None: - """Verifies that short defaults to True.""" + def test_default_raw_is_false(self) -> None: + """Verifies that raw defaults to False.""" params = TableParameters() - assert params.short is True + assert params.raw is False - def test_short_can_be_set_false(self) -> None: - """Verifies that short can be set to False.""" - params = TableParameters(short=False) - assert params.short is False + def test_raw_can_be_set_true(self) -> None: + """Verifies that raw can be set to True.""" + params = TableParameters(raw=True) + assert params.raw is True class TestSimpleException: diff --git a/tests/unit/_cli/common/test_table.py b/tests/unit/_cli/common/test_table.py new file mode 100644 index 00000000..be744ce0 --- /dev/null +++ b/tests/unit/_cli/common/test_table.py @@ -0,0 +1,173 @@ +"""Unit tests for aimbat._cli.common._table.""" + +from datetime import datetime +from unittest.mock import MagicMock, patch + +from pandas import Timedelta, Timestamp +from pydantic import BaseModel, Field + +from aimbat._cli.common._table import json_to_table +from aimbat.models import RichColSpec + + +class MockModel(BaseModel): + """Simple model for testing.""" + + model_config = {"arbitrary_types_allowed": True} + + id: int = Field(title="ID") + name: str = Field(title="Name") + active: bool = Field(default=True, title="Is Active") + value: float | None = Field(default=None, title="Value") + timestamp: datetime | None = Field(default=None, title="Time") + duration: Timedelta | None = Field(default=None, title="Duration") + + +class RichMockModel(BaseModel): + """Model with RichColSpec for testing.""" + + model_config = {"arbitrary_types_allowed": True} + + id: int = Field( + json_schema_extra={ + "rich": RichColSpec(display_title="User ID", justify="right") # type: ignore[dict-item] + } + ) + name: str = Field( + json_schema_extra={"rich": RichColSpec(style="bold cyan")} # type: ignore[dict-item] + ) + score: float = Field( + json_schema_extra={"rich": RichColSpec(formatter=lambda x: f"{x:.1f} pts")} # type: ignore[dict-item] + ) + + +@patch("aimbat._cli.common._table.Console") +class TestJsonToTable: + """Tests for the json_to_table function.""" + + def test_dict_input_vertical_table(self, mock_console_cls: MagicMock) -> None: + """Verifies that dict input produces a vertical table.""" + mock_console = mock_console_cls.return_value + data = {"id": 1, "name": "Alice", "active": True} + + json_to_table(data, MockModel, title="User Info") + + # Check if table was created and printed + mock_console.print.assert_called_once() + table = mock_console.print.call_args[0][0] + assert table.title == "User Info" + assert len(table.columns) == 2 + assert table.columns[0].header == "Property" + assert table.columns[1].header == "Value" + + # Check rows โ€” MockModel defines id, name, active, value, timestamp, duration + # but only id, name, active are in data. + # json_to_table implementation iterates over field_names and skips if not in data. + # So we expect 3 rows. + assert table.row_count == 3 + + def test_list_input_horizontal_table(self, mock_console_cls: MagicMock) -> None: + """Verifies that list input produces a horizontal table.""" + mock_console = mock_console_cls.return_value + data = [ + {"id": 1, "name": "Alice", "active": True}, + {"id": 2, "name": "Bob", "active": False}, + ] + + json_to_table(data, MockModel) + + mock_console.print.assert_called_once() + table = mock_console.print.call_args[0][0] + # visible_fields should be id, name, active (those present in at least one row) + assert len(table.columns) == 3 + headers = [col.header for col in table.columns] + assert "ID" in headers + assert "Name" in headers + assert "Is Active" in headers + assert table.row_count == 2 + + def test_raw_mode_ignores_specs(self, mock_console_cls: MagicMock) -> None: + """Verifies that raw=True ignores RichColSpec and formatters.""" + mock_console = mock_console_cls.return_value + data = {"id": 1, "name": "Alice", "score": 95.5} + + # RichMockModel has score formatter returning "95.5 pts" + # In raw mode it should just be "95.5" (str(val)) + json_to_table(data, RichMockModel, raw=True) + + table = mock_console.print.call_args[0][0] + # In vertical table, values are in second column + # We need to find the "score" row. + # Row data is not easily accessible from rich.table.Table without private access + # but we can check column headers aren't overridden by RichColSpec + headers = [col.header for col in table.columns] + assert "User ID" not in headers # RichColSpec.display_title ignored + + def test_column_order(self, mock_console_cls: MagicMock) -> None: + """Verifies that column_order is respected.""" + mock_console = mock_console_cls.return_value + data = [{"id": 1, "name": "Alice", "active": True}] + + json_to_table(data, MockModel, column_order=["name", "id"]) + + table = mock_console.print.call_args[0][0] + headers = [col.header for col in table.columns] + assert headers[0] == "Name" + assert headers[1] == "ID" + assert headers[2] == "Is Active" + + def test_col_specs_overrides(self, mock_console_cls: MagicMock) -> None: + """Verifies that caller-supplied col_specs override model defaults.""" + mock_console = mock_console_cls.return_value + data = [{"id": 1, "name": "Alice"}] + + overrides = {"name": RichColSpec(display_title="Full Name", style="red")} + json_to_table(data, MockModel, col_specs=overrides) + + table = mock_console.print.call_args[0][0] + name_col = next(c for c in table.columns if c.header == "Full Name") + assert name_col.style == "red" + + def test_empty_data(self, mock_console_cls: MagicMock) -> None: + """Verifies that empty list doesn't crash.""" + mock_console = mock_console_cls.return_value + json_to_table([], MockModel) + mock_console.print.assert_called_once() + + def test_formatting_logic(self, mock_console_cls: MagicMock) -> None: + """Verifies various type-based formatters are used.""" + mock_console = mock_console_cls.return_value + data = { + "id": 1, + "active": True, + "value": 1.23456, + "duration": Timedelta(seconds=1.5), + "timestamp": Timestamp("2023-01-01 12:00:00"), + } + + json_to_table(data, MockModel) + + # We can't easily check the formatted strings inside the Table rows + # without mocking the _fmt_val or similar, but we can trust the logic + # if the code runs. For deeper testing we'd need to inspect table._rows + # which is list[Renderables]. + mock_console.print.assert_called_once() + + def test_justify_inference(self, mock_console_cls: MagicMock) -> None: + """Verifies that justification is inferred from type hints.""" + mock_console = mock_console_cls.return_value + data = [{"id": 1, "active": True, "value": 1.0, "name": "Alice"}] + + json_to_table(data, MockModel) + + table = mock_console.print.call_args[0][0] + cols = {c.header: c for c in table.columns} + + # ID is int -> right + assert cols["ID"].justify == "right" + # Is Active is bool -> center + assert cols["Is Active"].justify == "center" + # Value is float -> right + assert cols["Value"].justify == "right" + # Name is str -> "left" (Rich default) + assert cols["Name"].justify == "left" diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index 7dfbd533..dc2a24e5 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -27,7 +27,7 @@ def _capture_pretty(monkeypatch: pytest.MonkeyPatch) -> str: """ buffer = io.StringIO() console = Console(file=buffer, highlight=False, no_color=True, width=500) - monkeypatch.setattr("aimbat.utils._table.Console", lambda: console) + monkeypatch.setattr("aimbat._cli.common._table.Console", lambda: console) print_settings_table(pretty=True) return buffer.getvalue() diff --git a/tests/unit/utils/test_table.py b/tests/unit/utils/test_table.py deleted file mode 100644 index a78218a6..00000000 --- a/tests/unit/utils/test_table.py +++ /dev/null @@ -1,177 +0,0 @@ -"""Unit tests for aimbat.utils._table.""" - -import io -from datetime import datetime -from typing import Any - -import pytest -from pandas import NaT -from rich.console import Console - -from aimbat.utils._table import TableStyling, json_to_table - - -class TestTableStyling: - """Tests for the TableStyling class formatters.""" - - def test_flip_formatter(self) -> None: - """Verifies flip_formatter outputs.""" - res_true = TableStyling.flip_formatter(True) - assert ":up-down_arrow:" in res_true.text - assert res_true.justify == "center" - - res_false = TableStyling.flip_formatter(False) - assert res_false.text == "" - assert res_false.justify == "center" - - res_other = TableStyling.flip_formatter("maybe") - assert res_other.text == "maybe" - - def test_bool_formatter(self) -> None: - """Verifies bool_formatter outputs.""" - res_true = TableStyling.bool_formatter(True) - assert ":heavy_check_mark:" in res_true.text - assert res_true.justify == "center" - - res_false = TableStyling.bool_formatter(False) - assert ":heavy_multiplication_x:" in res_false.text - assert res_false.justify == "center" - - res_none = TableStyling.bool_formatter(None) - assert res_none.text == "" - assert res_none.justify == "center" - - def test_float_formatter(self) -> None: - """Verifies float_formatter outputs.""" - res_val = TableStyling.float_formatter(1.23456) - assert res_val.text == "1.235" - assert res_val.justify == "right" - - res_long = TableStyling.float_formatter(1.23456, short=False) - assert res_long.text == "1.23456" - - res_nan = TableStyling.float_formatter(float("nan")) - assert res_nan.text == "โ€”" - - res_none = TableStyling.float_formatter(None) - assert res_none.text == "โ€”" - - def test_timestamp_formatter(self) -> None: - """Verifies timestamp_formatter outputs.""" - dt = datetime(2023, 1, 1, 12, 0, 0) - res = TableStyling.timestamp_formatter(dt) - assert "2023-01-01" in res.text - assert "12:00:00" in res.text - assert res.style == "italic" - - res_str = TableStyling.timestamp_formatter("2023-01-01 12:00:00") - assert "2023-01-01" in res_str.text - - res_nat = TableStyling.timestamp_formatter(NaT) - assert res_nat.text == "โ€”" - - res_invalid = TableStyling.timestamp_formatter("not a date") - assert res_invalid.text == "not a date" - - def test_default_formatter(self) -> None: - """Verifies default_formatter outputs.""" - assert TableStyling.default_formatter("hello").text == "hello" - assert TableStyling.default_formatter(123).text == "123" - assert TableStyling.default_formatter(123).justify == "right" - assert TableStyling.default_formatter(None).text == "โ€”" - - -def _capture_json_to_table(data: Any, **kwargs: Any) -> str: - """Helper to capture json_to_table output.""" - buffer = io.StringIO() - # Use a large width to avoid wrapping issues in tests - console = Console(file=buffer, width=500, no_color=True, highlight=False) - - # We need to monkeypatch Console within the module because json_to_table - # instantiates its own Console() - with pytest.MonkeyPatch().context() as m: - m.setattr("aimbat.utils._table.Console", lambda: console) - json_to_table(data, **kwargs) - - return buffer.getvalue() - - -class TestJsonToTable: - """Tests for the json_to_table function.""" - - def test_dict_input(self) -> None: - """Verifies table rendering for a single dictionary.""" - data = {"a": 1, "b": "hello"} - output = _capture_json_to_table(data) - assert "Key" in output - assert "Value" in output - assert "a" in output - assert "1" in output - assert "b" in output - assert "hello" in output - - def test_list_input(self) -> None: - """Verifies table rendering for a list of dictionaries.""" - data = [{"id": 1, "name": "foo"}, {"id": 2, "name": "bar"}] - output = _capture_json_to_table(data) - assert "ID" in output - assert "name" in output - assert "1" in output - assert "foo" in output - assert "2" in output - assert "bar" in output - - def test_title(self) -> None: - """Verifies that the title is displayed.""" - output = _capture_json_to_table({"a": 1}, title="My Table") - assert "My Table" in output - - def test_skip_keys(self) -> None: - """Verifies that skip_keys are omitted.""" - data = {"a": 1, "b": 2} - output = _capture_json_to_table(data, skip_keys=["b"]) - assert "a" in output - assert "b" not in output - - def test_column_order(self) -> None: - """Verifies that column_order is respected.""" - # This is harder to test via string matching without fragile regex, - # but we can check if they both exist. - data = [{"a": 1, "b": 2}] - output = _capture_json_to_table(data, column_order=["b", "a"]) - assert "a" in output - assert "b" in output - - def test_custom_formatter(self) -> None: - """Verifies that custom formatters are used.""" - data = {"val": 10} - formatters = {"val": lambda x: f"Custom {x}"} - output = _capture_json_to_table(data, formatters=formatters) - assert "Custom 10" in output - - def test_styling_keys(self) -> None: - """Verifies that keys matching TableStyling fields get automatic styling.""" - # ID is a styling key in TableStyling - data = [{"ID": "some_id"}] - output = _capture_json_to_table(data) - assert "ID" in output - assert "some_id" in output - - def test_empty_list(self) -> None: - """Verifies behaviour with an empty list.""" - output = _capture_json_to_table([], title="Empty") - # json_to_table returns immediately if data is an empty list, - # but it DOES print the (empty) table if we look at the code. - # However, capture seems to get just a newline or empty. - assert "Empty" not in output # Based on observed failure - - def test_mixed_types_in_list(self) -> None: - """Verifies handling of mixed types and nulls in list data.""" - data = [{"a": 1, "b": True}, {"a": 2.5, "b": None}, {"a": "str", "b": False}] - output = _capture_json_to_table(data) - assert "1" in output - assert "2.500" in output - assert "str" in output - # bool_formatter uses emojis which might be stripped or represented differently - # in plain text capture, but check that SOMETHING is there or just no error - assert output diff --git a/uv.lock b/uv.lock index 53f1b6ef..dbfe0331 100644 --- a/uv.lock +++ b/uv.lock @@ -27,6 +27,7 @@ dependencies = [ { name = "sqlmodel" }, { name = "textual" }, { name = "textual-fspicker" }, + { name = "typing-extensions" }, ] [package.dev-dependencies] @@ -66,6 +67,7 @@ requires-dist = [ { name = "sqlmodel", specifier = ">=0.0.24" }, { name = "textual", specifier = ">=8.0.0" }, { name = "textual-fspicker", specifier = ">=1.0.0" }, + { name = "typing-extensions", specifier = ">=4.15.0" }, ] [package.metadata.requires-dev] @@ -352,86 +354,86 @@ wheels = [ [[package]] name = "coverage" -version = "7.13.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/24/56/95b7e30fa389756cb56630faa728da46a27b8c6eb46f9d557c68fff12b65/coverage-7.13.4.tar.gz", hash = "sha256:e5c8f6ed1e61a8b2dcdf31eb0b9bbf0130750ca79c1c49eb898e2ad86f5ccc91", size = 827239, upload-time = "2026-02-09T12:59:03.86Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/81/4ce2fdd909c5a0ed1f6dedb88aa57ab79b6d1fbd9b588c1ac7ef45659566/coverage-7.13.4-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:02231499b08dabbe2b96612993e5fc34217cdae907a51b906ac7fca8027a4459", size = 219449, upload-time = "2026-02-09T12:56:54.889Z" }, - { url = "https://files.pythonhosted.org/packages/5d/96/5238b1efc5922ddbdc9b0db9243152c09777804fb7c02ad1741eb18a11c0/coverage-7.13.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40aa8808140e55dc022b15d8aa7f651b6b3d68b365ea0398f1441e0b04d859c3", size = 219810, upload-time = "2026-02-09T12:56:56.33Z" }, - { url = "https://files.pythonhosted.org/packages/78/72/2f372b726d433c9c35e56377cf1d513b4c16fe51841060d826b95caacec1/coverage-7.13.4-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5b856a8ccf749480024ff3bd7310adaef57bf31fd17e1bfc404b7940b6986634", size = 251308, upload-time = "2026-02-09T12:56:57.858Z" }, - { url = "https://files.pythonhosted.org/packages/5d/a0/2ea570925524ef4e00bb6c82649f5682a77fac5ab910a65c9284de422600/coverage-7.13.4-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2c048ea43875fbf8b45d476ad79f179809c590ec7b79e2035c662e7afa3192e3", size = 254052, upload-time = "2026-02-09T12:56:59.754Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ac/45dc2e19a1939098d783c846e130b8f862fbb50d09e0af663988f2f21973/coverage-7.13.4-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b7b38448866e83176e28086674fe7368ab8590e4610fb662b44e345b86d63ffa", size = 255165, upload-time = "2026-02-09T12:57:01.287Z" }, - { url = "https://files.pythonhosted.org/packages/2d/4d/26d236ff35abc3b5e63540d3386e4c3b192168c1d96da5cb2f43c640970f/coverage-7.13.4-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:de6defc1c9badbf8b9e67ae90fd00519186d6ab64e5cc5f3d21359c2a9b2c1d3", size = 257432, upload-time = "2026-02-09T12:57:02.637Z" }, - { url = "https://files.pythonhosted.org/packages/ec/55/14a966c757d1348b2e19caf699415a2a4c4f7feaa4bbc6326a51f5c7dd1b/coverage-7.13.4-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7eda778067ad7ffccd23ecffce537dface96212576a07924cbf0d8799d2ded5a", size = 251716, upload-time = "2026-02-09T12:57:04.056Z" }, - { url = "https://files.pythonhosted.org/packages/77/33/50116647905837c66d28b2af1321b845d5f5d19be9655cb84d4a0ea806b4/coverage-7.13.4-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e87f6c587c3f34356c3759f0420693e35e7eb0e2e41e4c011cb6ec6ecbbf1db7", size = 253089, upload-time = "2026-02-09T12:57:05.503Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b4/8efb11a46e3665d92635a56e4f2d4529de6d33f2cb38afd47d779d15fc99/coverage-7.13.4-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:8248977c2e33aecb2ced42fef99f2d319e9904a36e55a8a68b69207fb7e43edc", size = 251232, upload-time = "2026-02-09T12:57:06.879Z" }, - { url = "https://files.pythonhosted.org/packages/51/24/8cd73dd399b812cc76bb0ac260e671c4163093441847ffe058ac9fda1e32/coverage-7.13.4-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:25381386e80ae727608e662474db537d4df1ecd42379b5ba33c84633a2b36d47", size = 255299, upload-time = "2026-02-09T12:57:08.245Z" }, - { url = "https://files.pythonhosted.org/packages/03/94/0a4b12f1d0e029ce1ccc1c800944a9984cbe7d678e470bb6d3c6bc38a0da/coverage-7.13.4-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:ee756f00726693e5ba94d6df2bdfd64d4852d23b09bb0bc700e3b30e6f333985", size = 250796, upload-time = "2026-02-09T12:57:10.142Z" }, - { url = "https://files.pythonhosted.org/packages/73/44/6002fbf88f6698ca034360ce474c406be6d5a985b3fdb3401128031eef6b/coverage-7.13.4-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fdfc1e28e7c7cdce44985b3043bc13bbd9c747520f94a4d7164af8260b3d91f0", size = 252673, upload-time = "2026-02-09T12:57:12.197Z" }, - { url = "https://files.pythonhosted.org/packages/de/c6/a0279f7c00e786be75a749a5674e6fa267bcbd8209cd10c9a450c655dfa7/coverage-7.13.4-cp312-cp312-win32.whl", hash = "sha256:01d4cbc3c283a17fc1e42d614a119f7f438eabb593391283adca8dc86eff1246", size = 221990, upload-time = "2026-02-09T12:57:14.085Z" }, - { url = "https://files.pythonhosted.org/packages/77/4e/c0a25a425fcf5557d9abd18419c95b63922e897bc86c1f327f155ef234a9/coverage-7.13.4-cp312-cp312-win_amd64.whl", hash = "sha256:9401ebc7ef522f01d01d45532c68c5ac40fb27113019b6b7d8b208f6e9baa126", size = 222800, upload-time = "2026-02-09T12:57:15.944Z" }, - { url = "https://files.pythonhosted.org/packages/47/ac/92da44ad9a6f4e3a7debd178949d6f3769bedca33830ce9b1dcdab589a37/coverage-7.13.4-cp312-cp312-win_arm64.whl", hash = "sha256:b1ec7b6b6e93255f952e27ab58fbc68dcc468844b16ecbee881aeb29b6ab4d8d", size = 221415, upload-time = "2026-02-09T12:57:17.497Z" }, - { url = "https://files.pythonhosted.org/packages/db/23/aad45061a31677d68e47499197a131eea55da4875d16c1f42021ab963503/coverage-7.13.4-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b66a2da594b6068b48b2692f043f35d4d3693fb639d5ea8b39533c2ad9ac3ab9", size = 219474, upload-time = "2026-02-09T12:57:19.332Z" }, - { url = "https://files.pythonhosted.org/packages/a5/70/9b8b67a0945f3dfec1fd896c5cefb7c19d5a3a6d74630b99a895170999ae/coverage-7.13.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3599eb3992d814d23b35c536c28df1a882caa950f8f507cef23d1cbf334995ac", size = 219844, upload-time = "2026-02-09T12:57:20.66Z" }, - { url = "https://files.pythonhosted.org/packages/97/fd/7e859f8fab324cef6c4ad7cff156ca7c489fef9179d5749b0c8d321281c2/coverage-7.13.4-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:93550784d9281e374fb5a12bf1324cc8a963fd63b2d2f223503ef0fd4aa339ea", size = 250832, upload-time = "2026-02-09T12:57:22.007Z" }, - { url = "https://files.pythonhosted.org/packages/e4/dc/b2442d10020c2f52617828862d8b6ee337859cd8f3a1f13d607dddda9cf7/coverage-7.13.4-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b720ce6a88a2755f7c697c23268ddc47a571b88052e6b155224347389fdf6a3b", size = 253434, upload-time = "2026-02-09T12:57:23.339Z" }, - { url = "https://files.pythonhosted.org/packages/5a/88/6728a7ad17428b18d836540630487231f5470fb82454871149502f5e5aa2/coverage-7.13.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7b322db1284a2ed3aa28ffd8ebe3db91c929b7a333c0820abec3d838ef5b3525", size = 254676, upload-time = "2026-02-09T12:57:24.774Z" }, - { url = "https://files.pythonhosted.org/packages/7c/bc/21244b1b8cedf0dff0a2b53b208015fe798d5f2a8d5348dbfece04224fff/coverage-7.13.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f4594c67d8a7c89cf922d9df0438c7c7bb022ad506eddb0fdb2863359ff78242", size = 256807, upload-time = "2026-02-09T12:57:26.125Z" }, - { url = "https://files.pythonhosted.org/packages/97/a0/ddba7ed3251cff51006737a727d84e05b61517d1784a9988a846ba508877/coverage-7.13.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53d133df809c743eb8bce33b24bcababb371f4441340578cd406e084d94a6148", size = 251058, upload-time = "2026-02-09T12:57:27.614Z" }, - { url = "https://files.pythonhosted.org/packages/9b/55/e289addf7ff54d3a540526f33751951bf0878f3809b47f6dfb3def69c6f7/coverage-7.13.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:76451d1978b95ba6507a039090ba076105c87cc76fc3efd5d35d72093964d49a", size = 252805, upload-time = "2026-02-09T12:57:29.066Z" }, - { url = "https://files.pythonhosted.org/packages/13/4e/cc276b1fa4a59be56d96f1dabddbdc30f4ba22e3b1cd42504c37b3313255/coverage-7.13.4-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:7f57b33491e281e962021de110b451ab8a24182589be17e12a22c79047935e23", size = 250766, upload-time = "2026-02-09T12:57:30.522Z" }, - { url = "https://files.pythonhosted.org/packages/94/44/1093b8f93018f8b41a8cf29636c9292502f05e4a113d4d107d14a3acd044/coverage-7.13.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1731dc33dc276dafc410a885cbf5992f1ff171393e48a21453b78727d090de80", size = 254923, upload-time = "2026-02-09T12:57:31.946Z" }, - { url = "https://files.pythonhosted.org/packages/8b/55/ea2796da2d42257f37dbea1aab239ba9263b31bd91d5527cdd6db5efe174/coverage-7.13.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:bd60d4fe2f6fa7dff9223ca1bbc9f05d2b6697bc5961072e5d3b952d46e1b1ea", size = 250591, upload-time = "2026-02-09T12:57:33.842Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fa/7c4bb72aacf8af5020675aa633e59c1fbe296d22aed191b6a5b711eb2bc7/coverage-7.13.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9181a3ccead280b828fae232df12b16652702b49d41e99d657f46cc7b1f6ec7a", size = 252364, upload-time = "2026-02-09T12:57:35.743Z" }, - { url = "https://files.pythonhosted.org/packages/5c/38/a8d2ec0146479c20bbaa7181b5b455a0c41101eed57f10dd19a78ab44c80/coverage-7.13.4-cp313-cp313-win32.whl", hash = "sha256:f53d492307962561ac7de4cd1de3e363589b000ab69617c6156a16ba7237998d", size = 222010, upload-time = "2026-02-09T12:57:37.25Z" }, - { url = "https://files.pythonhosted.org/packages/e2/0c/dbfafbe90a185943dcfbc766fe0e1909f658811492d79b741523a414a6cc/coverage-7.13.4-cp313-cp313-win_amd64.whl", hash = "sha256:e6f70dec1cc557e52df5306d051ef56003f74d56e9c4dd7ddb07e07ef32a84dd", size = 222818, upload-time = "2026-02-09T12:57:38.734Z" }, - { url = "https://files.pythonhosted.org/packages/04/d1/934918a138c932c90d78301f45f677fb05c39a3112b96fd2c8e60503cdc7/coverage-7.13.4-cp313-cp313-win_arm64.whl", hash = "sha256:fb07dc5da7e849e2ad31a5d74e9bece81f30ecf5a42909d0a695f8bd1874d6af", size = 221438, upload-time = "2026-02-09T12:57:40.223Z" }, - { url = "https://files.pythonhosted.org/packages/52/57/ee93ced533bcb3e6df961c0c6e42da2fc6addae53fb95b94a89b1e33ebd7/coverage-7.13.4-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:40d74da8e6c4b9ac18b15331c4b5ebc35a17069410cad462ad4f40dcd2d50c0d", size = 220165, upload-time = "2026-02-09T12:57:41.639Z" }, - { url = "https://files.pythonhosted.org/packages/c5/e0/969fc285a6fbdda49d91af278488d904dcd7651b2693872f0ff94e40e84a/coverage-7.13.4-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4223b4230a376138939a9173f1bdd6521994f2aff8047fae100d6d94d50c5a12", size = 220516, upload-time = "2026-02-09T12:57:44.215Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b8/9531944e16267e2735a30a9641ff49671f07e8138ecf1ca13db9fd2560c7/coverage-7.13.4-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1d4be36a5114c499f9f1f9195e95ebf979460dbe2d88e6816ea202010ba1c34b", size = 261804, upload-time = "2026-02-09T12:57:45.989Z" }, - { url = "https://files.pythonhosted.org/packages/8a/f3/e63df6d500314a2a60390d1989240d5f27318a7a68fa30ad3806e2a9323e/coverage-7.13.4-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:200dea7d1e8095cc6e98cdabe3fd1d21ab17d3cee6dab00cadbb2fe35d9c15b9", size = 263885, upload-time = "2026-02-09T12:57:47.42Z" }, - { url = "https://files.pythonhosted.org/packages/f3/67/7654810de580e14b37670b60a09c599fa348e48312db5b216d730857ffe6/coverage-7.13.4-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b8eb931ee8e6d8243e253e5ed7336deea6904369d2fd8ae6e43f68abbf167092", size = 266308, upload-time = "2026-02-09T12:57:49.345Z" }, - { url = "https://files.pythonhosted.org/packages/37/6f/39d41eca0eab3cc82115953ad41c4e77935286c930e8fad15eaed1389d83/coverage-7.13.4-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:75eab1ebe4f2f64d9509b984f9314d4aa788540368218b858dad56dc8f3e5eb9", size = 267452, upload-time = "2026-02-09T12:57:50.811Z" }, - { url = "https://files.pythonhosted.org/packages/50/6d/39c0fbb8fc5cd4d2090811e553c2108cf5112e882f82505ee7495349a6bf/coverage-7.13.4-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c35eb28c1d085eb7d8c9b3296567a1bebe03ce72962e932431b9a61f28facf26", size = 261057, upload-time = "2026-02-09T12:57:52.447Z" }, - { url = "https://files.pythonhosted.org/packages/a4/a2/60010c669df5fa603bb5a97fb75407e191a846510da70ac657eb696b7fce/coverage-7.13.4-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb88b316ec33760714a4720feb2816a3a59180fd58c1985012054fa7aebee4c2", size = 263875, upload-time = "2026-02-09T12:57:53.938Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d9/63b22a6bdbd17f1f96e9ed58604c2a6b0e72a9133e37d663bef185877cf6/coverage-7.13.4-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7d41eead3cc673cbd38a4417deb7fd0b4ca26954ff7dc6078e33f6ff97bed940", size = 261500, upload-time = "2026-02-09T12:57:56.012Z" }, - { url = "https://files.pythonhosted.org/packages/70/bf/69f86ba1ad85bc3ad240e4c0e57a2e620fbc0e1645a47b5c62f0e941ad7f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:fb26a934946a6afe0e326aebe0730cdff393a8bc0bbb65a2f41e30feddca399c", size = 265212, upload-time = "2026-02-09T12:57:57.5Z" }, - { url = "https://files.pythonhosted.org/packages/ae/f2/5f65a278a8c2148731831574c73e42f57204243d33bedaaf18fa79c5958f/coverage-7.13.4-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:dae88bc0fc77edaa65c14be099bd57ee140cf507e6bfdeea7938457ab387efb0", size = 260398, upload-time = "2026-02-09T12:57:59.027Z" }, - { url = "https://files.pythonhosted.org/packages/ef/80/6e8280a350ee9fea92f14b8357448a242dcaa243cb2c72ab0ca591f66c8c/coverage-7.13.4-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:845f352911777a8e722bfce168958214951e07e47e5d5d9744109fa5fe77f79b", size = 262584, upload-time = "2026-02-09T12:58:01.129Z" }, - { url = "https://files.pythonhosted.org/packages/22/63/01ff182fc95f260b539590fb12c11ad3e21332c15f9799cb5e2386f71d9f/coverage-7.13.4-cp313-cp313t-win32.whl", hash = "sha256:2fa8d5f8de70688a28240de9e139fa16b153cc3cbb01c5f16d88d6505ebdadf9", size = 222688, upload-time = "2026-02-09T12:58:02.736Z" }, - { url = "https://files.pythonhosted.org/packages/a9/43/89de4ef5d3cd53b886afa114065f7e9d3707bdb3e5efae13535b46ae483d/coverage-7.13.4-cp313-cp313t-win_amd64.whl", hash = "sha256:9351229c8c8407645840edcc277f4a2d44814d1bc34a2128c11c2a031d45a5dd", size = 223746, upload-time = "2026-02-09T12:58:05.362Z" }, - { url = "https://files.pythonhosted.org/packages/35/39/7cf0aa9a10d470a5309b38b289b9bb07ddeac5d61af9b664fe9775a4cb3e/coverage-7.13.4-cp313-cp313t-win_arm64.whl", hash = "sha256:30b8d0512f2dc8c8747557e8fb459d6176a2c9e5731e2b74d311c03b78451997", size = 222003, upload-time = "2026-02-09T12:58:06.952Z" }, - { url = "https://files.pythonhosted.org/packages/92/11/a9cf762bb83386467737d32187756a42094927150c3e107df4cb078e8590/coverage-7.13.4-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:300deaee342f90696ed186e3a00c71b5b3d27bffe9e827677954f4ee56969601", size = 219522, upload-time = "2026-02-09T12:58:08.623Z" }, - { url = "https://files.pythonhosted.org/packages/d3/28/56e6d892b7b052236d67c95f1936b6a7cf7c3e2634bf27610b8cbd7f9c60/coverage-7.13.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:29e3220258d682b6226a9b0925bc563ed9a1ebcff3cad30f043eceea7eaf2689", size = 219855, upload-time = "2026-02-09T12:58:10.176Z" }, - { url = "https://files.pythonhosted.org/packages/e5/69/233459ee9eb0c0d10fcc2fe425a029b3fa5ce0f040c966ebce851d030c70/coverage-7.13.4-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:391ee8f19bef69210978363ca930f7328081c6a0152f1166c91f0b5fdd2a773c", size = 250887, upload-time = "2026-02-09T12:58:12.503Z" }, - { url = "https://files.pythonhosted.org/packages/06/90/2cdab0974b9b5bbc1623f7876b73603aecac11b8d95b85b5b86b32de5eab/coverage-7.13.4-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0dd7ab8278f0d58a0128ba2fca25824321f05d059c1441800e934ff2efa52129", size = 253396, upload-time = "2026-02-09T12:58:14.615Z" }, - { url = "https://files.pythonhosted.org/packages/ac/15/ea4da0f85bf7d7b27635039e649e99deb8173fe551096ea15017f7053537/coverage-7.13.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:78cdf0d578b15148b009ccf18c686aa4f719d887e76e6b40c38ffb61d264a552", size = 254745, upload-time = "2026-02-09T12:58:16.162Z" }, - { url = "https://files.pythonhosted.org/packages/99/11/bb356e86920c655ca4d61daee4e2bbc7258f0a37de0be32d233b561134ff/coverage-7.13.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:48685fee12c2eb3b27c62f2658e7ea21e9c3239cba5a8a242801a0a3f6a8c62a", size = 257055, upload-time = "2026-02-09T12:58:17.892Z" }, - { url = "https://files.pythonhosted.org/packages/c9/0f/9ae1f8cb17029e09da06ca4e28c9e1d5c1c0a511c7074592e37e0836c915/coverage-7.13.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4e83efc079eb39480e6346a15a1bcb3e9b04759c5202d157e1dd4303cd619356", size = 250911, upload-time = "2026-02-09T12:58:19.495Z" }, - { url = "https://files.pythonhosted.org/packages/89/3a/adfb68558fa815cbc29747b553bc833d2150228f251b127f1ce97e48547c/coverage-7.13.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ecae9737b72408d6a950f7e525f30aca12d4bd8dd95e37342e5beb3a2a8c4f71", size = 252754, upload-time = "2026-02-09T12:58:21.064Z" }, - { url = "https://files.pythonhosted.org/packages/32/b1/540d0c27c4e748bd3cd0bd001076ee416eda993c2bae47a73b7cc9357931/coverage-7.13.4-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ae4578f8528569d3cf303fef2ea569c7f4c4059a38c8667ccef15c6e1f118aa5", size = 250720, upload-time = "2026-02-09T12:58:22.622Z" }, - { url = "https://files.pythonhosted.org/packages/c7/95/383609462b3ffb1fe133014a7c84fc0dd01ed55ac6140fa1093b5af7ebb1/coverage-7.13.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:6fdef321fdfbb30a197efa02d48fcd9981f0d8ad2ae8903ac318adc653f5df98", size = 254994, upload-time = "2026-02-09T12:58:24.548Z" }, - { url = "https://files.pythonhosted.org/packages/f7/ba/1761138e86c81680bfc3c49579d66312865457f9fe405b033184e5793cb3/coverage-7.13.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b0f6ccf3dbe577170bebfce1318707d0e8c3650003cb4b3a9dd744575daa8b5", size = 250531, upload-time = "2026-02-09T12:58:26.271Z" }, - { url = "https://files.pythonhosted.org/packages/f8/8e/05900df797a9c11837ab59c4d6fe94094e029582aab75c3309a93e6fb4e3/coverage-7.13.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75fcd519f2a5765db3f0e391eb3b7d150cce1a771bf4c9f861aeab86c767a3c0", size = 252189, upload-time = "2026-02-09T12:58:27.807Z" }, - { url = "https://files.pythonhosted.org/packages/00/bd/29c9f2db9ea4ed2738b8a9508c35626eb205d51af4ab7bf56a21a2e49926/coverage-7.13.4-cp314-cp314-win32.whl", hash = "sha256:8e798c266c378da2bd819b0677df41ab46d78065fb2a399558f3f6cae78b2fbb", size = 222258, upload-time = "2026-02-09T12:58:29.441Z" }, - { url = "https://files.pythonhosted.org/packages/a7/4d/1f8e723f6829977410efeb88f73673d794075091c8c7c18848d273dc9d73/coverage-7.13.4-cp314-cp314-win_amd64.whl", hash = "sha256:245e37f664d89861cf2329c9afa2c1fe9e6d4e1a09d872c947e70718aeeac505", size = 223073, upload-time = "2026-02-09T12:58:31.026Z" }, - { url = "https://files.pythonhosted.org/packages/51/5b/84100025be913b44e082ea32abcf1afbf4e872f5120b7a1cab1d331b1e13/coverage-7.13.4-cp314-cp314-win_arm64.whl", hash = "sha256:ad27098a189e5838900ce4c2a99f2fe42a0bf0c2093c17c69b45a71579e8d4a2", size = 221638, upload-time = "2026-02-09T12:58:32.599Z" }, - { url = "https://files.pythonhosted.org/packages/a7/e4/c884a405d6ead1370433dad1e3720216b4f9fd8ef5b64bfd984a2a60a11a/coverage-7.13.4-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:85480adfb35ffc32d40918aad81b89c69c9cc5661a9b8a81476d3e645321a056", size = 220246, upload-time = "2026-02-09T12:58:34.181Z" }, - { url = "https://files.pythonhosted.org/packages/81/5c/4d7ed8b23b233b0fffbc9dfec53c232be2e695468523242ea9fd30f97ad2/coverage-7.13.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:79be69cf7f3bf9b0deeeb062eab7ac7f36cd4cc4c4dd694bd28921ba4d8596cc", size = 220514, upload-time = "2026-02-09T12:58:35.704Z" }, - { url = "https://files.pythonhosted.org/packages/2f/6f/3284d4203fd2f28edd73034968398cd2d4cb04ab192abc8cff007ea35679/coverage-7.13.4-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:caa421e2684e382c5d8973ac55e4f36bed6821a9bad5c953494de960c74595c9", size = 261877, upload-time = "2026-02-09T12:58:37.864Z" }, - { url = "https://files.pythonhosted.org/packages/09/aa/b672a647bbe1556a85337dc95bfd40d146e9965ead9cc2fe81bde1e5cbce/coverage-7.13.4-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:14375934243ee05f56c45393fe2ce81fe5cc503c07cee2bdf1725fb8bef3ffaf", size = 264004, upload-time = "2026-02-09T12:58:39.492Z" }, - { url = "https://files.pythonhosted.org/packages/79/a1/aa384dbe9181f98bba87dd23dda436f0c6cf2e148aecbb4e50fc51c1a656/coverage-7.13.4-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25a41c3104d08edb094d9db0d905ca54d0cd41c928bb6be3c4c799a54753af55", size = 266408, upload-time = "2026-02-09T12:58:41.852Z" }, - { url = "https://files.pythonhosted.org/packages/53/5e/5150bf17b4019bc600799f376bb9606941e55bd5a775dc1e096b6ffea952/coverage-7.13.4-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6f01afcff62bf9a08fb32b2c1d6e924236c0383c02c790732b6537269e466a72", size = 267544, upload-time = "2026-02-09T12:58:44.093Z" }, - { url = "https://files.pythonhosted.org/packages/e0/ed/f1de5c675987a4a7a672250d2c5c9d73d289dbf13410f00ed7181d8017dd/coverage-7.13.4-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:eb9078108fbf0bcdde37c3f4779303673c2fa1fe8f7956e68d447d0dd426d38a", size = 260980, upload-time = "2026-02-09T12:58:45.721Z" }, - { url = "https://files.pythonhosted.org/packages/b3/e3/fe758d01850aa172419a6743fe76ba8b92c29d181d4f676ffe2dae2ba631/coverage-7.13.4-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:0e086334e8537ddd17e5f16a344777c1ab8194986ec533711cbe6c41cde841b6", size = 263871, upload-time = "2026-02-09T12:58:47.334Z" }, - { url = "https://files.pythonhosted.org/packages/b6/76/b829869d464115e22499541def9796b25312b8cf235d3bb00b39f1675395/coverage-7.13.4-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:725d985c5ab621268b2edb8e50dfe57633dc69bda071abc470fed55a14935fd3", size = 261472, upload-time = "2026-02-09T12:58:48.995Z" }, - { url = "https://files.pythonhosted.org/packages/14/9e/caedb1679e73e2f6ad240173f55218488bfe043e38da577c4ec977489915/coverage-7.13.4-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:3c06f0f1337c667b971ca2f975523347e63ec5e500b9aa5882d91931cd3ef750", size = 265210, upload-time = "2026-02-09T12:58:51.178Z" }, - { url = "https://files.pythonhosted.org/packages/3a/10/0dd02cb009b16ede425b49ec344aba13a6ae1dc39600840ea6abcb085ac4/coverage-7.13.4-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:590c0ed4bf8e85f745e6b805b2e1c457b2e33d5255dd9729743165253bc9ad39", size = 260319, upload-time = "2026-02-09T12:58:53.081Z" }, - { url = "https://files.pythonhosted.org/packages/92/8e/234d2c927af27c6d7a5ffad5bd2cf31634c46a477b4c7adfbfa66baf7ebb/coverage-7.13.4-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:eb30bf180de3f632cd043322dad5751390e5385108b2807368997d1a92a509d0", size = 262638, upload-time = "2026-02-09T12:58:55.258Z" }, - { url = "https://files.pythonhosted.org/packages/2f/64/e5547c8ff6964e5965c35a480855911b61509cce544f4d442caa759a0702/coverage-7.13.4-cp314-cp314t-win32.whl", hash = "sha256:c4240e7eded42d131a2d2c4dec70374b781b043ddc79a9de4d55ca71f8e98aea", size = 223040, upload-time = "2026-02-09T12:58:56.936Z" }, - { url = "https://files.pythonhosted.org/packages/c7/96/38086d58a181aac86d503dfa9c47eb20715a79c3e3acbdf786e92e5c09a8/coverage-7.13.4-cp314-cp314t-win_amd64.whl", hash = "sha256:4c7d3cc01e7350f2f0f6f7036caaf5673fb56b6998889ccfe9e1c1fe75a9c932", size = 224148, upload-time = "2026-02-09T12:58:58.645Z" }, - { url = "https://files.pythonhosted.org/packages/ce/72/8d10abd3740a0beb98c305e0c3faf454366221c0f37a8bcf8f60020bb65a/coverage-7.13.4-cp314-cp314t-win_arm64.whl", hash = "sha256:23e3f687cf945070d1c90f85db66d11e3025665d8dafa831301a0e0038f3db9b", size = 222172, upload-time = "2026-02-09T12:59:00.396Z" }, - { url = "https://files.pythonhosted.org/packages/0d/4a/331fe2caf6799d591109bb9c08083080f6de90a823695d412a935622abb2/coverage-7.13.4-py3-none-any.whl", hash = "sha256:1af1641e57cf7ba1bd67d677c9abdbcd6cc2ab7da3bca7fa1e2b7e50e65f2ad0", size = 211242, upload-time = "2026-02-09T12:59:02.032Z" }, +version = "7.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, + { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, + { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, + { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, + { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, + { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, + { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, + { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, + { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, + { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, + { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, + { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, + { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, + { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, + { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, + { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, + { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, + { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, + { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, + { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, + { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, + { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, + { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, + { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, + { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, + { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, + { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, + { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, + { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, + { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, + { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, + { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, + { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, + { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, + { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, + { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, + { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, + { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, + { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, + { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, + { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, + { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, + { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, + { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, + { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, + { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, + { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, + { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, + { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, + { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, + { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, + { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, + { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, + { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, + { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, + { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, + { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, + { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, + { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, + { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, + { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, + { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, + { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, + { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, + { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, ] [[package]] @@ -1221,14 +1223,14 @@ wheels = [ [[package]] name = "mplcursors" -version = "0.7" +version = "0.7.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "matplotlib" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/7c/4b/777032b40e5e41cd0c8dc1b4a9d4d760aacbb6f0aaec0bb8b5889bc88945/mplcursors-0.7.tar.gz", hash = "sha256:a4dda539931ff961498f98bd35386bf345954c6235849eb8981728d479b099a9", size = 88940, upload-time = "2025-09-02T09:14:59.385Z" } +sdist = { url = "https://files.pythonhosted.org/packages/76/e1/434939d426b967e86312f9b53913a96a0ec195e42392d9fdfcb20a2298cd/mplcursors-0.7.1.tar.gz", hash = "sha256:b567d7380415f4c7c2c23825809be6fa597ff32f2f5299886b14a473e46da7dd", size = 89014, upload-time = "2026-03-18T19:23:01.228Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/19/e6/35e28d41f24c8ad071385709a7cbb983344387a92900e7b8a9b70b5e5599/mplcursors-0.7-py3-none-any.whl", hash = "sha256:140102df515e275f4df6c80c6dde4d22188c5fc30f38133444fa799a4716bd3f", size = 20556, upload-time = "2025-09-02T09:14:57.772Z" }, + { url = "https://files.pythonhosted.org/packages/e9/4b/3a89414c611e6effbf4666d2102737a310cc03c8a38aab36f4ad24de87f5/mplcursors-0.7.1-py3-none-any.whl", hash = "sha256:eb38f23c90d15c37693a1db1ab64e9b81d1939aabc5a519e1261613ab488508f", size = 20595, upload-time = "2026-03-18T19:22:59.879Z" }, ] [[package]] From 2e50dae6265a293e76e23f1f03a51b1adcaab807 Mon Sep 17 00:00:00 2001 From: Simon Lloyd Date: Thu, 19 Mar 2026 11:15:14 +0000 Subject: [PATCH 2/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/aimbat/_tui/app.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aimbat/_tui/app.py b/src/aimbat/_tui/app.py index 74794eb7..a9d5b98c 100644 --- a/src/aimbat/_tui/app.py +++ b/src/aimbat/_tui/app.py @@ -123,7 +123,7 @@ ], } -_EVENT_TABLE_EXCLUDE: set[str] = {""} +_EVENT_TABLE_EXCLUDE: set[str] = set() _STATION_TABLE_EXCLUDE: set[str] = {"event_count"} _SEISMOGRAM_TABLE_EXCLUDE: set[str] = {"event_id", "short_event_id"} _SNAPSHOT_TABLE_EXCLUDE: set[str] = {"event_id", "short_event_id"} From 871946af1129f48620ab395e281e5dd0eaaf3c01 Mon Sep 17 00:00:00 2001 From: Simon Lloyd Date: Thu, 19 Mar 2026 11:16:33 +0000 Subject: [PATCH 3/6] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/aimbat/_tui/_format.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/aimbat/_tui/_format.py b/src/aimbat/_tui/_format.py index 23847c54..265483ed 100644 --- a/src/aimbat/_tui/_format.py +++ b/src/aimbat/_tui/_format.py @@ -137,10 +137,10 @@ def tui_cell(model: type[BaseModel], title: str, val: object) -> str | Text: def tui_fmt(val: object) -> str: """Format a raw field value for display in a Textual DataTable cell. - Applies generic type-based rules (bool as โœ“/โœ—, float to 3 d.p., ISO - timestamp truncation) before falling back to `str`. Field-specific - formatting should be handled via `TuiColSpec.formatter` instead. - Returns `โ€”` for `None`.""" + Applies generic type-based rules (bool via ``fmt_bool``, float via + ``fmt_float``, ISO timestamp truncation) before falling back to ``str``. + Field-specific formatting should be handled via ``TuiColSpec.formatter`` + instead. Returns ``โ€”`` for ``None``.""" if val is None: return "โ€”" if isinstance(val, bool): From fe399effa83e94c2191837df477e11f748444af4 Mon Sep 17 00:00:00 2001 From: Simon Lloyd Date: Thu, 19 Mar 2026 11:26:47 +0000 Subject: [PATCH 4/6] add quotes for python <=3.14 --- src/aimbat/_cli/data.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/aimbat/_cli/data.py b/src/aimbat/_cli/data.py index f510741a..e0f66ad7 100644 --- a/src/aimbat/_cli/data.py +++ b/src/aimbat/_cli/data.py @@ -55,7 +55,7 @@ def _print_dry_run_results( - added_datasources: Sequence[AimbatDataSource], + added_datasources: Sequence["AimbatDataSource"], existing_station_ids: set, existing_event_ids: set, existing_seismogram_ids: set, From 57eef46eea033301a56d8a655df7a34742064b31 Mon Sep 17 00:00:00 2001 From: Simon Lloyd Date: Thu, 19 Mar 2026 11:29:35 +0000 Subject: [PATCH 5/6] _MISSING_MARKER string --- src/aimbat/_cli/common/_parameters.py | 5 +++-- src/aimbat/_cli/common/_table.py | 4 +++- src/aimbat/_cli/data.py | 4 +++- src/aimbat/utils/formatters.py | 14 ++++++++------ 4 files changed, 17 insertions(+), 10 deletions(-) diff --git a/src/aimbat/_cli/common/_parameters.py b/src/aimbat/_cli/common/_parameters.py index 1f5d615a..1e128efd 100644 --- a/src/aimbat/_cli/common/_parameters.py +++ b/src/aimbat/_cli/common/_parameters.py @@ -1,5 +1,6 @@ """Common parameters and functions for the AIMBAT CLI.""" +import sys from collections.abc import Callable from dataclasses import dataclass from typing import Annotated, Literal, overload @@ -9,9 +10,9 @@ from aimbat import settings -try: +if sys.version_info >= (3, 13): from typing import TypeIs -except ImportError: +else: from typing_extensions import TypeIs __all__ = [ diff --git a/src/aimbat/_cli/common/_table.py b/src/aimbat/_cli/common/_table.py index b6f80638..7b4a9873 100644 --- a/src/aimbat/_cli/common/_table.py +++ b/src/aimbat/_cli/common/_table.py @@ -19,6 +19,8 @@ __all__ = ["json_to_table"] +_MISSING_MARKER = " โ€” " + def _justify_for_annotation(annotation: Any) -> str | None: """Infer a default column justification from a field's type annotation. @@ -120,7 +122,7 @@ def _fmt_val(name: str, val: Any) -> str: if raw: return "" if val is None else str(val) if val is None or val is NaT: - return "โ€”" + return _MISSING_MARKER spec = specs.get(name) if spec and spec.formatter: return spec.formatter(val) diff --git a/src/aimbat/_cli/data.py b/src/aimbat/_cli/data.py index e0f66ad7..e3d57bf8 100644 --- a/src/aimbat/_cli/data.py +++ b/src/aimbat/_cli/data.py @@ -27,6 +27,8 @@ records are reused rather than duplicated. """ +from __future__ import annotations + import uuid from collections.abc import Sequence from pathlib import Path @@ -55,7 +57,7 @@ def _print_dry_run_results( - added_datasources: Sequence["AimbatDataSource"], + added_datasources: Sequence[AimbatDataSource], existing_station_ids: set, existing_event_ids: set, existing_seismogram_ids: set, diff --git a/src/aimbat/utils/formatters.py b/src/aimbat/utils/formatters.py index 1c68f8db..a365e2c0 100644 --- a/src/aimbat/utils/formatters.py +++ b/src/aimbat/utils/formatters.py @@ -14,6 +14,8 @@ "fmt_timestamp", ] +_MISSING_MARKER = " โ€” " + type Formatter[T] = Callable[[T], str] @@ -30,32 +32,32 @@ def fmt_bool(val: bool | object) -> str: def fmt_float(val: float | object) -> str: - """Format a float to 3 decimal places, or `โ€”` for None/NaN.""" + """Format a float to 3 decimal places, or ` โ€” ` for None/NaN.""" if val is None or (isinstance(val, float) and math.isnan(val)): - return "โ€” " + return _MISSING_MARKER if isinstance(val, float): return f"{val:.3f}" return str(val) def fmt_timestamp(val: Any) -> str: - """Format a timestamp as `YYYY-MM-DD HH:MM:SS`, or `โ€”` for missing values.""" + """Format a timestamp as `YYYY-MM-DD HH:MM:SS`, or ` โ€” ` for missing values.""" if isinstance(val, str) and val.strip(): try: val = to_datetime(val) except (ValueError, TypeError): return str(val) if val is None or val is NaT or val == "": - return "โ€” " + return _MISSING_MARKER if hasattr(val, "strftime"): return val.strftime("%Y-%m-%d %H:%M:%S") return str(val) def fmt_timedelta(val: Timedelta | object) -> str: - """Format a Timedelta as total seconds to 5 decimal places, or `โ€”` for None.""" + """Format a Timedelta as total seconds to 5 decimal places, or ` โ€” ` for None.""" if val is None: - return "โ€” " + return _MISSING_MARKER if isinstance(val, Timedelta): return f"{val.total_seconds():.5f} s" return str(val) From 149b0e97f6376fe04d11294e8c7a3462188a6cc3 Mon Sep 17 00:00:00 2001 From: Simon Lloyd Date: Thu, 19 Mar 2026 12:08:30 +0000 Subject: [PATCH 6/6] Add overload --- src/aimbat/_cli/data.py | 33 +++++++++++++++++++++------------ src/aimbat/core/_data.py | 27 ++++++++++++++++++++++++++- 2 files changed, 47 insertions(+), 13 deletions(-) diff --git a/src/aimbat/_cli/data.py b/src/aimbat/_cli/data.py index e3d57bf8..ee77a8ec 100644 --- a/src/aimbat/_cli/data.py +++ b/src/aimbat/_cli/data.py @@ -166,18 +166,27 @@ def cli_data_add( disable_progress_bar = not show_progress_bar with Session(engine) as session: - results = add_data_to_project( - session, - data_sources, - data_type, - station_id=station_id, - event_id=event_id, - dry_run=dry_run, - disable_progress_bar=disable_progress_bar, - ) - if results is not None: - if dry_run: - _print_dry_run_results(*results) + if dry_run: + results = add_data_to_project( + session, + data_sources, + data_type, + station_id=station_id, + event_id=event_id, + dry_run=True, + disable_progress_bar=disable_progress_bar, + ) + _print_dry_run_results(*results) + else: + add_data_to_project( + session, + data_sources, + data_type, + station_id=station_id, + event_id=event_id, + dry_run=False, + disable_progress_bar=disable_progress_bar, + ) @app.command(name="dump") diff --git a/src/aimbat/core/_data.py b/src/aimbat/core/_data.py index 156301a8..f017c97c 100644 --- a/src/aimbat/core/_data.py +++ b/src/aimbat/core/_data.py @@ -1,6 +1,6 @@ import os from collections.abc import Sequence -from typing import Any +from typing import Any, Literal, overload from uuid import UUID from pydantic import TypeAdapter @@ -187,6 +187,31 @@ def _process_datasource( return aimbat_data_source +@overload +def add_data_to_project( + session: Session, + data_sources: Sequence[os.PathLike | str], + data_type: DataType, + station_id: UUID | None = ..., + event_id: UUID | None = ..., + dry_run: Literal[False] = ..., + disable_progress_bar: bool = ..., +) -> None: ... + + +@overload +def add_data_to_project( + session: Session, + data_sources: Sequence[os.PathLike | str], + data_type: DataType, + station_id: UUID | None = ..., + event_id: UUID | None = ..., + *, + dry_run: Literal[True], + disable_progress_bar: bool = ..., +) -> tuple[list[AimbatDataSource], set[UUID], set[UUID], set[UUID]]: ... + + def add_data_to_project( session: Session, data_sources: Sequence[os.PathLike | str],