Source code for os_helper.cli_click

"""
OS Helper — click-based command-line interface.

Twin of :mod:`os_helper.cli_argparse`: same public surface (identical
subcommand groups, identical flag semantics), but implemented with
:mod:`click` so users who already have a click-native shell setup
(``click.shell_completion`` for bash / zsh / fish, colored ``--help``,
nested command groups) can plug it in without friction. Installed as
the ``os-helper-click`` entry point in ``pyproject.toml``.

Design notes
------------
- Subcommand *groups* mirror ``os-helper`` (the argparse twin) so both
  CLIs can be introspected identically by higher layers.
- Flag names match the argparse names (``--path`` / ``--size`` / …)
  rather than the more idiomatic click positional style — consistency
  across the two CLIs beats micro-idiomaticity here.
- A library exception is caught by :func:`main` (the actual
  ``os-helper-click`` console-script target) and printed as a clean
  ``Error: ...`` line + exit 1, instead of a raw traceback. Click's own
  control flow (usage errors, ``--help``, an explicit ``sys.exit``) raises
  ``SystemExit``, which this does not catch, so it passes through unchanged.

Usage Example
-------------
>>> #   os-helper-click os system
>>> #   os-helper-click path exists /etc/hosts
>>> #   os-helper-click hash string hello --size 8
>>> #   os-helper-click misc now --fmt filename
>>> #   os-helper-click temp folder --prefix demo

Author
------
Warith Harchaoui, Ph.D. — https://linkedin.com/in/warith-harchaoui/
"""

from __future__ import annotations

import json
import subprocess
import sys
import time
from collections.abc import Callable

try:
    import click
except ImportError as exc:  # pragma: no cover
    raise ImportError(
        "The click CLI requires the [cli] extra. Install with: pip install 'os-helper[cli]'"
    ) from exc

# Same underlying functions as the argparse twin — one source of truth.
from . import (
    absolute2relative_path,
    asciistring,
    checkfile,
    copyfile,
    dir_exists,
    download_file,
    emptystring,
    file_exists,
    folder_description,
    folder_name_ext,
    format_size,
    get_config,
    get_nb_workers,
    get_user_ip,
    getpid,
    hardware_info,
    hash_string,
    hashfile,
    hashfolder,
    is_working_url,
    join,
    linux,
    macos,
    make_directory,
    now_string,
    openfile,
    path_without_home,
    recursive_glob,
    relative2absolute_path,
    remove_directory,
    remove_files,
    size_file,
    str2time,
    system,
    temporary_filename,
    temporary_folder,
    time2str,
    unix,
    windows,
    zip_folder,
)

# ---------------------------------------------------------------------------
# Top-level group
# ---------------------------------------------------------------------------


@click.group(
    context_settings={"help_option_names": ["-h", "--help"], "max_content_width": 100},
)
@click.version_option(package_name="os-helper", prog_name="os-helper-click")
def cli() -> None:
    """OS Helper — click twin of the argparse CLI. Same subcommand groups."""
    # Nothing to do at the group level — every subcommand carries its own
    # arguments and side effects.


# ---------------------------------------------------------------------------
# os — OS detection / process helpers
# ---------------------------------------------------------------------------


@cli.group("os")
def os_grp() -> None:
    """Operating-system detection and process helpers."""


@os_grp.command("system")
def os_system() -> None:
    """Print the current OS name."""
    # Probe in priority order and emit a single canonical short name; the final
    # ``unknown`` branch keeps the output well-defined on exotic platforms.
    if windows():
        click.echo("windows")
    elif macos():
        click.echo("macos")
    elif linux():
        click.echo("linux")
    else:
        click.echo("unknown")


def _make_flag(name: str, probe: Callable[[], bool]) -> None:
    """Register a click command that prints ``true``/``false`` for a probe.

    Parameters
    ----------
    name : str
        Subcommand name (also used in the generated help text).
    probe : Callable[[], bool]
        Zero-argument predicate whose result is surfaced to the shell.
    """

    # Factory pattern: generating the six OS-detection commands from one closure
    # avoids six near-identical copy-pasted command bodies.
    @os_grp.command(name)
    def _cmd() -> None:  # noqa: D401 — docstring set dynamically below
        """Print the boolean probe result as ``true``/``false``."""
        click.echo("true" if probe() else "false")

    # Set the help text per-command since the shared body cannot hardcode it.
    _cmd.__doc__ = f"Print 'true' if the current OS is {name}."


