diff options
| author | Dennis Fink | 2026-09-20 20:04:13 +0200 |
|---|---|---|
| committer | Dennis Fink | 2026-09-20 20:04:13 +0200 |
| commit | b751174b4ed7c23786ae54a1bbaf332f653faa81 (patch) | |
| tree | c1951961b7bdd171aa4903d9e2d5ad4f2a063b51 /duplicate_finder/cli.py | |
| download | duplicate-finder-main.tar.gz duplicate-finder-main.zip | |
Introduce the first public release of duplicate-finder, a command-line
tool for detecting duplicate files through metadata grouping and content
hashing.
Support recursive path scanning, optional symlink traversal, hard-link
handling, size and extension-based candidate grouping, and parallel
content hashing with xxHash or hashlib algorithms.
Provide human, plain, table, and JSON output formats together with size
filtering, path input from files or stdin, progress reporting,
diagnostic output, and configurable terminal colors.
Add Python packaging for Python 3.14, project documentation, dependency
locking, development tooling, and BSD-3-Clause licensing.
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) |
