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",