_make_flag("unix", unix)
_make_flag("linux", linux)
_make_flag("macos", macos)
_make_flag("windows", windows)


@os_grp.command("pid")
def os_pid() -> None:
    """Print the current process ID."""
    click.echo(getpid())


@os_grp.command("workers")
@click.option(
    "--n", type=int, default=-1, show_default=True, help="0 = full pool; >0 = exact; <0 = pool+n+1."
)
def os_workers(n: int) -> None:
    """Resolve a worker count (sklearn n_jobs convention)."""
    click.echo(get_nb_workers(n))


@os_grp.command("run")
@click.argument("cmd_string")
@click.option("--expected", default=None, help="File or directory expected to exist after success.")
@click.option("--check-empty", is_flag=True, help="Also require --expected to be non-empty.")
@click.option("--no-check-exitcode", is_flag=True, help="Do not assert exit code == 0.")
def os_run(
    cmd_string: str, expected: str | None, check_empty: bool, no_check_exitcode: bool
) -> None:
    """Run CMD_STRING as a subprocess and capture stdout / stderr."""
    result = system(
        cmd=cmd_string,
        expected_output=expected or "",
        check_exitcode=not no_check_exitcode,
        check_empty=check_empty,
    )
    # Relay the child's streams to our own: ``nl=False`` preserves the captured
    # bytes exactly (the command already produced its own newlines), and stderr
    # is kept on fd 2 so it never contaminates a stdout pipe.
    if result["out"]:
        click.echo(result["out"], nl=False)
    if result["err"]:
        click.echo(result["err"], nl=False, err=True)


@os_grp.command("open")
@click.argument("path", type=click.Path(exists=True))
def os_open(path: str) -> None:
    """Open PATH in the platform's default application."""
    openfile(path)


# ---------------------------------------------------------------------------
# hardware — CPU / RAM / GPU inspection
# ---------------------------------------------------------------------------


@cli.group("hardware")
def hardware_grp() -> None:
    """Hardware inspection (CPU, RAM, GPU)."""


@hardware_grp.command("info")
def hardware_info_cmd() -> None:
    """Print CPU cores/model, RAM, and GPU vendor/model/VRAM as JSON."""
    # hardware_info() is already JSON-ready; sort_keys matches the rest of
    # this CLI's structured-output commands so scripted diffs stay stable.
    click.echo(json.dumps(hardware_info(), indent=2, sort_keys=True))


# ---------------------------------------------------------------------------
# path — filesystem predicates and helpers
# ---------------------------------------------------------------------------


@cli.group("path")
def path_grp() -> None:
    """Filesystem predicates and helpers."""


@path_grp.command("exists")
@click.argument("path")
@click.option("--non-empty", is_flag=True)
def path_exists(path: str, non_empty: bool) -> None:
    """Check if PATH exists (exit 0 = yes)."""
    ok = file_exists(path, check_empty=non_empty)
    click.echo("true" if ok else "false")
    # Encode the boolean in the EXIT CODE too, so shell ``if`` guards can branch
    # on it directly without parsing stdout.
    sys.exit(0 if ok else 1)


@path_grp.command("dir-exists")
@click.argument("path")
@click.option("--non-empty", is_flag=True)
def path_dir_exists(path: str, non_empty: bool) -> None:
    """Check if PATH is an existing directory (exit 0 = yes)."""
    ok = dir_exists(path, check_empty=non_empty)
    click.echo("true" if ok else "false")
    # Same dual stdout+exit-code contract as ``path exists`` above.
    sys.exit(0 if ok else 1)


@path_grp.command("join")
@click.argument("parts", nargs=-1, required=True)
def path_join(parts: tuple[str, ...]) -> None:
    """Join PARTS into a normalized absolute path."""
    click.echo(join(*parts))


@path_grp.command("abs")
@click.argument("path")
@click.option("--check", is_flag=True, help="Assert the resulting path exists.")
def path_abs(path: str, check: bool) -> None:
    """Convert PATH to absolute."""
    click.echo(relative2absolute_path(path, checkpath=check))


@path_grp.command("rel")
@click.argument("path")
@click.option("--base", default=None, help="Reference path (default: cwd).")
def path_rel(path: str, base: str | None) -> None:
    """Convert PATH to relative from --base."""
    click.echo(absolute2relative_path(path, base_path=base))


