diff options
Diffstat (limited to 'duplicate_finder/cli.py')
| -rw-r--r-- | duplicate_finder/cli.py | 234 |
1 files changed, 234 insertions, 0 deletions
diff --git a/duplicate_finder/cli.py b/duplicate_finder/cli.py new file mode 100644 index 0000000..e1bc4f9 --- /dev/null +++ b/duplicate_finder/cli.py @@ -0,0 +1,234 @@ +# SPDX-FileCopyrightText: 2026 Dennis Fink <me+coding@dennisfink.me> +# +# SPDX-License-Identifier: BSD-3-Clause + +"""Define Click parameter types and styled terminal message helpers.""" + +import re +from collections.abc import Callable +from functools import update_wrapper +from typing import Any, Concatenate + +import click_extra as click +from click_extra.color import ColorOption + +_SIZE_RE = re.compile( + r"^\s*(?P<value>\d+\.?\d*)\s*((?P<unit>[kmgtpe]i?)?)b?\s*$", re.IGNORECASE +) +_SIZE_MULTIPLIERS = { + "": 1, + "k": 1000, + "ki": 1024, + "m": 1000**2, + "mi": 1024**2, + "g": 1000**3, + "gi": 1024**3, + "t": 1000**4, + "ti": 1024**4, + "p": 1000**5, + "pi": 1024**5, + "e": 1000**6, + "ei": 1024**6, +} + + +class ByteSizeParamType(click.ParamType): + """Parse byte sizes with optional decimal or binary unit suffixes.""" + + name = "size" + + def convert( + self, value: Any, param: click.Parameter | None, ctx: click.Context | None + ) -> int: + """Convert a Click parameter value to a size in bytes. + + :param value: Value supplied by Click. + :param param: Parameter currently being converted, if available. + :param ctx: Active Click context, if available. + :returns: The parsed size in bytes. + :raises click.BadParameter: If the value is negative, has an unsupported type, + or cannot be parsed as a byte size. + """ + if isinstance(value, int): + if value < 0: + self.fail("Size must be non-negative.", param, ctx) + return value + + if not isinstance(value, str): + self.fail("Size must be a string like '10M' or an integer.", param, ctx) + + if (match := _SIZE_RE.match(value)) is not None: + groups = match.groupdict() + return round( + float(groups["value"]) * _SIZE_MULTIPLIERS[groups["unit"].lower()] + ) + else: + self.fail(f"Invalid size: {value!r}", param, ctx) + + +class FlexibleColorOption(ColorOption): + """Allow the color option value to be passed with or without ``=``.""" + + _gnu_optional_value = False + + +def pass_obj_silent[T, **P, R]( + f: Callable[Concatenate[T | None, P], R], +) -> Callable[P, R]: + """Pass the current context object to a callback, if available. + + The current Click context is retrieved silently. If no context exists or + its object is not a dictionary, ``None`` is passed instead. + + The context object is inserted as the first positional argument to *f*. + """ + + def new_func(*args: P.args, **kwargs: P.kwargs) -> R: + ctx = click.get_current_context(silent=True) + obj = ctx.obj if ctx is not None else None + return f(obj, *args, **kwargs) + + return update_wrapper(new_func, f) + + +def emit( + prefix: str, color: str, *message: str, enabled: bool = True, err: bool = False +) -> None: + """Render a styled prefix and message to stdout. + + This helper is used by :func:`error`, :func:`msg`, :func:`warn`, + :func:`verbose`, and :func:`debug`. The first message argument is rendered in + bold and subsequent arguments are appended unstyled. + + :param prefix: Prefix displayed before the message. + :param color: Click-compatible color name applied to the prefix. + :param message: One or more message parts to display. + :param enabled: Whether the message should be emitted. + :param err: Write to `stderr` instead of `stdout`. + :raises TypeError: If no message arguments are provided. + """ + if not message: + raise TypeError("emit() missing 1 required positional argument: 'message'") + + if not enabled: + return + + click.echo( + " ".join( + [ + click.style(prefix, fg=color, bold=True), + click.style(message[0], bold=True), + *message[1:], + ] + ), + err=err, + ) + + +def error(*message: str) -> None: + """Print a formatted error message to stdout in red. + + The first argument is rendered in bold and subsequent arguments are appended + unstyled. Error messages are always printed regardless of quiet or verbose + flags. + + :param message: One or more message parts to display. + """ + emit("==> ERROR:", "red", *message, err=True) + + +@pass_obj_silent +def msg(obj: dict[str, Any] | None, *message: str, err: bool = False) -> None: + """Print a formatted informational message to stdout in green. + + The message is suppressed when the quiet flag is set. When called outside a + Click context, the message is emitted normally. The first argument is rendered + in bold and subsequent arguments are appended unstyled. + + :param message: One or more message parts to display. + """ + emit( + "==>", + "green", + *message, + enabled=obj is None or not obj.get("quiet", False), + err=err, + ) + + +@pass_obj_silent +def warn(obj: dict[str, Any] | None, *message: str, err: bool = False) -> None: + """Print a formatted warning message to stdout in yellow. + + The message is suppressed when the quiet flag is set. When called outside a + Click context, the message is emitted normally. The first argument is rendered + in bold and subsequent arguments are appended unstyled. + + :param message: One or more message parts to display. + """ + emit( + "==>", + "yellow", + *message, + enabled=obj is None or not obj.get("quiet", False), + err=err, + ) + + +@pass_obj_silent +def verbose(obj: dict[str, Any] | None, *message: str, err: bool = False) -> None: + """Print a formatted verbose message to stdout in blue. + + The message is printed only when called from a Click context with the verbose + flag set and the quiet flag unset. Calls made outside a Click context are a + no-op. + + :param message: One or more message parts to display. + """ + emit( + "==>", + "blue", + *message, + enabled=( + obj is not None + and obj.get("verbose", False) + and not obj.get("quiet", False) + ), + err=err, + ) + + +@pass_obj_silent +def debug(obj: dict[str, Any] | None, *message: str, err: bool = False) -> None: + """Print a formatted debug message to stdout in magenta. + + The message is printed only when called from a Click context with the debug + flag enabled. Calls made outside a Click context are a no-op. + + :param message: One or more message parts to display. + """ + emit( + "==>", + "magenta", + *message, + enabled=obj is not None and obj.get("debug", False), + err=err, + ) + + +@pass_obj_silent +def is_debug_enabled(obj: dict[str, Any] | None) -> bool: + """Return whether debug output is enabled for the active Click context. + + :returns: ``True`` when a Click context exists and its debug flag is enabled. + """ + return obj is not None and obj.get("debug", False) + + +@pass_obj_silent +def is_quiet_enabled(obj: dict[str, Any] | None) -> bool: + """Return whether quiet output is enabled for the active Click context. + + :returns: ``True`` when a Click context exists and its quiet flag is enabled. + """ + return obj is not None and obj.get("quiet", False) |
