aboutsummaryrefslogtreecommitdiff
path: root/duplicate_finder/cli.py
diff options
context:
space:
mode:
Diffstat (limited to 'duplicate_finder/cli.py')
-rw-r--r--duplicate_finder/cli.py234
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)