@path_grp.command("no-home")
@click.argument("path")
def path_no_home(path: str) -> None:
    """Replace the user's home prefix with '~'."""
    click.echo(path_without_home(path))


@path_grp.command("size")
@click.argument("path")
def path_size(path: str) -> None:
    """Print PATH's size in bytes (-1 when it does not exist)."""
    click.echo(size_file(path))


@path_grp.command("split")
@click.argument("path")
@click.option("--check", is_flag=True)
def path_split(path: str, check: bool) -> None:
    """Decompose PATH into folder/name/ext as JSON."""
    folder, name, ext = folder_name_ext(path, checkpath=check)
    # Emit JSON (not three lines) so callers can consume the parts with ``jq``.
    click.echo(json.dumps({"folder": folder, "name": name, "ext": ext}, indent=2))


@path_grp.command("glob")
@click.argument("root")
@click.argument("pattern")
def path_glob(root: str, pattern: str) -> None:
    """Recursively glob for files matching PATTERN under ROOT."""
    for match in recursive_glob(root, pattern):
        click.echo(match)


@path_grp.command("mkdir")
@click.argument("path")
@click.option("--strict", is_flag=True, help="Fail if the directory already exists.")
def path_mkdir(path: str, strict: bool) -> None:
    """Create PATH (and parents)."""
    make_directory(path, exist_ok=not strict)


@path_grp.command("rmdir")
@click.argument("path")
def path_rmdir(path: str) -> None:
    """Recursively remove PATH (missing = no-op)."""
    remove_directory(path)


@path_grp.command("rm")
@click.argument("paths", nargs=-1, required=True)
def path_rm(paths: tuple[str, ...]) -> None:
    """Remove PATHS best-effort (missing entries skipped)."""
    remove_files(list(paths))


@path_grp.command("cp")
@click.argument("source", type=click.Path(exists=True))
@click.argument("dest")
def path_cp(source: str, dest: str) -> None:
    """Copy SOURCE to DEST, preserving metadata."""
    copyfile(source, dest)


@path_grp.command("check")
@click.argument("path")
@click.option("--msg", default=None)
@click.option("--non-empty", is_flag=True)
def path_check(path: str, msg: str | None, non_empty: bool) -> None:
    """Assert PATH exists (and optionally is non-empty)."""
    checkfile(path, msg=msg or "", check_empty=non_empty)


# ---------------------------------------------------------------------------
# hash — hashing helpers
# ---------------------------------------------------------------------------


@cli.group("hash")
def hash_grp() -> None:
    """Hashing helpers (RIPEMD-160 / BLAKE2b fallback)."""


@hash_grp.command("string")
@click.argument("value")
@click.option("--size", type=int, default=-1, show_default=True)
def hash_string_cmd(value: str, size: int) -> None:
    """Hash VALUE; --size N truncates the digest to N chars."""
    click.echo(hash_string(value, size=size))


@hash_grp.command("file")
@click.argument("path", type=click.Path(exists=True))
@click.option("--path-only", is_flag=True, help="Hash the file path instead of its content.")
@click.option("--date", is_flag=True, help="Mix the current date into the hash.")
def hash_file_cmd(path: str, path_only: bool, date: bool) -> None:
    """Hash PATH's content."""
    click.echo(hashfile(path, hash_content=not path_only, date=date))


@hash_grp.command("folder")
@click.argument("path", type=click.Path(exists=True, file_okay=False))
@click.option("--no-content", is_flag=True)
@click.option("--include-path", is_flag=True)
@click.option("--date", is_flag=True)
def hash_folder_cmd(path: str, no_content: bool, include_path: bool, date: bool) -> None:
    """Hash PATH's contents."""
    click.echo(hashfolder(path, hash_content=not no_content, hash_path=include_path, date=date))


# ---------------------------------------------------------------------------
# str — string utilities
# ---------------------------------------------------------------------------


@cli.group("str")
def str_grp() -> None:
    """String utilities."""


@str_grp.command("empty")
@click.argument("value")
def str_empty(value: str) -> None:
    """Check if VALUE is None / whitespace-only (exit 0 = yes)."""
    result = emptystring(value)
    click.echo("true" if result else "false")
    # Predicate result mirrored into the exit code for shell-friendly use.
    sys.exit(0 if result else 1)


