# Author: LKouadio <etanoyau@gmail.com>
# License: LGPL-3.0
"""
pycsamt.api.cli.params
======================
Custom Click parameter types for pyCSAMT-specific inputs.
Use these as the ``type=`` argument on ``@click.argument`` or
``@click.option`` to get automatic validation and helpful error messages.
Examples
--------
::
import click
from pycsamt.api.cli.params import EDIPath, FreqRange, StationList
@click.command()
@click.argument("edi", type=EDIPath())
@click.option("--freq", type=FreqRange(), default=None)
@click.option("--stations", type=StationList(), default=None)
def my_command(edi, freq, stations):
...
"""
from __future__ import annotations
from pathlib import Path
from typing import Any
import click
# ---------------------------------------------------------------------------
# EDIPath — single EDI file, must exist and have .edi extension
# ---------------------------------------------------------------------------
[docs]
class EDIPath(click.Path):
"""A Click Path type that additionally requires a ``.edi`` extension."""
name = "EDI_FILE"
def __init__(self) -> None:
super().__init__(
exists=True,
file_okay=True,
dir_okay=False,
readable=True,
path_type=Path,
)
[docs]
def convert(
self,
value: Any,
param: click.Parameter | None,
ctx: click.Context | None,
) -> Path:
path: Path = super().convert(value, param, ctx)
if path.suffix.lower() != ".edi":
self.fail(
f"{path.name!r} does not have a .edi extension.",
param,
ctx,
)
return path
# ---------------------------------------------------------------------------
# EDIDir — directory that contains at least one .edi file
# ---------------------------------------------------------------------------
[docs]
class EDIDir(click.Path):
"""A Click Path type for a directory that contains EDI files."""
name = "EDI_DIR"
def __init__(self) -> None:
super().__init__(
exists=True,
file_okay=False,
dir_okay=True,
readable=True,
path_type=Path,
)
[docs]
def convert(
self,
value: Any,
param: click.Parameter | None,
ctx: click.Context | None,
) -> Path:
path: Path = super().convert(value, param, ctx)
edi_files = list(path.glob("*.edi"))
if not edi_files:
self.fail(
f"Directory {str(path)!r} contains no .edi files.",
param,
ctx,
)
return path
# ---------------------------------------------------------------------------
# FreqRange — "fmin:fmax" string → (float, float)
# ---------------------------------------------------------------------------
[docs]
class FreqRange(click.ParamType):
"""Parse a ``fmin:fmax`` frequency-range string into ``(float, float)``.
Examples
--------
``--freq 0.1:1000`` → ``(0.1, 1000.0)``
"""
name = "FREQ_RANGE"
[docs]
def convert(
self,
value: Any,
param: click.Parameter | None,
ctx: click.Context | None,
) -> tuple[float, float]:
if isinstance(value, tuple):
return value
try:
lo_str, hi_str = str(value).split(":")
lo, hi = float(lo_str), float(hi_str)
except (ValueError, TypeError):
self.fail(
f"{value!r} is not a valid frequency range. "
"Expected format: fmin:fmax (e.g. 0.1:1000)",
param,
ctx,
)
if lo >= hi:
self.fail(
f"fmin ({lo}) must be less than fmax ({hi}).",
param,
ctx,
)
return (lo, hi)
# ---------------------------------------------------------------------------
# StationList — comma-separated station ids → list[str]
# ---------------------------------------------------------------------------
[docs]
class StationList(click.ParamType):
"""Parse a comma-separated list of station identifiers.
Examples
--------
``--stations S01,S02,S03`` → ``['S01', 'S02', 'S03']``
"""
name = "STATION_LIST"
[docs]
def convert(
self,
value: Any,
param: click.Parameter | None,
ctx: click.Context | None,
) -> list[str]:
if isinstance(value, list):
return value
parts = [s.strip() for s in str(value).split(",") if s.strip()]
if not parts:
self.fail(
f"{value!r} is not a valid station list. "
"Provide comma-separated station IDs, e.g. S01,S02,S03",
param,
ctx,
)
return parts
# ---------------------------------------------------------------------------
# PipeStepList — comma-separated step codes/names → list[str] of codes
# ---------------------------------------------------------------------------
[docs]
class PipeStepList(click.ParamType):
"""Parse a comma-separated list of pipeline step codes or names.
Each token is validated against the step registry, so the user gets
an immediate error with a hint if they mistype a code.
Examples
--------
``--steps NR001,FREQ002,FREQ001,FREQ004,SS001``
→ ``['NR001', 'FREQ002', 'FREQ001', 'FREQ004', 'SS001']``
``--steps notch_powerline,drop_duplicates,align_grid``
→ ``['NR001', 'FREQ002', 'FREQ004']``
"""
name = "STEP_LIST"
[docs]
def convert(
self,
value: Any,
param: click.Parameter | None,
ctx: click.Context | None,
) -> list[str]:
if isinstance(value, list):
return value
tokens = [t.strip() for t in str(value).split(",") if t.strip()]
if not tokens:
self.fail(
f"{value!r} is not a valid step list. "
"Provide comma-separated codes, e.g. NR001,FREQ001,SS001",
param,
ctx,
)
try:
from pycsamt.pipeline import (
lookup_step, # noqa: PLC0415
)
except ImportError:
return tokens # best-effort if pipeline not yet importable
codes: list[str] = []
for token in tokens:
try:
spec = lookup_step(token)
codes.append(spec.code)
except KeyError:
self.fail(
f"Unknown step {token!r}. "
"Run pycsamt pipe steps to see all available steps.",
param,
ctx,
)
return codes
__all__ = [
"EDIDir",
"EDIPath",
"FreqRange",
"PipeStepList",
"StationList",
]