From 303b580312a1858bc0148589c1db4c77f16a1375 Mon Sep 17 00:00:00 2001 From: dylan-robins Date: Sun, 24 Aug 2025 17:41:04 +0200 Subject: [PATCH 1/9] feat: Added basic integration with the built-in logging package --- src/cleo/application.py | 23 ++++++++++++ src/cleo/io/output_handler.py | 46 +++++++++++++++++++++++ tests/fixtures/foo4_command.py | 26 +++++++++++++ tests/test_application.py | 68 ++++++++++++++++++++++++++++++++++ 4 files changed, 163 insertions(+) create mode 100644 src/cleo/io/output_handler.py create mode 100644 tests/fixtures/foo4_command.py diff --git a/src/cleo/application.py b/src/cleo/application.py index b02e0d00..69b7b9ed 100644 --- a/src/cleo/application.py +++ b/src/cleo/application.py @@ -1,5 +1,6 @@ from __future__ import annotations +import logging import os import re import sys @@ -27,6 +28,7 @@ from cleo.io.inputs.definition import Definition from cleo.io.inputs.option import Option from cleo.io.io import IO +from cleo.io.output_handler import OutputHandler from cleo.io.outputs.output import Verbosity from cleo.io.outputs.stream_output import StreamOutput from cleo.terminal import Terminal @@ -309,6 +311,7 @@ def run( io = self.create_io(input, output, error_output) self._configure_io(io) + self._configure_logging(io) try: exit_code = self._run(io) @@ -530,6 +533,26 @@ def _configure_io(self, io: IO) -> None: if shell_verbosity == -1: io.interactive(False) + def _configure_logging(self, io: IO) -> None: + """ + Configures the built-in logging package to write it's output via Cleo's output class. + """ + root = logging.getLogger() + + level_mapping = { + 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, + } + + root.setLevel(level_mapping[io.output.verbosity]) + + handler = OutputHandler(io.output) + handler.setLevel(level_mapping[io.output.verbosity]) + root.addHandler(handler) + @property def _default_definition(self) -> Definition: return Definition( diff --git a/src/cleo/io/output_handler.py b/src/cleo/io/output_handler.py new file mode 100644 index 00000000..1ff53a90 --- /dev/null +++ b/src/cleo/io/output_handler.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +import logging + +from typing import TYPE_CHECKING +from typing import ClassVar + + +if TYPE_CHECKING: + from cleo.io.outputs.output import Output + + +class OutputHandler(logging.Handler): + """ + A handler class which writes logging records, appropriately formatted, + to a Cleo output stream. + """ + + tags: ClassVar[dict[str, str]] = { + "CRITICAL": "", + "ERROR": "", + "WARNING": "", + "DEBUG": "", + } + + def __init__(self, output: Output): + super().__init__() + self.output = output + + def emit(self, record: logging.LogRecord): + """ + 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) + + except Exception: + self.handleError(record) diff --git a/tests/fixtures/foo4_command.py b/tests/fixtures/foo4_command.py new file mode 100644 index 00000000..cdd265cb --- /dev/null +++ b/tests/fixtures/foo4_command.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +import logging + +from typing import ClassVar + +from cleo.commands.command import Command + + +def log_stuff(): + logging.debug("This is an debug log record") + logging.info("This is an info log record") + logging.warning("This is an warning log record") + logging.error("This is an error log record") + + +class Foo4Command(Command): + name = "foo4" + + description = "The foo4 bar command" + + aliases: ClassVar[list[str]] = ["foo4"] + + def handle(self) -> int: + log_stuff() + return 0 diff --git a/tests/test_application.py b/tests/test_application.py index f08286a4..e46986ec 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -17,6 +17,7 @@ from tests.fixtures.foo1_command import Foo1Command from tests.fixtures.foo2_command import Foo2Command from tests.fixtures.foo3_command import Foo3Command +from tests.fixtures.foo4_command import Foo4Command from tests.fixtures.foo_command import FooCommand from tests.fixtures.foo_sub_namespaced1_command import FooSubNamespaced1Command from tests.fixtures.foo_sub_namespaced2_command import FooSubNamespaced2Command @@ -380,3 +381,70 @@ def test_run_with_input_and_non_interactive(cmd: Command) -> None: assert status_code == 0 assert tester.io.fetch_output() == "default input\n" + + +@pytest.mark.parametrize("cmd", (Foo4Command(),)) +def test_run_with_logging_integration_normal(cmd: Command) -> None: + app = Application() + + app.add(cmd) + + tester = ApplicationTester(app) + status_code = tester.execute(f"{cmd.name}") + + expected = "This is an warning log record\n" "This is an error log record\n" + + assert status_code == 0 + assert tester.io.fetch_output() == expected + + +@pytest.mark.parametrize("cmd", (Foo4Command(),)) +def test_run_with_logging_integration_quiet(cmd: Command) -> None: + app = Application() + + app.add(cmd) + + tester = ApplicationTester(app) + status_code = tester.execute(f"{cmd.name} -q") + + assert status_code == 0 + assert tester.io.fetch_output() == "" + + +@pytest.mark.parametrize("cmd", (Foo4Command(),)) +def test_run_with_logging_integration_verbose(cmd: Command) -> None: + app = Application() + + app.add(cmd) + + tester = ApplicationTester(app) + status_code = tester.execute(f"{cmd.name} -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 + + +@pytest.mark.parametrize("cmd", (Foo4Command(),)) +def test_run_with_logging_integration_very_verbose(cmd: Command) -> None: + app = Application() + + app.add(cmd) + + tester = ApplicationTester(app) + status_code = tester.execute(f"{cmd.name} -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 From d62f31e82163f911e95abcd72b17cb2c4f56186c Mon Sep 17 00:00:00 2001 From: dylan-robins Date: Sun, 24 Aug 2025 17:41:04 +0200 Subject: [PATCH 2/9] misc: Added news entry --- news/440.feat.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 news/440.feat.md diff --git a/news/440.feat.md b/news/440.feat.md new file mode 100644 index 00000000..da14b0ed --- /dev/null +++ b/news/440.feat.md @@ -0,0 +1 @@ +Added integration with the built-in logging package. From 6ebd066d069725339d434c237be7cef5652173a5 Mon Sep 17 00:00:00 2001 From: dylan-robins Date: Sun, 24 Aug 2025 17:41:04 +0200 Subject: [PATCH 3/9] fix: Added missing type annotations --- src/cleo/io/output_handler.py | 2 +- tests/fixtures/foo4_command.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cleo/io/output_handler.py b/src/cleo/io/output_handler.py index 1ff53a90..63162c37 100644 --- a/src/cleo/io/output_handler.py +++ b/src/cleo/io/output_handler.py @@ -27,7 +27,7 @@ def __init__(self, output: Output): super().__init__() self.output = output - def emit(self, record: logging.LogRecord): + def emit(self, record: logging.LogRecord) -> None: """ Emit a record. diff --git a/tests/fixtures/foo4_command.py b/tests/fixtures/foo4_command.py index cdd265cb..02bfcdca 100644 --- a/tests/fixtures/foo4_command.py +++ b/tests/fixtures/foo4_command.py @@ -7,7 +7,7 @@ from cleo.commands.command import Command -def log_stuff(): +def log_stuff() -> None: logging.debug("This is an debug log record") logging.info("This is an info log record") logging.warning("This is an warning log record") From 441c2270cdb67bffc63d2fce85d89b5cb7f0513c Mon Sep 17 00:00:00 2001 From: dylan-robins Date: Sun, 24 Aug 2025 17:41:04 +0200 Subject: [PATCH 4/9] Moved logging-related code to `cleo.logging` package, tests to test_logging.py, and renamed Handler subclass to CleoHandler --- src/cleo/application.py | 18 +---- src/cleo/logging/__init__.py | 0 .../cleo_handler.py} | 18 ++++- tests/test_application.py | 68 ----------------- tests/test_logging.py | 76 +++++++++++++++++++ 5 files changed, 97 insertions(+), 83 deletions(-) create mode 100644 src/cleo/logging/__init__.py rename src/cleo/{io/output_handler.py => logging/cleo_handler.py} (63%) create mode 100644 tests/test_logging.py diff --git a/src/cleo/application.py b/src/cleo/application.py index 69b7b9ed..0846cf2a 100644 --- a/src/cleo/application.py +++ b/src/cleo/application.py @@ -28,9 +28,9 @@ from cleo.io.inputs.definition import Definition from cleo.io.inputs.option import Option from cleo.io.io import IO -from cleo.io.output_handler import OutputHandler from cleo.io.outputs.output import Verbosity from cleo.io.outputs.stream_output import StreamOutput +from cleo.logging.cleo_handler import CleoHandler from cleo.terminal import Terminal from cleo.ui.ui import UI @@ -537,21 +537,11 @@ def _configure_logging(self, io: IO) -> None: """ Configures the built-in logging package to write it's output via Cleo's output class. """ + handler = CleoHandler(io.output) + handler.setLevel(io.output.verbosity) root = logging.getLogger() - - level_mapping = { - 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, - } - - root.setLevel(level_mapping[io.output.verbosity]) - - handler = OutputHandler(io.output) - handler.setLevel(level_mapping[io.output.verbosity]) root.addHandler(handler) + root.setLevel(handler.level) @property def _default_definition(self) -> Definition: diff --git a/src/cleo/logging/__init__.py b/src/cleo/logging/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/src/cleo/io/output_handler.py b/src/cleo/logging/cleo_handler.py similarity index 63% rename from src/cleo/io/output_handler.py rename to src/cleo/logging/cleo_handler.py index 63162c37..a5851c8a 100644 --- a/src/cleo/io/output_handler.py +++ b/src/cleo/logging/cleo_handler.py @@ -5,12 +5,14 @@ from typing import TYPE_CHECKING from typing import ClassVar +from cleo.io.outputs.output import Verbosity + if TYPE_CHECKING: from cleo.io.outputs.output import Output -class OutputHandler(logging.Handler): +class CleoHandler(logging.Handler): """ A handler class which writes logging records, appropriately formatted, to a Cleo output stream. @@ -44,3 +46,17 @@ def emit(self, record: logging.LogRecord) -> None: except Exception: self.handleError(record) + + def setLevel(self, verbosity: Verbosity) -> None: # noqa: N802 + """ + Set the logging level of this handler. verbosity must be an instance of Cleo's Verbosity enum. + This level is then mapped to it's corresponding `logging` level. + """ + level_mapping = { + 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 super().setLevel(level_mapping[verbosity]) diff --git a/tests/test_application.py b/tests/test_application.py index e46986ec..f08286a4 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -17,7 +17,6 @@ from tests.fixtures.foo1_command import Foo1Command from tests.fixtures.foo2_command import Foo2Command from tests.fixtures.foo3_command import Foo3Command -from tests.fixtures.foo4_command import Foo4Command from tests.fixtures.foo_command import FooCommand from tests.fixtures.foo_sub_namespaced1_command import FooSubNamespaced1Command from tests.fixtures.foo_sub_namespaced2_command import FooSubNamespaced2Command @@ -381,70 +380,3 @@ def test_run_with_input_and_non_interactive(cmd: Command) -> None: assert status_code == 0 assert tester.io.fetch_output() == "default input\n" - - -@pytest.mark.parametrize("cmd", (Foo4Command(),)) -def test_run_with_logging_integration_normal(cmd: Command) -> None: - app = Application() - - app.add(cmd) - - tester = ApplicationTester(app) - status_code = tester.execute(f"{cmd.name}") - - expected = "This is an warning log record\n" "This is an error log record\n" - - assert status_code == 0 - assert tester.io.fetch_output() == expected - - -@pytest.mark.parametrize("cmd", (Foo4Command(),)) -def test_run_with_logging_integration_quiet(cmd: Command) -> None: - app = Application() - - app.add(cmd) - - tester = ApplicationTester(app) - status_code = tester.execute(f"{cmd.name} -q") - - assert status_code == 0 - assert tester.io.fetch_output() == "" - - -@pytest.mark.parametrize("cmd", (Foo4Command(),)) -def test_run_with_logging_integration_verbose(cmd: Command) -> None: - app = Application() - - app.add(cmd) - - tester = ApplicationTester(app) - status_code = tester.execute(f"{cmd.name} -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 - - -@pytest.mark.parametrize("cmd", (Foo4Command(),)) -def test_run_with_logging_integration_very_verbose(cmd: Command) -> None: - app = Application() - - app.add(cmd) - - tester = ApplicationTester(app) - status_code = tester.execute(f"{cmd.name} -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 diff --git a/tests/test_logging.py b/tests/test_logging.py new file mode 100644 index 00000000..8eb7f24a --- /dev/null +++ b/tests/test_logging.py @@ -0,0 +1,76 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest + +from cleo.application import Application +from cleo.testers.application_tester import ApplicationTester +from tests.fixtures.foo4_command import Foo4Command + + +if TYPE_CHECKING: + from cleo.commands.command import Command + + +@pytest.mark.parametrize("cmd", (Foo4Command(),)) +def test_run_with_logging_integration_normal(cmd: Command) -> None: + app = Application() + app.add(cmd) + + tester = ApplicationTester(app) + status_code = tester.execute(f"{cmd.name}") + + expected = "This is an warning log record\n" "This is an error log record\n" + + assert status_code == 0 + assert tester.io.fetch_output() == expected + + +@pytest.mark.parametrize("cmd", (Foo4Command(),)) +def test_run_with_logging_integration_quiet(cmd: Command) -> None: + app = Application() + app.add(cmd) + + tester = ApplicationTester(app) + status_code = tester.execute(f"{cmd.name} -q") + + assert status_code == 0 + assert tester.io.fetch_output() == "" + + +@pytest.mark.parametrize("cmd", (Foo4Command(),)) +def test_run_with_logging_integration_verbose(cmd: Command) -> None: + app = Application() + app.add(cmd) + + tester = ApplicationTester(app) + status_code = tester.execute(f"{cmd.name} -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 + + +@pytest.mark.parametrize("cmd", (Foo4Command(),)) +def test_run_with_logging_integration_very_verbose(cmd: Command) -> None: + app = Application() + app.add(cmd) + + tester = ApplicationTester(app) + status_code = tester.execute(f"{cmd.name} -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 From 60c04725dfd2f400aba22e18634a5aa67ef47b6c Mon Sep 17 00:00:00 2001 From: dylan-robins Date: Sun, 24 Aug 2025 17:41:04 +0200 Subject: [PATCH 5/9] Cleaned up verbosity remapping system --- src/cleo/application.py | 3 ++- src/cleo/logging/cleo_handler.py | 11 ++++------- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/cleo/application.py b/src/cleo/application.py index 0846cf2a..26eea80e 100644 --- a/src/cleo/application.py +++ b/src/cleo/application.py @@ -538,7 +538,8 @@ def _configure_logging(self, io: IO) -> None: Configures the built-in logging package to write it's output via Cleo's output class. """ handler = CleoHandler(io.output) - handler.setLevel(io.output.verbosity) + handler.setLevel(handler.remap_verbosity(io.output.verbosity)) + root = logging.getLogger() root.addHandler(handler) root.setLevel(handler.level) diff --git a/src/cleo/logging/cleo_handler.py b/src/cleo/logging/cleo_handler.py index a5851c8a..43c8d14e 100644 --- a/src/cleo/logging/cleo_handler.py +++ b/src/cleo/logging/cleo_handler.py @@ -47,16 +47,13 @@ def emit(self, record: logging.LogRecord) -> None: except Exception: self.handleError(record) - def setLevel(self, verbosity: Verbosity) -> None: # noqa: N802 - """ - Set the logging level of this handler. verbosity must be an instance of Cleo's Verbosity enum. - This level is then mapped to it's corresponding `logging` level. - """ - level_mapping = { + @staticmethod + def remap_verbosity(verbosity: Verbosity) -> 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 super().setLevel(level_mapping[verbosity]) + return verbosity_mapping[verbosity] From c8decb500168af4903aca709250b3b50a0be0259 Mon Sep 17 00:00:00 2001 From: dylan-robins Date: Sun, 24 Aug 2025 17:41:04 +0200 Subject: [PATCH 6/9] Made logging integration entirely opt-in and configurable on the user-side --- src/cleo/application.py | 14 ------ src/cleo/logging/cleo_handler.py | 33 ++++++++++----- tests/fixtures/foo4_command.py | 11 +++-- tests/test_logging.py | 73 ++++++++++++++++++++------------ 4 files changed, 75 insertions(+), 56 deletions(-) diff --git a/src/cleo/application.py b/src/cleo/application.py index 26eea80e..b02e0d00 100644 --- a/src/cleo/application.py +++ b/src/cleo/application.py @@ -1,6 +1,5 @@ from __future__ import annotations -import logging import os import re import sys @@ -30,7 +29,6 @@ from cleo.io.io import IO from cleo.io.outputs.output import Verbosity from cleo.io.outputs.stream_output import StreamOutput -from cleo.logging.cleo_handler import CleoHandler from cleo.terminal import Terminal from cleo.ui.ui import UI @@ -311,7 +309,6 @@ def run( io = self.create_io(input, output, error_output) self._configure_io(io) - self._configure_logging(io) try: exit_code = self._run(io) @@ -533,17 +530,6 @@ def _configure_io(self, io: IO) -> None: if shell_verbosity == -1: io.interactive(False) - def _configure_logging(self, io: IO) -> None: - """ - Configures the built-in logging package to write it's output via Cleo's output class. - """ - handler = CleoHandler(io.output) - handler.setLevel(handler.remap_verbosity(io.output.verbosity)) - - root = logging.getLogger() - root.addHandler(handler) - root.setLevel(handler.level) - @property def _default_definition(self) -> Definition: return Definition( diff --git a/src/cleo/logging/cleo_handler.py b/src/cleo/logging/cleo_handler.py index 43c8d14e..9c0f87a4 100644 --- a/src/cleo/logging/cleo_handler.py +++ b/src/cleo/logging/cleo_handler.py @@ -2,6 +2,7 @@ import logging +from logging import LogRecord from typing import TYPE_CHECKING from typing import ClassVar @@ -12,6 +13,25 @@ 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, @@ -28,6 +48,7 @@ class CleoHandler(logging.Handler): def __init__(self, output: Output): super().__init__() self.output = output + self.addFilter(CleoFilter(output)) def emit(self, record: logging.LogRecord) -> None: """ @@ -40,20 +61,10 @@ def emit(self, record: logging.LogRecord) -> None: 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) except Exception: self.handleError(record) - - @staticmethod - def remap_verbosity(verbosity: Verbosity) -> 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[verbosity] diff --git a/tests/fixtures/foo4_command.py b/tests/fixtures/foo4_command.py index 02bfcdca..35c35e96 100644 --- a/tests/fixtures/foo4_command.py +++ b/tests/fixtures/foo4_command.py @@ -7,11 +7,14 @@ from cleo.commands.command import Command +_logger = logging.getLogger(__file__) + + def log_stuff() -> None: - logging.debug("This is an debug log record") - logging.info("This is an info log record") - logging.warning("This is an warning log record") - logging.error("This is an error log record") + _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") class Foo4Command(Command): diff --git a/tests/test_logging.py b/tests/test_logging.py index 8eb7f24a..1263951c 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -1,25 +1,44 @@ from __future__ import annotations -from typing import TYPE_CHECKING +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 -if TYPE_CHECKING: - from cleo.commands.command import Command - - -@pytest.mark.parametrize("cmd", (Foo4Command(),)) -def test_run_with_logging_integration_normal(cmd: Command) -> None: +@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 + - tester = ApplicationTester(app) - status_code = tester.execute(f"{cmd.name}") +def test_run_with_logging_integration_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\n" "This is an error log record\n" @@ -27,25 +46,25 @@ def test_run_with_logging_integration_normal(cmd: Command) -> None: assert tester.io.fetch_output() == expected -@pytest.mark.parametrize("cmd", (Foo4Command(),)) -def test_run_with_logging_integration_quiet(cmd: Command) -> None: - app = Application() - app.add(cmd) +def test_run_with_logging_integration_quiet( + tester: ApplicationTester, root_logger: logging.Logger +) -> None: + handler = CleoHandler(tester.io.output) + root_logger.addHandler(handler) - tester = ApplicationTester(app) - status_code = tester.execute(f"{cmd.name} -q") + status_code = tester.execute("-q") assert status_code == 0 assert tester.io.fetch_output() == "" -@pytest.mark.parametrize("cmd", (Foo4Command(),)) -def test_run_with_logging_integration_verbose(cmd: Command) -> None: - app = Application() - app.add(cmd) +def test_run_with_logging_integration_verbose( + tester: ApplicationTester, root_logger: logging.Logger +) -> None: + handler = CleoHandler(tester.io.output) + root_logger.addHandler(handler) - tester = ApplicationTester(app) - status_code = tester.execute(f"{cmd.name} -v") + status_code = tester.execute("-v") expected = ( "This is an info log record\n" @@ -57,13 +76,13 @@ def test_run_with_logging_integration_verbose(cmd: Command) -> None: assert tester.io.fetch_output() == expected -@pytest.mark.parametrize("cmd", (Foo4Command(),)) -def test_run_with_logging_integration_very_verbose(cmd: Command) -> None: - app = Application() - app.add(cmd) +def test_run_with_logging_integration_very_verbose( + tester: ApplicationTester, root_logger: logging.Logger +) -> None: + handler = CleoHandler(tester.io.output) + root_logger.addHandler(handler) - tester = ApplicationTester(app) - status_code = tester.execute(f"{cmd.name} -vv") + status_code = tester.execute("-vv") expected = ( "This is an debug log record\n" From 4c98f77f4a3872a8b38f046162368b4acae3ff0c Mon Sep 17 00:00:00 2001 From: dylan-robins Date: Sun, 24 Aug 2025 17:41:04 +0200 Subject: [PATCH 7/9] Logger handler manages exception info using the existing ExceptionTrace class --- src/cleo/logging/cleo_handler.py | 11 ++++++ tests/fixtures/foo4_command.py | 21 +++++++++- tests/test_logging.py | 68 ++++++++++++++++++++++++++++---- 3 files changed, 91 insertions(+), 9 deletions(-) diff --git a/src/cleo/logging/cleo_handler.py b/src/cleo/logging/cleo_handler.py index 9c0f87a4..344c3e76 100644 --- a/src/cleo/logging/cleo_handler.py +++ b/src/cleo/logging/cleo_handler.py @@ -5,8 +5,11 @@ 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: @@ -65,6 +68,14 @@ def emit(self, record: logging.LogRecord) -> None: 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) diff --git a/tests/fixtures/foo4_command.py b/tests/fixtures/foo4_command.py index 35c35e96..d53c18ed 100644 --- a/tests/fixtures/foo4_command.py +++ b/tests/fixtures/foo4_command.py @@ -2,9 +2,15 @@ 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__) @@ -17,6 +23,13 @@ def log_stuff() -> None: _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" @@ -24,6 +37,12 @@ class Foo4Command(Command): aliases: ClassVar[list[str]] = ["foo4"] + options: ClassVar[list[Option]] = [option("exception")] + def handle(self) -> int: - log_stuff() + if self.option("exception"): + log_exception() + else: + log_stuff() + return 0 diff --git a/tests/test_logging.py b/tests/test_logging.py index 1263951c..45123eca 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -32,8 +32,9 @@ def root_logger() -> logging.Logger: return root -def test_run_with_logging_integration_normal( - tester: ApplicationTester, root_logger: logging.Logger +def test_cleohandler_normal( + tester: ApplicationTester, + root_logger: logging.Logger, ) -> None: handler = CleoHandler(tester.io.output) root_logger.addHandler(handler) @@ -46,8 +47,9 @@ def test_run_with_logging_integration_normal( assert tester.io.fetch_output() == expected -def test_run_with_logging_integration_quiet( - tester: ApplicationTester, root_logger: logging.Logger +def test_cleohandler_quiet( + tester: ApplicationTester, + root_logger: logging.Logger, ) -> None: handler = CleoHandler(tester.io.output) root_logger.addHandler(handler) @@ -58,8 +60,9 @@ def test_run_with_logging_integration_quiet( assert tester.io.fetch_output() == "" -def test_run_with_logging_integration_verbose( - tester: ApplicationTester, root_logger: logging.Logger +def test_cleohandler_verbose( + tester: ApplicationTester, + root_logger: logging.Logger, ) -> None: handler = CleoHandler(tester.io.output) root_logger.addHandler(handler) @@ -76,8 +79,9 @@ def test_run_with_logging_integration_verbose( assert tester.io.fetch_output() == expected -def test_run_with_logging_integration_very_verbose( - tester: ApplicationTester, root_logger: logging.Logger +def test_cleohandler_very_verbose( + tester: ApplicationTester, + root_logger: logging.Logger, ) -> None: handler = CleoHandler(tester.io.output) root_logger.addHandler(handler) @@ -93,3 +97,51 @@ def test_run_with_logging_integration_very_verbose( 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" From 678aa807dcae7805ebb3c249f873f1facbb34af6 Mon Sep 17 00:00:00 2001 From: dylan-robins Date: Sun, 24 Aug 2025 17:41:48 +0200 Subject: [PATCH 8/9] Added text styles for success, warning and debug --- src/cleo/formatters/formatter.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/cleo/formatters/formatter.py b/src/cleo/formatters/formatter.py index 0ac13118..5fbf780b 100644 --- a/src/cleo/formatters/formatter.py +++ b/src/cleo/formatters/formatter.py @@ -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")) From 7cc8288c89b7edfb57532e8d45acff3333b7b5b0 Mon Sep 17 00:00:00 2001 From: dylan-robins Date: Sun, 24 Aug 2025 18:13:25 +0200 Subject: [PATCH 9/9] refactor(logging): updated CleoHandler to use new debug/warning tags --- src/cleo/logging/cleo_handler.py | 6 +++--- tests/test_logging.py | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/cleo/logging/cleo_handler.py b/src/cleo/logging/cleo_handler.py index 344c3e76..8d0f6108 100644 --- a/src/cleo/logging/cleo_handler.py +++ b/src/cleo/logging/cleo_handler.py @@ -44,8 +44,8 @@ class CleoHandler(logging.Handler): tags: ClassVar[dict[str, str]] = { "CRITICAL": "", "ERROR": "", - "WARNING": "", - "DEBUG": "", + "WARNING": "", + "DEBUG": "", } def __init__(self, output: Output): @@ -73,7 +73,7 @@ def emit(self, record: logging.LogRecord) -> None: simple = not self.output.is_verbose() or isinstance( error, CleoUserError ) - error = cast(Exception, error) + error = cast("Exception", error) trace = ExceptionTrace(error) trace.render(self.output, simple) diff --git a/tests/test_logging.py b/tests/test_logging.py index 45123eca..8172d10a 100644 --- a/tests/test_logging.py +++ b/tests/test_logging.py @@ -41,7 +41,7 @@ def test_cleohandler_normal( status_code = tester.execute("") - expected = "This is an warning log record\n" "This is an error log record\n" + expected = "This is an warning log record\nThis is an error log record\n" assert status_code == 0 assert tester.io.fetch_output() == expected