@str_grp.command("ascii")
@click.argument("value")
@click.option("--replacement", default="-", show_default=True)
@click.option("--preserve-case", is_flag=True)
@click.option("--no-digits", is_flag=True)
def str_ascii(value: str, replacement: str, preserve_case: bool, no_digits: bool) -> None:
    """Normalize VALUE to a filesystem-safe ASCII slug."""
    click.echo(
        asciistring(
            value,
            replacement_char=replacement,
            lower=not preserve_case,
            allow_digits=not no_digits,
        )
    )


# ---------------------------------------------------------------------------
# config — configuration loader
# ---------------------------------------------------------------------------


@cli.group("config")
def config_grp() -> None:
    """Configuration loading (JSON / YAML / .env / env vars)."""


@config_grp.command("get")
@click.option("--name", required=True, help="Human-readable label used in log messages.")
@click.option("--keys", required=True, multiple=True, help="Repeat --keys for each key to load.")
@click.option("--path", default=None, help="Config file or directory to search.")
@click.option("--env-files", multiple=True, help="Repeat --env-files for each .env file.")
def config_get(
    name: str, keys: tuple[str, ...], path: str | None, env_files: tuple[str, ...]
) -> None:
    """Load a set of KEYS and print JSON."""
    # click gathers repeated ``--keys``/``--env-files`` into tuples; convert to
    # lists for the library API, and pass None (not an empty list) so the
    # loader falls back to its own default env-file set.
    result = get_config(
        keys=list(keys),
        config_type=name,
        path=path,
        env_files=list(env_files) if env_files else None,
    )
    click.echo(json.dumps(result, indent=2, sort_keys=True))


# ---------------------------------------------------------------------------
# temp — temporary file / folder helpers
# ---------------------------------------------------------------------------


@cli.group("temp")
def temp_grp() -> None:
    """Temporary file / folder helpers."""


@temp_grp.command("file")
@click.option("--suffix", default=None)
@click.option("--prefix", default=None)
@click.option("--mode", default="wt", show_default=True)
@click.option("--keep", is_flag=True, help="Do not delete on exit.")
def temp_file(suffix: str | None, prefix: str | None, mode: str, keep: bool) -> None:
    """Create a temporary file and print its path."""
    # Print the path inside the context so it is emitted before cleanup; when
    # ``--keep`` is absent the file is deleted on block exit (proof-of-life).
    with temporary_filename(
        suffix=suffix or "",
        mode=mode,
        prefix=prefix or "",
        delete=not keep,
    ) as path:
        click.echo(path)


@temp_grp.command("folder")
@click.option("--prefix", default=None)
@click.option("--keep", is_flag=True, help="Do not delete on exit.")
def temp_folder_cmd(prefix: str | None, keep: bool) -> None:
    """Create a temporary directory and print its path."""
    # Same emit-then-cleanup pattern as ``temp file``.
    with temporary_folder(prefix=prefix or "", delete=not keep) as path:
        click.echo(path)


# ---------------------------------------------------------------------------
# misc — grab-bag utilities
# ---------------------------------------------------------------------------


@cli.group("misc")
def misc_grp() -> None:
    """Miscellaneous utilities."""


@misc_grp.command("now")
@click.option("--fmt", type=click.Choice(["log", "filename"]), default="log", show_default=True)
def misc_now(fmt: str) -> None:
    """Print a formatted timestamp."""
    click.echo(now_string(fmt))


@misc_grp.command("format-size")
@click.argument("nb_bytes", type=int)
def misc_format_size(nb_bytes: int) -> None:
    """Format NB_BYTES as a human-readable size."""
    click.echo(format_size(nb_bytes))


@misc_grp.command("describe")
@click.argument("path", type=click.Path(exists=True, file_okay=False))
@click.option("--flat", is_flag=True, help="Do not descend into subdirectories.")
@click.option("--no-html", is_flag=True)
@click.option("--no-json", is_flag=True)
@click.option("--no-size", is_flag=True)
def misc_describe(path: str, flat: bool, no_html: bool, no_json: bool, no_size: bool) -> None:
    """Describe PATH's contents as JSON."""
    result = folder_description(
        path,
        recursive=not flat,
        index_html=not no_html,
        with_size=not no_size,
        description_json=not no_json,
    )
    click.echo(json.dumps(result, indent=2, sort_keys=True))


@misc_grp.command("url-ok")
@click.argument("url")
def misc_url_ok(url: str) -> None:
    """Check whether URL is syntactically valid + reachable."""
    ok = is_working_url(url)
    click.echo("true" if ok else "false")
    # Reachability reflected in the exit code for scripting.
    sys.exit(0 if ok else 1)


