diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e199302e..4fe1a8e3 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -11,6 +11,8 @@ Release 0.12.0 (unreleased) * Skip patches outside manifest dir (#942) * Make patch path in metadata platform independent (#937) * Fix extra newlines in patch for new files (#945) +* Replace colored-logs with Rich +* Respect `NO_COLOR `_ Release 0.11.0 (released 2026-01-03) ==================================== diff --git a/dfetch/__main__.py b/dfetch/__main__.py index 0cc82c8b..83cdf11e 100644 --- a/dfetch/__main__.py +++ b/dfetch/__main__.py @@ -18,8 +18,7 @@ import dfetch.commands.validate import dfetch.log import dfetch.util.cmdline - -logger = dfetch.log.setup_root(__name__) +from dfetch.log import DLogger class DfetchFatalException(Exception): @@ -34,6 +33,9 @@ def create_parser() -> argparse.ArgumentParser: parser.add_argument( "--verbose", "-v", action="store_true", help="Increase verbosity" ) + parser.add_argument( + "--no-color", action="store_true", help="Disable colored output" + ) parser.set_defaults(func=_help) subparsers = parser.add_subparsers(help="commands") @@ -50,16 +52,21 @@ def create_parser() -> argparse.ArgumentParser: return parser -def _help(args: argparse.Namespace) -> None: - """Show the help.""" - raise RuntimeError("Select a function") +def _help(_: argparse.Namespace) -> None: + """Show help if no subcommand was selected.""" + parser = create_parser() + parser.print_help() def run(argv: Sequence[str]) -> None: """Start dfetch.""" - logger.print_title() args = create_parser().parse_args(argv) + console = dfetch.log.make_console(no_color=args.no_color) + logger: DLogger = dfetch.log.setup_root(__name__, console=console) + + logger.print_title() + if args.verbose: dfetch.log.increase_verbosity() diff --git a/dfetch/log.py b/dfetch/log.py index 5b67838e..c09406a5 100644 --- a/dfetch/log.py +++ b/dfetch/log.py @@ -1,63 +1,125 @@ """Logging related items.""" import logging -from typing import cast +import os +import sys +from typing import Any, Optional, cast -import coloredlogs -from colorama import Fore +from rich.console import Console +from rich.highlighter import NullHighlighter +from rich.logging import RichHandler from dfetch import __version__ +def make_console(no_color: bool = False) -> Console: + """Create a Rich Console with proper color handling.""" + return Console( + no_color=no_color + or os.getenv("NO_COLOR") is not None + or not sys.stdout.isatty() + ) + + +def configure_root_logger(console: Optional[Console] = None) -> None: + """Configure the root logger with RichHandler using the provided Console.""" + console = console or make_console() + + handler = RichHandler( + console=console, + show_time=False, + show_path=False, + show_level=False, + markup=True, + rich_tracebacks=True, + highlighter=NullHighlighter(), + ) + + logging.basicConfig( + level=logging.INFO, + format="%(message)s", + handlers=[handler], + force=True, + ) + + class DLogger(logging.Logger): """Logging class extended with specific log items for dfetch.""" def print_info_line(self, name: str, info: str) -> None: """Print a line of info.""" - self.info(f" {Fore.GREEN}{name:20s}:{Fore.BLUE} {info}") + self.info( + f" [bold][bright_green]{name:20s}:[/bright_green][blue] {info}[/blue][/bold]" + ) def print_warning_line(self, name: str, info: str) -> None: - """Print a line of info.""" - self.info(f" {Fore.GREEN}{name:20s}:{Fore.YELLOW} {info}") + """Print a warning line: green name, yellow value.""" + self.warning( + f" [bold][bright_green]{name:20s}:[/bright_green][bright_yellow] {info}[/bright_yellow][/bold]" + ) def print_title(self) -> None: """Print the DFetch tool title and version.""" - self.info(f"{Fore.BLUE}Dfetch ({__version__})") + self.info(f"[bold blue]Dfetch ({__version__})[/bold blue]") def print_info_field(self, field_name: str, field: str) -> None: """Print a field with corresponding value.""" self.print_info_line(field_name, field if field else "") + def warning(self, msg: object, *args: Any, **kwargs: Any) -> None: + """Log warning.""" + super().warning( + f"[bold bright_yellow]{msg}[/bold bright_yellow]", *args, **kwargs + ) -def setup_root(name: str) -> DLogger: - """Create the root logger.""" - logger = get_logger(name) - - msg_format = "%(message)s" - - level_style = { - "critical": {"color": "magenta", "bright": True, "bold": True}, - "debug": {"color": "green", "bright": True, "bold": True}, - "error": {"color": "red", "bright": True, "bold": True}, - "info": {"color": 4, "bright": True, "bold": True}, - "notice": {"color": "magenta", "bright": True, "bold": True}, - "spam": {"color": "green", "faint": True}, - "success": {"color": "green", "bright": True, "bold": True}, - "verbose": {"color": "blue", "bright": True, "bold": True}, - "warning": {"color": "yellow", "bright": True, "bold": True}, - } + def error(self, msg: object, *args: Any, **kwargs: Any) -> None: + """Log error.""" + super().error(f"[red]{msg}[/red]", *args, **kwargs) - coloredlogs.install(fmt=msg_format, level_styles=level_style, level="INFO") - return logger +def setup_root(name: str, console: Optional[Console] = None) -> DLogger: + """Create and return the root logger.""" + logging.setLoggerClass(DLogger) + configure_root_logger(console) + logger = logging.getLogger(name) + return cast(DLogger, logger) def increase_verbosity() -> None: - """Increase the verbosity of the logger.""" - coloredlogs.increase_verbosity() - - -def get_logger(name: str) -> DLogger: - """Get logger for a module.""" + """Increase verbosity of the root logger.""" + levels = [ + logging.CRITICAL, + logging.ERROR, + logging.WARNING, + logging.INFO, + logging.DEBUG, + ] + logger_ = logging.getLogger() + current_level = logger_.getEffectiveLevel() + try: + idx = levels.index(current_level) + if idx < len(levels) - 1: + new_level = levels[idx + 1] + else: + new_level = levels[-1] + except ValueError: + new_level = logging.DEBUG + logger_.setLevel(new_level) + + +def get_logger(name: str, console: Optional[Console] = None) -> DLogger: + """Get logger for a module, optionally configuring console colors.""" logging.setLoggerClass(DLogger) - return cast(DLogger, logging.getLogger(name)) + logger = logging.getLogger(name) + logger.propagate = True + if console: + configure_root_logger(console) + return cast(DLogger, logger) + + +def configure_external_logger(name: str, level: int = logging.INFO) -> None: + """Configure an external logger from a third party package.""" + logger = logging.getLogger(name) + logger.setLevel(level) + logger.propagate = True + logger.handlers.clear() diff --git a/dfetch/vcs/patch.py b/dfetch/vcs/patch.py index a70e85d9..ba8e7db9 100644 --- a/dfetch/vcs/patch.py +++ b/dfetch/vcs/patch.py @@ -8,10 +8,12 @@ import patch_ng -from dfetch.log import get_logger +from dfetch.log import configure_external_logger, get_logger logger = get_logger(__name__) +configure_external_logger("patch_ng") + def _git_mode(path: Path) -> str: if path.is_symlink(): diff --git a/doc/legal.rst b/doc/legal.rst index 5e36a740..c7b53001 100644 --- a/doc/legal.rst +++ b/doc/legal.rst @@ -77,34 +77,33 @@ We use `PyYAML`_ for parsing manifests (which are YAML). This uses the MIT licen .. _`PyYAML`: https://pyyaml.org/ -python-coloredlogs -~~~~~~~~~~~~~~~~~~ -`Colored logs`_ is used for the colored text output. +Rich +~~~~ +`Rich`_ is used for the colored text output. :: - Copyright (c) 2020 Peter Odding + Copyright (c) 2020 Will McGugan - Permission is hereby granted, free of charge, to any person obtaining - a copy of this software and associated documentation files (the - "Software"), to deal in the Software without restriction, including - without limitation the rights to use, copy, modify, merge, publish, - distribute, sublicense, and/or sell copies of the Software, and to - permit persons to whom the Software is furnished to do so, subject to - the following conditions: + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: - The above copyright notice and this permission notice shall be - included in all copies or substantial portions of the Software. + The above copyright notice and this permission notice shall be included in all + copies or substantial portions of the Software. - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, - EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF - MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND - NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE - LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION - OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION - WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. -.. _`Colored logs`: https://coloredlogs.readthedocs.io/en/latest/ +.. _`Rich`: https://rich.readthedocs.io/en/latest/ pykwalify ~~~~~~~~~ @@ -137,41 +136,7 @@ pykwalify .. _`pykwalify`: https://github.com/Grokzen/pykwalify -Colorama -~~~~~~~~ -`colorama`_ is also used for the colored text output. - -:: - - Copyright (c) 2010 Jonathan Hartley - All rights reserved. - - Redistribution and use in source and binary forms, with or without - modification, are permitted provided that the following conditions are met: - - * Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. - - * Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. - - * Neither the name of the copyright holders, nor those of its contributors - may be used to endorse or promote products derived from this software without - specific prior written permission. - - THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED - WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE - FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL - DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR - SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER - CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, - OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE - OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -.. _`colorama`: https://github.com/tartley/colorama Typing-extensions diff --git a/features/steps/generic_steps.py b/features/steps/generic_steps.py index 24737fee..8f04d094 100644 --- a/features/steps/generic_steps.py +++ b/features/steps/generic_steps.py @@ -17,7 +17,7 @@ from dfetch.__main__ import DfetchFatalException, run from dfetch.util.util import in_directory -ansi_escape = re.compile(r"\x1b(?:[@A-Z\\-_]|\[[0-9:;<=>?]*[ -/]*[@-~])") +ansi_escape = re.compile(r"\[/?[a-z\_ ]+\]") dfetch_title = re.compile(r"Dfetch \(\d+.\d+.\d+\)") timestamp = re.compile(r"\d+\/\d+\/\d+, \d+:\d+:\d+") git_hash = re.compile(r"(\s?)[a-f0-9]{40}(\s?)") @@ -41,10 +41,7 @@ def call_command(context: Context, args: list[str], path: Optional[str] = ".") - context.cmd_returncode = 0 except DfetchFatalException: context.cmd_returncode = 1 - # Remove the color code + title - context.cmd_output = dfetch_title.sub( - "", ansi_escape.sub("", context.captured.output[length_at_start:].strip("\n")) - ) + context.cmd_output = context.captured.output[length_at_start:].strip("\n") def check_file(path, content): @@ -81,7 +78,7 @@ def check_content( ): expected = multisub( patterns=[ - (git_hash, r"\1[commit hash]\2"), + (git_hash, r"\1[commit-hash]\2"), (iso_timestamp, "[timestamp]"), (urn_uuid, "[urn-uuid]"), (bom_ref, "[bom-ref]"), @@ -91,7 +88,7 @@ def check_content( actual = multisub( patterns=[ - (git_hash, r"\1[commit hash]\2"), + (git_hash, r"\1[commit-hash]\2"), (iso_timestamp, "[timestamp]"), (urn_uuid, "[urn-uuid]"), (bom_ref, "[bom-ref]"), @@ -164,9 +161,9 @@ def check_output(context, line_count=None): """ expected_text = multisub( patterns=[ - (git_hash, r"\1[commit hash]\2"), + (git_hash, r"\1[commit-hash]\2"), (timestamp, "[timestamp]"), - (dfetch_title, ""), + (ansi_escape, ""), (svn_error, "svn: EXXXXXX: "), ], text=context.text, @@ -174,7 +171,7 @@ def check_output(context, line_count=None): actual_text = multisub( patterns=[ - (git_hash, r"\1[commit hash]\2"), + (git_hash, r"\1[commit-hash]\2"), (timestamp, "[timestamp]"), (ansi_escape, ""), ( diff --git a/pyproject.toml b/pyproject.toml index 5d81164a..1c6a1f47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,10 +40,9 @@ classifiers = [ ] dependencies = [ "PyYAML==6.0.3", - "coloredlogs==15.0.1", "strictyaml==1.7.3", "halo==0.0.31", - "colorama==0.4.6", + "rich==14.2.0", "typing-extensions==4.15.0", "tldextract==5.3.0", "sarif-om==1.0.4",