"""click command-line interface — the optional, ergonomic CLI twin.
Identical behaviour to :mod:`elbow_helper.cli_argparse`, but built on click
for a nicer help/UX. click is an *optional* dependency (``pip install
'elbow-helper[cli]'``): the argparse CLI is the always-available one, so
importing this module lazily requires click and fails with an actionable
message if it is missing. Every command delegates to the shared
:mod:`elbow_helper._core_cli`, so the two CLIs can never drift apart.
Consumes: ``click`` (optional), ``elbow_helper._core_cli``, ``os_helper``.
Produces: :func:`cli` (the ``elbow-helper-click`` console-script entry point).
Author
------
Warith Harchaoui, <warith.harchaoui@deraison.ai>
"""
from __future__ import annotations
import json
import logging
import sys
from typing import Optional
try:
import click
except ImportError as exc: # pragma: no cover - exercised only without the extra
raise ImportError(
"the click CLI needs the [cli] extra. Run: pip install 'elbow-helper[cli]' "
"(the argparse CLI `elbow-helper` is always available without it)."
) from exc
import os_helper as oh
from . import _core_cli as core
from .cli_argparse import _load_series
def _emit(result: dict | str) -> None:
"""Print a core result as raw SVG (str) or indented JSON (dict).
Parameters
----------
result : dict or str
A core function's result — a JSON-ready dict, or the raw SVG string
from ``diagnostics``.
"""
click.echo(result if isinstance(result, str) else json.dumps(result, indent=2))
def _resolve_xy(
x_values: Optional[str],
x_npy: Optional[str],
x_csv: Optional[str],
y_values: Optional[str],
y_npy: Optional[str],
y_csv: Optional[str],
) -> tuple[list, Optional[list]]:
"""Resolve x/y series from the click data options, with the y-only shorthand.
Mirrors :func:`elbow_helper.cli_argparse._xy_from_args` for the click
surface, reusing the same three-way loader so both CLIs read identical
inputs identically.
Parameters
----------
x_values, x_npy, x_csv : str, optional
The x-side inputs (mutually exclusive; see :func:`_load_series`).
y_values, y_npy, y_csv : str, optional
The y-side inputs.
Returns
-------
tuple of (list, list or None)
``(x, y)``, with ``y`` ``None`` for the shorthand form.
Raises
------
click.UsageError
If neither side had any input.
"""
x = _load_series(x_values, x_npy, x_csv)
y = _load_series(y_values, y_npy, y_csv)
if x is None and y is None:
raise click.UsageError(
"no data given: pass --y-values/--y-npy/--y-csv (shorthand: y "
"only) or both x and y series"
)
if x is None:
return y, None
if y is None:
return x, None
return x, y
# Every command below passes `help=` explicitly rather than let click derive
# it from the docstring: the docstring is full numpydoc (Parameters section
# and all, per house style), and click would otherwise print that raw RST as
# the command's --help text.
def _data_options(func):
"""Attach the shared x/y input + orientation/config options to a command.
Parameters
----------
func : Callable
The click command function to decorate.
Returns
-------
Callable
``func``, wrapped with every data option.
"""
opts = [
click.option("--x-values", help="comma-separated x values, e.g. 0,1,2,3"),
click.option("--x-npy", help="path to a 1-D .npy file of x values"),
click.option("--x-csv", help="PATH:COLUMN_INDEX into a CSV file for x"),
click.option("--y-values", help="comma-separated y values"),
click.option("--y-npy", help="path to a 1-D .npy file of y values"),
click.option("--y-csv", help="PATH:COLUMN_INDEX into a CSV file for y"),
click.option("--curve", type=click.Choice(["concave", "convex"]), default=None),
click.option(
"--direction", type=click.Choice(["increasing", "decreasing"]), default=None
),
click.option(
"--config-json",
default=None,
help="RobustKneeConfig field overrides as JSON, e.g. '{\"bootstrap_replicates\": 500}'",
),
]
for opt in reversed(opts):
func = opt(func)
return func
@click.group()
@click.version_option(package_name="elbow-helper")
@click.option("-v", "--verbose", count=True, help="repeat for more logs")
def cli(verbose: int) -> None:
"""Noise-robust knee/elbow detection: a knee with uncertainty, or an explicit abstention.
Parameters
----------
verbose : int
``-v`` repeat count; raises the log level (0=WARNING, 1=INFO, 2+=DEBUG).
"""
level = {0: logging.WARNING, 1: logging.INFO}.get(verbose, logging.DEBUG)
oh.init_logging(level=level, stdout=False)
@cli.command("knee", help="Locate a knee with uncertainty, or abstain.")
@_data_options
def knee_cmd(
x_values,
x_npy,
x_csv,
y_values,
y_npy,
y_csv,
curve,
direction,
config_json,
) -> None:
"""Locate a knee with uncertainty, or abstain.
Parameters
----------
x_values, x_npy, x_csv, y_values, y_npy, y_csv : str, optional
The curve inputs, from :func:`_data_options`.
curve, direction : str, optional
Orientation hints.
config_json : str, optional
``RobustKneeConfig`` field overrides as a JSON object.
"""
x, y = _resolve_xy(x_values, x_npy, x_csv, y_values, y_npy, y_csv)
overrides = json.loads(config_json) if config_json else None
_emit(
core.do_knee(x, y, curve=curve, direction=direction, config_overrides=overrides)
)
@cli.command("elbow", help="The convex/decreasing convenience (k-means elbow).")
@_data_options
def elbow_cmd(
x_values,
x_npy,
x_csv,
y_values,
y_npy,
y_csv,
curve,
direction,
config_json,
) -> None:
"""The convex/decreasing convenience (k-means elbow).
Parameters
----------
x_values, x_npy, x_csv, y_values, y_npy, y_csv : str, optional
The curve inputs, from :func:`_data_options`.
curve, direction : str, optional
Accepted for a uniform option surface across commands; unused —
``robust_elbow`` always pins convex/decreasing.
config_json : str, optional
``RobustKneeConfig`` field overrides as a JSON object.
"""
_ = curve, direction
x, y = _resolve_xy(x_values, x_npy, x_csv, y_values, y_npy, y_csv)
if y is None:
raise click.UsageError(
"elbow needs both an x and a y series (no y-only shorthand)"
)
overrides = json.loads(config_json) if config_json else None
_emit(core.do_elbow(x, y, config_overrides=overrides))
@cli.command("diagnostics", help="Render the diagnostic SVG.")
@_data_options
@click.option("--out", default=None, help="write the SVG to this path")
@click.option("--language", type=click.Choice(["en", "fr"]), default="en")
def diagnostics_cmd(
x_values,
x_npy,
x_csv,
y_values,
y_npy,
y_csv,
curve,
direction,
config_json,
out,
language,
) -> None:
"""Render the diagnostic SVG.
Parameters
----------
x_values, x_npy, x_csv, y_values, y_npy, y_csv : str, optional
The curve inputs, from :func:`_data_options`.
curve, direction : str, optional
Orientation hints.
config_json : str, optional
``RobustKneeConfig`` field overrides as a JSON object.
out : str, optional
Write the SVG to this path in addition to printing it.
language : str, optional
Chrome-text language, ``"en"`` (default) or ``"fr"``.
"""
x, y = _resolve_xy(x_values, x_npy, x_csv, y_values, y_npy, y_csv)
overrides = json.loads(config_json) if config_json else None
_emit(
core.do_diagnostics(
x,
y,
curve=curve,
direction=direction,
config_overrides=overrides,
out=out,
language=language,
)
)
@cli.command("locator", help="The standalone locator (no uncertainty evidence).")
@_data_options
@click.option("--sensitivity", type=float, default=1.0)
@click.option("--online/--no-online", default=True)
def locator_cmd(
x_values,
x_npy,
x_csv,
y_values,
y_npy,
y_csv,
curve,
direction,
config_json,
sensitivity,
online,
) -> None:
"""The standalone locator (no uncertainty evidence).
Parameters
----------
x_values, x_npy, x_csv, y_values, y_npy, y_csv : str, optional
The curve inputs, from :func:`_data_options`.
curve, direction : str, optional
Orientation hints (falls back to the locator's own defaults).
config_json : str, optional
Accepted for a uniform option surface; unused — the standalone
locator has no ``RobustKneeConfig``.
sensitivity : float
The locator's ``S`` sensitivity parameter.
online : bool
Whether the locator re-arms at every later, higher peak.
"""
_ = config_json
x, y = _resolve_xy(x_values, x_npy, x_csv, y_values, y_npy, y_csv)
if y is None:
raise click.UsageError("locator needs both an x and a y series")
_emit(
core.do_locator(
x,
y,
sensitivity=sensitivity,
curve=curve,
direction=direction,
online=online,
)
)
[docs]
def main() -> None:
"""Console entry point (``elbow-helper-click``).
click's own ``main()`` only special-cases ``ClickException``/``Abort``
(and a broken pipe); a plain library exception (e.g. ``ValueError`` from
``locator`` on a malformed curve) would otherwise propagate as a raw
Python traceback instead of a clean CLI error. This wraps the whole
invocation and translates that last case into a one-line stderr message
+ exit 1 — click's own control flow (usage errors, ``--help``, an
explicit ``sys.exit(1)`` in a subcommand) already raises ``SystemExit``,
a ``BaseException`` this does not catch, so it passes through untouched.
"""
try:
cli()
except Exception as err: # noqa: BLE001 — last resort: see docstring
click.echo(f"Error: {err}", err=True)
sys.exit(1)
if __name__ == "__main__": # pragma: no cover
main()