@misc_grp.command("zip")
@click.argument("folder", type=click.Path(exists=True, file_okay=False))
@click.option("--output", default=None, help="Output archive path (default: folder + '.zip').")
def misc_zip(folder: str, output: str | None) -> None:
    """Zip FOLDER (skipping hidden files)."""
    zip_folder(folder, zip_file_path=output or "")


@misc_grp.command("download")
@click.argument("url")
@click.option("--output", default=None)
def misc_download(url: str, output: str | None) -> None:
    """Download URL to a local file."""
    download_file(url, file_path=output or "")


@misc_grp.command("time2str")
@click.argument("seconds", type=float)
@click.option("--no-space", is_flag=True)
def misc_time2str(seconds: float, no_space: bool) -> None:
    """Convert SECONDS to a readable duration string."""
    click.echo(time2str(seconds, no_space=no_space))


@misc_grp.command("str2time")
@click.argument("value")
def misc_str2time(value: str) -> None:
    """Parse VALUE as a duration and print seconds."""
    click.echo(str2time(value))


@misc_grp.command("ip")
def misc_ip() -> None:
    """Fetch the caller's public IPv4 / IPv6 addresses."""
    click.echo(json.dumps(get_user_ip(), indent=2, sort_keys=True))


# ---------------------------------------------------------------------------
# prof — profile an arbitrary child command
# ---------------------------------------------------------------------------


@cli.group("prof")
def prof_grp() -> None:
    """Profile an arbitrary subcommand."""


def _run_subprocess(argv: list[str]) -> int:
    """Run a caller-provided command as a child process, inheriting stdio.

    Parameters
    ----------
    argv : list of str
        Command and arguments to execute.

    Returns
    -------
    int
        The child's exit code, so the profiling wrapper stays transparent.
    """
    # Inherit the parent's stdio so the wrapped command behaves as if run direct.
    return subprocess.call(argv)


@prof_grp.command("wall", context_settings={"ignore_unknown_options": True})
@click.argument("argv", nargs=-1, type=click.UNPROCESSED)
def prof_wall(argv: tuple[str, ...]) -> None:
    """Wall-clock elapsed time (seconds on stderr)."""
    # Bracket the child run with a monotonic clock; the timing goes to stderr so
    # the child's own stdout stays clean, and we propagate its exit code.
    start = time.perf_counter()
    rc = _run_subprocess(list(argv))
    elapsed = time.perf_counter() - start
    click.echo(f"{elapsed:.6f}", err=True)
    sys.exit(rc)


@prof_grp.command("cpu", context_settings={"ignore_unknown_options": True})
@click.argument("argv", nargs=-1, type=click.UNPROCESSED)
def prof_cpu(argv: tuple[str, ...]) -> None:
    """CPU time consumed by the child subprocess."""
    import os as _os

    # ``time.process_time`` measures only THIS process, so for a subprocess we
    # read the ``children_*`` counters from ``os.times()`` and diff them across
    # the run to isolate the child's user+system CPU consumption.
    before = _os.times()
    rc = _run_subprocess(list(argv))
    after = _os.times()
    cpu_s = (after.children_user + after.children_system) - (
        before.children_user + before.children_system
    )
    click.echo(f"{cpu_s:.6f}", err=True)
    sys.exit(rc)


@prof_grp.command("gpu", context_settings={"ignore_unknown_options": True})
@click.argument("argv", nargs=-1, type=click.UNPROCESSED)
def prof_gpu(argv: tuple[str, ...]) -> None:
    """GPU timing (falls back to wall-clock for subprocesses)."""
    # A separate child process has its own CUDA context we cannot instrument
    # from here, so we warn and degrade gracefully to wall-clock timing rather
    # than fail — keeping the ``prof`` surface symmetric with wall/cpu.
    click.echo(
        "gpu profiling of an external subprocess is not supported; using wall-clock timing",
        err=True,
    )
    start = time.perf_counter()
    rc = _run_subprocess(list(argv))
    elapsed = time.perf_counter() - start
    click.echo(f"{elapsed:.6f}", err=True)
    sys.exit(rc)


[docs] def main() -> None: """Console entry point (``os-helper-click``). Click's own error handling only special-cases ``ClickException``/ ``Abort`` (and a broken pipe); a plain library exception (e.g. a ``RuntimeError`` from ``get_config``) 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`` 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()