diff --git a/litestar/logging/config.py b/litestar/logging/config.py index 19615a229f..1edf357188 100644 --- a/litestar/logging/config.py +++ b/litestar/logging/config.py @@ -292,13 +292,16 @@ def configure(self) -> GetLogger: if self.logging_module == "picologging": try: - from picologging import config, getLogger + from picologging import ( # pyright: ignore[reportMissingImports,reportGeneralTypeIssues] + config, # pyright: ignore[reportMissingImports,reportGeneralTypeIssues] + getLogger, # pyright: ignore[reportMissingImports,reportGeneralTypeIssues] + ) except ImportError as e: raise MissingDependencyException("picologging") from e excluded_fields.add("incremental") else: - from logging import config, getLogger # type: ignore[no-redef, assignment] + from logging import config, getLogger # type: ignore[no-redef,assignment,unused-ignore] values = { _field.name: getattr(self, _field.name) diff --git a/litestar/logging/picologging.py b/litestar/logging/picologging.py index 2cd599f463..e07c074283 100644 --- a/litestar/logging/picologging.py +++ b/litestar/logging/picologging.py @@ -11,15 +11,15 @@ try: - import picologging # noqa: F401 + import picologging # noqa: F401 # pyright: ignore[reportMissingImports] except ImportError as e: raise MissingDependencyException("picologging") from e -from picologging import StreamHandler -from picologging.handlers import QueueHandler, QueueListener +from picologging import StreamHandler # pyright: ignore[reportMissingImports] +from picologging.handlers import QueueHandler, QueueListener # pyright: ignore[reportMissingImports] -class QueueListenerHandler(QueueHandler): +class QueueListenerHandler(QueueHandler): # type: ignore[misc,unused-ignore] """Configure queue listener and handler to support non-blocking logging configuration.""" def __init__(self, handlers: list[Any] | None = None) -> None: @@ -32,8 +32,8 @@ def __init__(self, handlers: list[Any] | None = None) -> None: - Requires ``picologging`` to be installed. """ super().__init__(Queue(-1)) - handlers = resolve_handlers(handlers) if handlers else [StreamHandler()] - self.listener = QueueListener(self.queue, *handlers) + handlers = resolve_handlers(handlers) if handlers else [StreamHandler()] # pyright: ignore[reportGeneralTypeIssues] + self.listener = QueueListener(self.queue, *handlers) # pyright: ignore[reportGeneralTypeIssues] self.listener.start() atexit.register(self.listener.stop) diff --git a/tests/helpers.py b/tests/helpers.py index b541ed28ec..353a592eb5 100644 --- a/tests/helpers.py +++ b/tests/helpers.py @@ -1,14 +1,16 @@ from __future__ import annotations import atexit +import importlib.util import inspect import logging import random import sys from contextlib import AbstractContextManager, contextmanager +from pathlib import Path from typing import Any, AsyncContextManager, Awaitable, ContextManager, Generator, TypeVar, cast, overload -import picologging +import pytest from _pytest.logging import LogCaptureHandler, _LiveLoggingNullHandler from litestar._openapi.schema_generation import SchemaCreator @@ -90,9 +92,9 @@ def cleanup_logging_impl() -> Generator: # Don't interfere with PyTest handler config if not isinstance(std_handler, (_LiveLoggingNullHandler, LogCaptureHandler)): std_root_logger.removeHandler(std_handler) - + picologging = pytest.importorskip("picologging") # Reset root logger (`picologging` module) - pico_root_logger: picologging.Logger = picologging.getLogger() + pico_root_logger: picologging.Logger = picologging.getLogger() # type: ignore[name-defined,unused-ignore] # pyright: ignore[reportPrivateUsage,reportGeneralTypeIssues,reportAssignmentType,reportInvalidTypeForm] for pico_handler in pico_root_logger.handlers: pico_root_logger.removeHandler(pico_handler) @@ -111,3 +113,10 @@ def cleanup_logging_impl() -> Generator: def not_none(val: T | None) -> T: assert val is not None return val + + +def purge_module(module_names: list[str], path: str | Path) -> None: + for name in module_names: + if name in sys.modules: + del sys.modules[name] + Path(importlib.util.cache_from_source(path)).unlink(missing_ok=True) # type: ignore[arg-type] diff --git a/tests/unit/test_logging/test_logging_config.py b/tests/unit/test_logging/test_logging_config.py index c4b73d8994..3423184efc 100644 --- a/tests/unit/test_logging/test_logging_config.py +++ b/tests/unit/test_logging/test_logging_config.py @@ -1,14 +1,13 @@ +import importlib import logging import sys import time from importlib.util import find_spec from logging.handlers import QueueHandler from queue import Queue -from types import ModuleType -from typing import TYPE_CHECKING, Any, Dict, Generator, Optional +from typing import TYPE_CHECKING, Any, Dict, Generator, Optional, Union, cast from unittest.mock import patch -import picologging import pytest from _pytest.logging import LogCaptureHandler, _LiveLoggingNullHandler @@ -21,9 +20,6 @@ default_handlers, default_picologging_handlers, ) -from litestar.logging.picologging import QueueListenerHandler as PicologgingQueueListenerHandler -from litestar.logging.standard import LoggingQueueListener -from litestar.logging.standard import QueueListenerHandler as StandardQueueListenerHandler from litestar.status_codes import HTTP_200_OK from litestar.testing import create_test_client from tests.helpers import cleanup_logging_impl @@ -34,6 +30,7 @@ @pytest.fixture(autouse=True) def cleanup_logging() -> Generator: + _ = pytest.importorskip("picologging") with cleanup_logging_impl(): yield @@ -123,23 +120,54 @@ def test_dictconfig_on_startup(logging_module: str, dict_config_not_called: str) @pytest.mark.parametrize( - "logging_module, expected_handler_class, expected_listener_class", + "logging_module_str, expected_handler_class_str, expected_listener_class_str", [ [ - logging, - QueueHandler if sys.version_info >= (3, 12, 0) else StandardQueueListenerHandler, - LoggingQueueListener, + "logging", + "logging.handlers.QueueHandler" + if sys.version_info >= (3, 12, 0) + else "litestar.logging.standard.QueueListenerHandler", + "litestar.logging.standard.LoggingQueueListener", ], [ - picologging, - PicologgingQueueListenerHandler, - picologging.handlers.QueueListener, # pyright: ignore[reportGeneralTypeIssues] + "picologging", + "litestar.logging.picologging.QueueListenerHandler", + "picologging.handlers.QueueListener", # pyright: ignore[reportGeneralTypeIssues,reportAttributeAccessIssue] ], ], ) def test_default_queue_listener_handler( - logging_module: ModuleType, expected_handler_class: Any, expected_listener_class: Any, capsys: "CaptureFixture[str]" + logging_module_str: str, + expected_handler_class_str: Union[str, Any], + expected_listener_class_str: str, + capsys: "CaptureFixture[str]", ) -> None: + logging_module = importlib.import_module(logging_module_str) + if expected_handler_class_str == "litestar.logging.standard.QueueListenerHandler": + from litestar.logging.standard import QueueListenerHandler + + expected_handler_class = QueueListenerHandler + elif expected_handler_class_str == "litestar.logging.picologging.QueueListenerHandler": + from litestar.logging.picologging import QueueListenerHandler + + expected_handler_class = QueueListenerHandler + elif expected_handler_class_str == "logging.handlers.QueueHandler": + from logging.handlers import QueueHandler as QueueListenerHandler + + expected_handler_class = QueueListenerHandler + else: + expected_handler_class = importlib.import_module(expected_handler_class_str) + if expected_listener_class_str == "litestar.logging.standard.LoggingQueueListener": + from litestar.logging.standard import LoggingQueueListener + + expected_listener_class = LoggingQueueListener + elif expected_listener_class_str == "picologging.handlers.QueueListener": + from picologging.handlers import QueueListener # pyright: ignore[reportMissingImports] + + expected_listener_class = QueueListener + else: + expected_listener_class = importlib.import_module(expected_listener_class_str) + def wait_log_queue(queue: Any, sleep_time: float = 0.1, max_retries: int = 5) -> None: retry = 0 while queue.qsize() > 0 and retry < max_retries: @@ -168,7 +196,7 @@ def assert_log(queue: Any, expected: str, count: Optional[int] = None) -> None: logger = get_logger("test_logger") assert type(logger) is logging_module.Logger - handler = logger.handlers[0] # pyright: ignore[reportGeneralTypeIssues] + handler = logger.handlers[0] # pyright: ignore[reportGeneralTypeIssues,reportAttributeAccessIssue] assert type(handler) is expected_handler_class assert type(handler.queue) is Queue @@ -193,14 +221,34 @@ def test_get_logger_without_logging_config() -> None: @pytest.mark.parametrize( - "logging_module, expected_handler_class", + "logging_module_str, expected_handler_class_str", [ - [logging, QueueHandler if sys.version_info >= (3, 12, 0) else StandardQueueListenerHandler], - [picologging, PicologgingQueueListenerHandler], + [ + "logging", + "logging.handlers.QueueHandler" + if sys.version_info >= (3, 12, 0) + else "litestar.logging.standard.QueueListenerHandler", + ], + ["picologging", "litestar.logging.picologging.QueueListenerHandler"], ], ) -def test_default_loggers(logging_module: ModuleType, expected_handler_class: Any) -> None: - with create_test_client(logging_config=LoggingConfig(logging_module=logging_module.__name__)) as client: +def test_default_loggers(logging_module_str: str, expected_handler_class_str: str) -> None: + logging_module = importlib.import_module(logging_module_str) + if expected_handler_class_str == "litestar.logging.standard.QueueListenerHandler": + from litestar.logging.standard import QueueListenerHandler + + expected_handler_class = QueueListenerHandler + elif expected_handler_class_str == "litestar.logging.picologging.QueueListenerHandler": + from litestar.logging.picologging import QueueListenerHandler + + expected_handler_class = QueueListenerHandler + elif expected_handler_class_str == "logging.handlers.QueueHandler": + from logging.handlers import QueueHandler as QueueListenerHandler + + expected_handler_class = QueueListenerHandler + else: + expected_handler_class = importlib.import_module(expected_handler_class_str) + with create_test_client(logging_config=LoggingConfig(logging_module=logging_module_str)) as client: root_logger = client.app.get_logger() assert isinstance(root_logger, logging_module.Logger) assert root_logger.name == "root" @@ -216,28 +264,50 @@ def test_default_loggers(logging_module: ModuleType, expected_handler_class: Any @pytest.mark.parametrize( - "logging_module, expected_handler_class", + "logging_module_str, expected_handler_class_str", [ - ["logging", QueueHandler if sys.version_info >= (3, 12, 0) else StandardQueueListenerHandler], - ["picologging", PicologgingQueueListenerHandler], + [ + "logging", + "logging.handlers.QueueHandler" + if sys.version_info >= (3, 12, 0) + else "litestar.logging.standard.QueueListenerHandler", + ], + ["picologging", "litestar.logging.picologging.QueueListenerHandler"], ], ) -def test_connection_logger(logging_module: str, expected_handler_class: Any) -> None: +def test_connection_logger(logging_module_str: str, expected_handler_class_str: str) -> None: + logging_module = importlib.import_module(logging_module_str) + if expected_handler_class_str == "litestar.logging.standard.QueueListenerHandler": + from litestar.logging.standard import QueueListenerHandler + + expected_handler_class = QueueListenerHandler + elif expected_handler_class_str == "litestar.logging.picologging.QueueListenerHandler": + from litestar.logging.picologging import QueueListenerHandler + + expected_handler_class = QueueListenerHandler + elif expected_handler_class_str == "logging.handlers.QueueHandler": + from logging.handlers import QueueHandler as QueueListenerHandler + + expected_handler_class = QueueListenerHandler + else: + expected_handler_class = importlib.import_module(expected_handler_class_str) + @get("/") def handler(request: Request) -> Dict[str, bool]: return {"isinstance": isinstance(request.logger.handlers[0], expected_handler_class)} # type: ignore[attr-defined] with create_test_client( route_handlers=[handler], - logging_config=LoggingConfig(logging_module=logging_module), + logging_config=LoggingConfig(logging_module=logging_module.__name__), ) as client: response = client.get("/") assert response.status_code == HTTP_200_OK assert response.json()["isinstance"] -@pytest.mark.parametrize("logging_module", [logging, picologging, None]) -def test_validation(logging_module: Optional[ModuleType]) -> None: +@pytest.mark.parametrize("logging_module_str", ["logging", "picologging", None]) +def test_validation(logging_module_str: Optional[str]) -> None: + logging_module = importlib.import_module(logging_module_str) if logging_module_str else None if logging_module is None: logging_config = LoggingConfig( formatters={}, @@ -267,32 +337,54 @@ def test_validation(logging_module: Optional[ModuleType]) -> None: @pytest.mark.parametrize( - "logging_module, expected_handler_class", + "logging_module_str, expected_handler_class_str", [ - [logging, QueueHandler if sys.version_info >= (3, 12, 0) else StandardQueueListenerHandler], - [picologging, PicologgingQueueListenerHandler], + [ + "logging", + "logging.handlers.QueueHandler" + if sys.version_info >= (3, 12, 0) + else "litestar.logging.standard.QueueListenerHandler", + ], + ["picologging", "litestar.logging.picologging.QueueListenerHandler"], ], ) -def test_root_logger(logging_module: ModuleType, expected_handler_class: Any) -> None: +def test_root_logger(logging_module_str: str, expected_handler_class_str: str) -> None: + logging_module = importlib.import_module(logging_module_str) + if expected_handler_class_str == "litestar.logging.standard.QueueListenerHandler": + from litestar.logging.standard import QueueListenerHandler + + expected_handler_class = QueueListenerHandler + elif expected_handler_class_str == "litestar.logging.picologging.QueueListenerHandler": + from litestar.logging.picologging import QueueListenerHandler + + expected_handler_class = QueueListenerHandler + elif expected_handler_class_str == "logging.handlers.QueueHandler": + from logging.handlers import QueueHandler as QueueListenerHandler + + expected_handler_class = QueueListenerHandler + else: + expected_handler_class = importlib.import_module(expected_handler_class_str) + logging_config = LoggingConfig(logging_module=logging_module.__name__) get_logger = logging_config.configure() root_logger = get_logger() assert root_logger.name == "root" # type: ignore[attr-defined] assert isinstance(root_logger, logging_module.Logger) - root_logger_handler = root_logger.handlers[0] # pyright: ignore[reportGeneralTypeIssues] + root_logger_handler = root_logger.handlers[0] # pyright: ignore[reportGeneralTypeIssues,reportAttributeAccessIssue] assert root_logger_handler.name == "queue_listener" - assert isinstance(root_logger_handler, expected_handler_class) + assert isinstance(root_logger_handler, cast("Any", expected_handler_class)) -@pytest.mark.parametrize("logging_module", [logging, picologging]) -def test_root_logger_no_config(logging_module: ModuleType) -> None: - logging_config = LoggingConfig(logging_module=logging_module.__name__, configure_root_logger=False) +@pytest.mark.parametrize("logging_module_str", ["logging", "picologging"]) +def test_root_logger_no_config(logging_module_str: str) -> None: + logging_module = importlib.import_module(logging_module_str) + logging_config = LoggingConfig(logging_module=logging_module_str, configure_root_logger=False) get_logger = logging_config.configure() root_logger = get_logger() assert isinstance(root_logger, logging_module.Logger) - handlers = root_logger.handlers # pyright: ignore[reportGeneralTypeIssues] + handlers = root_logger.handlers # pyright: ignore[reportGeneralTypeIssues,reportAttributeAccessIssue] if logging_module == logging: # pytest automatically configures some handlers for handler in handlers: @@ -302,20 +394,44 @@ def test_root_logger_no_config(logging_module: ModuleType) -> None: @pytest.mark.parametrize( - "logging_module, configure_root_logger, expected_root_logger_handler_class", + "logging_module_str, configure_root_logger, expected_root_logger_handler_class_str", [ - [logging, True, QueueHandler if sys.version_info >= (3, 12, 0) else StandardQueueListenerHandler], - [logging, False, None], - [picologging, True, PicologgingQueueListenerHandler], - [picologging, False, None], + [ + "logging", + True, + "logging.handlers.QueueHandler" + if sys.version_info >= (3, 12, 0) + else "litestar.logging.standard.QueueListenerHandler", + ], + ["logging", False, None], + ["picologging", True, "litestar.logging.picologging.QueueListenerHandler"], + ["picologging", False, None], ], ) def test_customizing_handler( - logging_module: ModuleType, + logging_module_str: str, configure_root_logger: bool, - expected_root_logger_handler_class: Any, + expected_root_logger_handler_class_str: "Optional[str]", capsys: "CaptureFixture[str]", ) -> None: + logging_module = importlib.import_module(logging_module_str) + if expected_root_logger_handler_class_str is None: + expected_root_logger_handler_class = None + elif expected_root_logger_handler_class_str == "litestar.logging.standard.QueueListenerHandler": + from litestar.logging.standard import QueueListenerHandler + + expected_root_logger_handler_class = QueueListenerHandler + elif expected_root_logger_handler_class_str == "litestar.logging.picologging.QueueListenerHandler": + from litestar.logging.picologging import QueueListenerHandler + + expected_root_logger_handler_class = QueueListenerHandler + elif expected_root_logger_handler_class_str == "logging.handlers.QueueHandler": + from logging.handlers import QueueHandler as QueueListenerHandler + + expected_root_logger_handler_class = QueueListenerHandler + else: + expected_root_logger_handler_class = importlib.import_module(expected_root_logger_handler_class_str) + log_format = "%(levelname)s :: %(name)s :: %(message)s" logging_config = LoggingConfig( @@ -348,7 +464,7 @@ def test_customizing_handler( # picologging seems to be broken, cannot make it log on stdout? # https://github.com/microsoft/picologging/issues/205 - if logging_module == picologging: + if logging_module_str == "picologging": del logging_config.handlers["console_stdout"]["stream"] get_logger = logging_config.configure() @@ -356,9 +472,9 @@ def test_customizing_handler( if configure_root_logger is True: assert isinstance(root_logger, logging_module.Logger) - assert root_logger.level == logging_module.INFO # pyright: ignore[reportGeneralTypeIssues] + assert root_logger.level == logging_module.INFO # pyright: ignore[reportGeneralTypeIssues,reportAttributeAccessIssue] - root_logger_handler = root_logger.handlers[0] # pyright: ignore[reportGeneralTypeIssues] + root_logger_handler = root_logger.handlers[0] # pyright: ignore[reportGeneralTypeIssues,reportAttributeAccessIssue] assert root_logger_handler.name == "queue_listener" assert type(root_logger_handler) is expected_root_logger_handler_class @@ -366,7 +482,8 @@ def test_customizing_handler( formatter = root_logger_handler.listener.handlers[0].formatter # type: ignore[attr-defined] else: formatter = root_logger_handler.formatter - assert formatter._fmt == log_format + if formatter is not None: + assert formatter._fmt == log_format else: # Root logger shouldn't be configured but pytest adds some handlers (for the standard `logging` module) for handler in root_logger.handlers: # type: ignore[attr-defined] @@ -381,7 +498,7 @@ def assert_logger(logger: Any) -> None: assert logger.handlers[0].formatter._fmt == log_format logger.info("Hello from '%s'", logging_module.__name__) - if logging_module == picologging: + if logging_module_str == "picologging": log_output = capsys.readouterr().err.strip() else: log_output = capsys.readouterr().out.strip()