Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ 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 and Halo with Rich (#960)
* Respect `NO_COLOR <https://no-color.org/>`_ (#960)
* Group logging under a project name header (#953)

Release 0.11.0 (released 2026-01-03)
====================================
Expand Down
24 changes: 17 additions & 7 deletions dfetch/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
import argparse
import sys
from collections.abc import Sequence
from typing import Optional

from rich.console import Console

import dfetch.commands.check
import dfetch.commands.diff
Expand All @@ -18,8 +21,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):
Expand All @@ -34,6 +36,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")

Expand All @@ -50,16 +55,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:
def run(argv: Sequence[str], console: Optional[Console] = None) -> None:
"""Start dfetch."""
logger.print_title()
args = create_parser().parse_args(argv)

console = console or 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()

Expand Down
27 changes: 12 additions & 15 deletions dfetch/commands/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,23 +44,20 @@ def _make_recommendation(
recommendations (List[ProjectEntry]): List of recommendations
childmanifest_path (str): Path to the source of recommendations
"""
logger.warning(
"\n".join(
[
"",
f'"{project.name}" depends on the following project(s) '
"which are not part of your manifest:",
f"(found in {childmanifest_path})",
]
)
)

recommendation_json = yaml.dump(
[proj.as_yaml() for proj in recommendations],
indent=4,
sort_keys=False,
)
logger.warning("")
for line in recommendation_json.splitlines():
logger.warning(line)
logger.warning("")
logger.print_warning_line(
project.name,
"\n".join(
[
f'"{project.name}" depends on the following project(s) which are not part of your manifest:',
f"(found in {childmanifest_path})",
"",
recommendation_json,
"",
]
),
)
4 changes: 3 additions & 1 deletion dfetch/commands/environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ def create_menu(subparsers: dfetch.commands.command.SubparserActionType) -> None

def __call__(self, _: argparse.Namespace) -> None:
"""Perform listing the environment."""
logger.print_info_line("platform", f"{platform.system()} {platform.release()}")
logger.print_report_line(
"platform", f"{platform.system()} {platform.release()}"
)
for project_type in SUPPORTED_PROJECT_TYPES:
project_type.list_tool_info()
2 changes: 1 addition & 1 deletion dfetch/commands/validate.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,4 @@ def __call__(self, args: argparse.Namespace) -> None:
manifest_path = find_manifest()
parse(manifest_path)
manifest_path = os.path.relpath(manifest_path, os.getcwd())
logger.print_info_line(manifest_path, "valid")
logger.print_report_line(manifest_path, "valid")
207 changes: 168 additions & 39 deletions dfetch/log.py
Original file line number Diff line number Diff line change
@@ -1,63 +1,192 @@
"""Logging related items."""

import logging
from typing import cast
import os
import sys
from contextlib import nullcontext
from typing import Any, Optional, Union, cast

import coloredlogs
from colorama import Fore
from rich.console import Console
from rich.highlighter import NullHighlighter
from rich.logging import RichHandler
from rich.status import Status

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

_printed_projects: set[str] = set()

def print_report_line(self, name: str, info: str) -> None:
"""Print a line for a report."""
self.info(
f" [bold][bright_green]{name:20s}:[/bright_green][blue] {info}[/blue][/bold]"
)

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}")
"""Print a line of info, only printing the project name once."""
if name not in DLogger._printed_projects:
self.info(f" [bold][bright_green]{name}:[/bright_green][/bold]")
DLogger._printed_projects.add(name)

line = info.replace("\n", "\n ")
self.info(f" [bold blue]> {line}[/bold blue]")

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."""
if name not in DLogger._printed_projects:
self.info(f" [bold][bright_green]{name}:[/bright_green][/bold]")
DLogger._printed_projects.add(name)

line = info.replace("\n", "\n ")
self.info(f" [bold bright_yellow]> {line}[/bold bright_yellow]")

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 "<none>")


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

coloredlogs.install(fmt=msg_format, level_styles=level_style, level="INFO")

return logger
self.print_report_line(field_name, field if field else "<none>")

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 error(self, msg: object, *args: Any, **kwargs: Any) -> None:
"""Log error."""
super().error(f"[red]{msg}[/red]", *args, **kwargs)

def status(
self, name: str, message: str, spinner: str = "dots", enabled: bool = True
) -> Union[Status, nullcontext[None]]:
"""Show status message with spinner if enabled."""
rich_console = None
logger: Optional[logging.Logger] = self
while logger:
for handler in getattr(logger, "handlers", []):
if isinstance(handler, RichHandler):
rich_console = handler.console
break
if rich_console or not getattr(logger, "parent", None):
break
logger = logger.parent

if not rich_console or not enabled:
return nullcontext(None)

if name not in DLogger._printed_projects:
self.info(f" [bold][bright_green]{name}:[/bright_green][/bold]")
DLogger._printed_projects.add(name)

return Status(
f"[bold bright_blue]> {message}[/bold bright_blue]",
spinner=spinner,
console=rich_console,
)

@classmethod
def reset_projects(cls) -> None:
"""Clear the record of printed project names."""
cls._printed_projects.clear()


class ExtLogFilter(logging.Filter): # pylint: disable=too-few-public-methods
"""Adds indentation to all log messages that pass through this filter."""

def __init__(self, prefix: str = " "):
"""Initialize the ExtLogFilter with a prefix."""
super().__init__()
self.prefix = prefix

def filter(self, record: logging.LogRecord) -> bool:
"""Add indentation to the log record message."""
color = "blue" if record.levelno < logging.WARNING else "yellow"

line = record.msg.replace("\n", "\n ")
record.msg = f"{self.prefix}[{color}]{line}[/{color}]"
return True


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()
logger.addFilter(ExtLogFilter())
Loading
Loading