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
1 change: 1 addition & 0 deletions news/440.feat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Added integration with the built-in logging package.
3 changes: 3 additions & 0 deletions src/cleo/formatters/formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,11 @@ def __init__(
self._decorated = decorated
self._styles: dict[str, Style] = {}

self.set_style("success", Style("green"))
self.set_style("error", Style("red", options=["bold"]))
self.set_style("warning", Style("yellow"))
self.set_style("info", Style("blue"))
self.set_style("debug", Style("default", options=["dark"]))
self.set_style("comment", Style("green"))
self.set_style("question", Style("cyan"))
self.set_style("c1", Style("cyan"))
Expand Down
Empty file added src/cleo/logging/__init__.py
Empty file.
81 changes: 81 additions & 0 deletions src/cleo/logging/cleo_handler.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
from __future__ import annotations

import logging

from logging import LogRecord
from typing import TYPE_CHECKING
from typing import ClassVar
from typing import cast

from cleo.exceptions import CleoUserError
from cleo.io.outputs.output import Verbosity
from cleo.ui.exception_trace.component import ExceptionTrace


if TYPE_CHECKING:
from cleo.io.outputs.output import Output


class CleoFilter:
def __init__(self, output: Output):
self.output = output

@property
def current_loglevel(self) -> int:
verbosity_mapping: dict[Verbosity, int] = {
Verbosity.QUIET: logging.CRITICAL, # Nothing gets emitted to the output anyway
Verbosity.NORMAL: logging.WARNING,
Verbosity.VERBOSE: logging.INFO,
Verbosity.VERY_VERBOSE: logging.DEBUG,
Verbosity.DEBUG: logging.DEBUG,
}
return verbosity_mapping[self.output.verbosity]

def filter(self, record: LogRecord) -> bool:
return record.levelno >= self.current_loglevel


class CleoHandler(logging.Handler):
"""
A handler class which writes logging records, appropriately formatted,
to a Cleo output stream.
"""

tags: ClassVar[dict[str, str]] = {
"CRITICAL": "<error>",
"ERROR": "<error>",
"WARNING": "<warning>",
"DEBUG": "<debug>",
}

def __init__(self, output: Output):
super().__init__()
self.output = output
self.addFilter(CleoFilter(output))

def emit(self, record: logging.LogRecord) -> None:
"""
Emit a record.

If a formatter is specified, it is used to format the record.
The record is then written to the output with a trailing newline. If
exception information is present, it is formatted using
traceback.print_exception and appended to the stream. If the stream
has an 'encoding' attribute, it is used to determine how to do the
output to the stream.
"""

try:
msg = self.tags.get(record.levelname, "") + self.format(record) + "</>"
self.output.write(msg, new_line=True)
if record.exc_info:
_type, error, traceback = record.exc_info
simple = not self.output.is_verbose() or isinstance(
error, CleoUserError
)
error = cast("Exception", error)
trace = ExceptionTrace(error)
trace.render(self.output, simple)

except Exception:
self.handleError(record)
48 changes: 48 additions & 0 deletions tests/fixtures/foo4_command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
from __future__ import annotations

import logging

from typing import TYPE_CHECKING
from typing import ClassVar

from cleo.commands.command import Command
from cleo.helpers import option


if TYPE_CHECKING:
from cleo.io.inputs.option import Option


_logger = logging.getLogger(__file__)


def log_stuff() -> None:
_logger.debug("This is an debug log record")
_logger.info("This is an info log record")
_logger.warning("This is an warning log record")
_logger.error("This is an error log record")


def log_exception() -> None:
try:
raise RuntimeError("This is an exception that I raised")
except RuntimeError as e:
_logger.exception(e)


class Foo4Command(Command):
name = "foo4"

description = "The foo4 bar command"

aliases: ClassVar[list[str]] = ["foo4"]

options: ClassVar[list[Option]] = [option("exception")]

def handle(self) -> int:
if self.option("exception"):
log_exception()
else:
log_stuff()

return 0
147 changes: 147 additions & 0 deletions tests/test_logging.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
from __future__ import annotations

import logging

import pytest

from cleo.application import Application
from cleo.logging.cleo_handler import CleoHandler
from cleo.testers.application_tester import ApplicationTester
from tests.fixtures.foo4_command import Foo4Command


@pytest.fixture
def app() -> Application:
app = Application()
cmd = Foo4Command()
app.add(cmd)
app._default_command = cmd.name
return app


@pytest.fixture
def tester(app: Application) -> ApplicationTester:
app.catch_exceptions(False)
return ApplicationTester(app)


@pytest.fixture
def root_logger() -> logging.Logger:
root = logging.getLogger()
root.setLevel(logging.NOTSET)
return root


def test_cleohandler_normal(
tester: ApplicationTester,
root_logger: logging.Logger,
) -> None:
handler = CleoHandler(tester.io.output)
root_logger.addHandler(handler)

status_code = tester.execute("")

expected = "This is an warning log record\nThis is an error log record\n"

assert status_code == 0
assert tester.io.fetch_output() == expected


def test_cleohandler_quiet(
tester: ApplicationTester,
root_logger: logging.Logger,
) -> None:
handler = CleoHandler(tester.io.output)
root_logger.addHandler(handler)

status_code = tester.execute("-q")

assert status_code == 0
assert tester.io.fetch_output() == ""


def test_cleohandler_verbose(
tester: ApplicationTester,
root_logger: logging.Logger,
) -> None:
handler = CleoHandler(tester.io.output)
root_logger.addHandler(handler)

status_code = tester.execute("-v")

expected = (
"This is an info log record\n"
"This is an warning log record\n"
"This is an error log record\n"
)

assert status_code == 0
assert tester.io.fetch_output() == expected


def test_cleohandler_very_verbose(
tester: ApplicationTester,
root_logger: logging.Logger,
) -> None:
handler = CleoHandler(tester.io.output)
root_logger.addHandler(handler)

status_code = tester.execute("-vv")

expected = (
"This is an debug log record\n"
"This is an info log record\n"
"This is an warning log record\n"
"This is an error log record\n"
)

assert status_code == 0
assert tester.io.fetch_output() == expected


def test_cleohandler_exception_normal(
tester: ApplicationTester,
root_logger: logging.Logger,
) -> None:
handler = CleoHandler(tester.io.output)
root_logger.addHandler(handler)

status_code = tester.execute("--exception")

assert status_code == 0
lines = tester.io.fetch_output().splitlines()

assert len(lines) == 7
assert lines[0] == "This is an exception that I raised"


def test_cleohandler_exception_verbose(
tester: ApplicationTester,
root_logger: logging.Logger,
) -> None:
handler = CleoHandler(tester.io.output)
root_logger.addHandler(handler)

status_code = tester.execute("-v --exception")

assert status_code == 0
lines = tester.io.fetch_output().splitlines()

assert len(lines) == 20
assert lines[0] == "This is an exception that I raised"


def test_cleohandler_exception_very_verbose(
tester: ApplicationTester,
root_logger: logging.Logger,
) -> None:
handler = CleoHandler(tester.io.output)
root_logger.addHandler(handler)

status_code = tester.execute("-vv --exception")

assert status_code == 0
lines = tester.io.fetch_output().splitlines()

assert len(lines) == 20
assert lines[0] == "This is an exception that I raised"