From 0dda771004673fbb5a998c67493344bf122dbfb6 Mon Sep 17 00:00:00 2001 From: pierrocknroll Date: Fri, 4 Apr 2025 15:23:21 +0200 Subject: [PATCH 1/4] :sparkles: Logger system --- docs/CHANGELOG.md | 12 +++ docs/README.md | 139 +++++++++++++++++++++++++++- docs/api/loggable_mixin.md | 3 + docs/api/loglevel.md | 3 + docs/api/loguru_logger.md | 4 + pyproject.toml | 1 + requirements-dev.lock | 2 + requirements.lock | 2 + src/logger/__init__.py | 1 - src/logger/__main__.py | 1 - src/logger/contract.py | 73 +++++++++++++++ src/logger/loggable_mixin.py | 32 +++++++ src/logger/loglevel.py | 34 +++++++ src/logger/loguru.py | 92 ++++++++++++++++++ tests/conftest.py | 15 +++ tests/logger/loggable_mixin_test.py | 24 +++++ tests/logger/loglevel_test.py | 21 +++++ tests/logger/loguru_test.py | 109 ++++++++++++++++++++++ 18 files changed, 561 insertions(+), 7 deletions(-) create mode 100644 docs/api/loggable_mixin.md create mode 100644 docs/api/loglevel.md create mode 100644 docs/api/loguru_logger.md delete mode 100644 src/logger/__init__.py delete mode 100644 src/logger/__main__.py create mode 100644 src/logger/contract.py create mode 100644 src/logger/loggable_mixin.py create mode 100644 src/logger/loglevel.py create mode 100644 src/logger/loguru.py create mode 100644 tests/logger/loggable_mixin_test.py create mode 100644 tests/logger/loglevel_test.py create mode 100644 tests/logger/loguru_test.py diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 3a919b3..e22fcb8 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -5,3 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.0] - 2025-03-20 + +### Added + +- Initial release of the logging framework +- `LoggerContract` abstract base class defining the logger interface +- `LoguruLogger` implementation using Loguru library +- `LogLevel` enum for standardized log levels +- `LoggableMixin` for adding logging capabilities to classes +- Comprehensive test suite for all components +- Support for structured logging with context information +- Exception logging with type and message extraction diff --git a/docs/README.md b/docs/README.md index 4dedec2..2206226 100644 --- a/docs/README.md +++ b/docs/README.md @@ -13,7 +13,19 @@ ## Overview -Python logger +This project provides a flexible and extensible logging framework for Python +applications. It offers a clean interface for consistent logging with rich +context information, built on top of the Loguru library while maintaining a +contract-based approach for easy adaptation to other logging backends. + +Key features: + +- ๐Ÿ“ Standardized logging interface through a contract +- ๐Ÿ”„ Easy integration into existing classes via a mixin +- ๐ŸŒˆ Beautiful, colorized console output via Loguru +- ๐Ÿงฉ Structured logging with context support +- ๐Ÿ“Š Granular log level control +- ๐Ÿงช Thoroughly tested implementation ## Setup and installation @@ -24,18 +36,135 @@ Python logger ### Installation +1. Clone the repository + +2. Install dependencies + ```sh + make init + ``` + ## Usage +### Basic Usage + +```python +# Create a logger instance +logger = LoguruLogger(level=LogLevel.DEBUG) + +# Basic logging +logger.info("Application started") + +# Logging with context +logger.debug("Processing item", + context={"item_id": "12345", "status": "pending"}) + +# Error logging +try: + result = 1 / 0 +except Exception as e: + logger.exception("Division error occurred", exc=e, + context={"operation": "division"}) +``` + +### Using the LoggableMixin + +```python +# Create a class with logging capabilities +class MyService(LoggableMixin): + def __init__(self, logger: LoggerContract): + super().__init__() + self.logger = logger + + def process(self, data): + self.logger.info("Processing data", context={"data_size": len(data)}) + + +# Usage +logger = LoguruLogger(level=LogLevel.INFO) +service = MyService(logger) +service.process([1, 2, 3]) +``` + +### Log Levels + +Available log levels (from lowest to highest priority): + +- `LogLevel.DEBUG` - Detailed information for debugging +- `LogLevel.INFO` - General information about system operation +- `LogLevel.WARNING` - Indication of potential issues +- `LogLevel.ERROR` - Error conditions preventing a function from working +- `LogLevel.CRITICAL` - Critical conditions requiring immediate attention + +Set the log level when creating the logger: + +```python +# Only show warnings and above +logger = LoguruLogger(level=LogLevel.WARNING) +``` + +## API Reference + +### LoggerContract + +The abstract base class that defines the interface for all logger +implementations: + +| Method | Description | +|-----------------------------------------|-------------------------------------------------| +| `debug(message, context=None)` | Log debug message with optional context | +| `info(message, context=None)` | Log info message with optional context | +| `warning(message, context=None)` | Log warning message with optional context | +| `error(message, context=None)` | Log error message with optional context | +| `critical(message, context=None)` | Log critical message with optional context | +| `exception(message, exc, context=None)` | Log exception with message and optional context | + +### LoguruLogger + +An implementation of `LoggerContract` using +the [Loguru](https://github.com/Delgan/loguru) library. + +### LoggableMixin + +A mixin class that adds logging capabilities to any class. + +| Property | Description | +|----------|--------------------------------| +| `logger` | Get or set the logger instance | + +### LogLevel + +An enum representing log levels: + +- `DEBUG` +- `INFO` +- `WARNING` +- `ERROR` +- `CRITICAL` + +With a helper method: + +- `from_str(value)` - Create LogLevel from a string (case-insensitive) + ## Development ### Code Formatting and Linting -### Environment Variables +This project uses ruff for formatting and linting: + +```sh +# Format code +make format + +# Run linters +make lint +``` -| Variable | Description | Required | Default Value | Possible Values | -|----------|-------------|----------|---------------|-----------------| +### Running Tests -### Architecture +```sh +# Run tests with coverage +make test +``` ## Contributing diff --git a/docs/api/loggable_mixin.md b/docs/api/loggable_mixin.md new file mode 100644 index 0000000..64d82a5 --- /dev/null +++ b/docs/api/loggable_mixin.md @@ -0,0 +1,3 @@ +# LoggableMixin + +::: src.logger.loggable_mixin.LoggableMixin diff --git a/docs/api/loglevel.md b/docs/api/loglevel.md new file mode 100644 index 0000000..13c437d --- /dev/null +++ b/docs/api/loglevel.md @@ -0,0 +1,3 @@ +# LogLevel + +::: src.logger.loglevel.LogLevel diff --git a/docs/api/loguru_logger.md b/docs/api/loguru_logger.md new file mode 100644 index 0000000..844f622 --- /dev/null +++ b/docs/api/loguru_logger.md @@ -0,0 +1,4 @@ +# LoguruLogger + +::: src.logger.loguru.LoguruLogger + diff --git a/pyproject.toml b/pyproject.toml index 71e8395..d53117c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,6 +10,7 @@ dependencies = [ "mkdocstrings-python~=1.16.6", "mkdocs-material~=9.6.9", "griffe-inherited-docstrings~=1.1.1", + "loguru~=0.7.3", ] readme = "docs/README.md" requires-python = ">= 3.12" diff --git a/requirements-dev.lock b/requirements-dev.lock index 48821c0..ecca036 100644 --- a/requirements-dev.lock +++ b/requirements-dev.lock @@ -52,6 +52,8 @@ jinja2==3.1.6 # via mkdocs # via mkdocs-material # via mkdocstrings +loguru==0.7.3 + # via logger markdown==3.7 # via mkdocs # via mkdocs-autorefs diff --git a/requirements.lock b/requirements.lock index 5b2d22a..e479647 100644 --- a/requirements.lock +++ b/requirements.lock @@ -36,6 +36,8 @@ jinja2==3.1.6 # via mkdocs # via mkdocs-material # via mkdocstrings +loguru==0.7.3 + # via logger markdown==3.7 # via mkdocs # via mkdocs-autorefs diff --git a/src/logger/__init__.py b/src/logger/__init__.py deleted file mode 100644 index 5016d4c..0000000 --- a/src/logger/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Package init file.""" diff --git a/src/logger/__main__.py b/src/logger/__main__.py deleted file mode 100644 index a7d956b..0000000 --- a/src/logger/__main__.py +++ /dev/null @@ -1 +0,0 @@ -"""Package main file.""" diff --git a/src/logger/contract.py b/src/logger/contract.py new file mode 100644 index 0000000..4b33fed --- /dev/null +++ b/src/logger/contract.py @@ -0,0 +1,73 @@ +from abc import ABC, abstractmethod +from collections.abc import Mapping +from typing import Any + + +class LoggerContract(ABC): + """Abstract base class for logger implementations at various severity levels, with optional context information.""" # noqa: E501 + + @abstractmethod + def debug(self, message: str, context: Mapping[str, Any] | None = None) -> None: + """Log a debug message. + + Args: + message: The message to log + context: Additional contextual information to include with the log. + """ + raise NotImplementedError + + @abstractmethod + def info(self, message: str, context: Mapping[str, Any] | None = None) -> None: + """Log an info message. + + Args: + message: The message to log + context: Additional contextual information to include with the log. + """ + raise NotImplementedError + + @abstractmethod + def warning(self, message: str, context: Mapping[str, Any] | None = None) -> None: + """Log a warning message. + + Args: + message: The message to log + context: Additional contextual information to include with the log. + """ + raise NotImplementedError + + @abstractmethod + def error(self, message: str, context: Mapping[str, Any] | None = None) -> None: + """Log an error message. + + Args: + message: The message to log + context: Additional contextual information to include with the log. + """ + raise NotImplementedError + + @abstractmethod + def critical(self, message: str, context: Mapping[str, Any] | None = None) -> None: + """Log a critical message. + + Args: + message: The message to log + context: Additional contextual information to include with the log. + """ + raise NotImplementedError + + @abstractmethod + def exception( + self, + message: str, + exc: Exception, + context: Mapping[str, Any] | None = None, + ) -> None: + """Log an exception. + + Args: + message: A descriptive message about the exception + exc: The exception object + context: Additional contextual information to include with the log. + """ + raise NotImplementedError diff --git a/src/logger/loggable_mixin.py b/src/logger/loggable_mixin.py new file mode 100644 index 0000000..9a7ca74 --- /dev/null +++ b/src/logger/loggable_mixin.py @@ -0,0 +1,32 @@ +from .contract import LoggerContract + + +class LoggableMixin: + """Mixin to add logging capabilities to a class.""" + + def __init__(self) -> None: + """Initialize the mixin.""" + self._logger: LoggerContract | None = None + + @property + def logger(self) -> LoggerContract: + """Get the logger instance. + + Returns: + The configured logger instance. + + Raises: + RuntimeError: If logger has not been set. + """ + if self._logger is None: + raise RuntimeError("Logger not set") + return self._logger + + @logger.setter + def logger(self, logger: LoggerContract) -> None: + """Set a logger. + + Args: + logger: Logger instance to use. + """ + self._logger = logger diff --git a/src/logger/loglevel.py b/src/logger/loglevel.py new file mode 100644 index 0000000..06fd4d2 --- /dev/null +++ b/src/logger/loglevel.py @@ -0,0 +1,34 @@ +import logging +from enum import IntEnum +from typing import Self + + +class LogLevel(IntEnum): + """Represents the different log levels.""" + + DEBUG = logging.DEBUG + INFO = logging.INFO + WARNING = logging.WARNING + ERROR = logging.ERROR + CRITICAL = logging.CRITICAL + + @classmethod + def from_str(cls, value: str) -> Self: + """Create a LogLevel from a string, case-insensitive. + + Args: + value: String representation of the log level. + + Returns: + LogLevel enum value. + + Raises: + ValueError: If the string doesn't match any log level. + """ + try: + return cls[value.upper()] + except KeyError as e: + valid_values = [e.name for e in cls] + raise ValueError( + f"Invalid log level '{value}'. Must be one of: {valid_values}", + ) from e diff --git a/src/logger/loguru.py b/src/logger/loguru.py new file mode 100644 index 0000000..5905dd4 --- /dev/null +++ b/src/logger/loguru.py @@ -0,0 +1,92 @@ +from collections.abc import Mapping +from sys import stderr +from typing import Any, override + +from loguru import logger + +from .contract import LoggerContract +from .loglevel import LogLevel + + +class LoguruLogger(LoggerContract): + """Loguru implementation of the logger contract. + + Produces logs in a structured JSON format. + """ + + def __init__(self, level: LogLevel) -> None: + """Initialize the Loguru logger with default configuration. + + Args: + level: The minimum log level to display. + """ + self._logger = logger + self._logger.remove() + + self._logger.add( + sink=stderr, + level=level, + format="{time:%Y-%m-%d %H:%M:%S} | {level} | {message}", + colorize=True, + backtrace=level == LogLevel.DEBUG, # Include full stack trace + diagnose=level == LogLevel.DEBUG, # Include variables in stack trace + ) + + def _log_with_context( + self, + level: LogLevel, + message: str, + context: Mapping[str, Any] | None = None, + ) -> None: + """Internal method to log messages with context. + + Args: + level: The log level to use. + message: The message to log. + context: Additional contextual information to include with the log. + """ + log_data = {"message": message} + if context: + log_data["context"] = context + + self._logger.log(level.name, log_data) + + @override + def debug(self, message: str, context: Mapping[str, Any] | None = None) -> None: + self._log_with_context(level=LogLevel.DEBUG, message=message, context=context) + + @override + def info(self, message: str, context: Mapping[str, Any] | None = None) -> None: + self._log_with_context(level=LogLevel.INFO, message=message, context=context) + + @override + def warning(self, message: str, context: Mapping[str, Any] | None = None) -> None: + self._log_with_context(level=LogLevel.WARNING, message=message, context=context) + + @override + def error(self, message: str, context: Mapping[str, Any] | None = None) -> None: + self._log_with_context(level=LogLevel.ERROR, message=message, context=context) + + @override + def critical(self, message: str, context: Mapping[str, Any] | None = None) -> None: + self._log_with_context( + level=LogLevel.CRITICAL, + message=message, + context=context, + ) + + @override + def exception( + self, + message: str, + exc: Exception, + context: Mapping[str, Any] | None = None, + ) -> None: + exc_context = { + "exception_type": type(exc).__name__, + "exception_message": str(exc), + } + if context: + exc_context.update(context) + + self.error(message=message, context=exc_context) diff --git a/tests/conftest.py b/tests/conftest.py index 66ed7d9..98dbdcc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -4,3 +4,18 @@ pytest automatically discovers this file and makes fixtures available to all test modules without needing to import them. """ + +from unittest.mock import Mock + +import pytest + +from src.logger.contract import LoggerContract + + +@pytest.fixture +def mock_logger() -> Mock: + """Create a mock logger. + + :return: A mock logger conforming to LoggerContract + """ + return Mock(spec=LoggerContract) diff --git a/tests/logger/loggable_mixin_test.py b/tests/logger/loggable_mixin_test.py new file mode 100644 index 0000000..9bd50db --- /dev/null +++ b/tests/logger/loggable_mixin_test.py @@ -0,0 +1,24 @@ +from unittest.mock import Mock + +import pytest + +from src.logger.loggable_mixin import LoggableMixin + + +class TestLoggableMixin: + """Test suite for LoggableMixin.""" + + @pytest.fixture + def loggable(self) -> LoggableMixin: + """Create a LoggableMixin instance.""" + return LoggableMixin() + + def test_logger_not_set(self, loggable: LoggableMixin) -> None: + """Test that accessing logger before setting raises RuntimeError.""" + with pytest.raises(RuntimeError, match="Logger not set"): + _ = loggable.logger + + def test_logger_set(self, loggable: LoggableMixin, mock_logger: Mock) -> None: + """Test setting and getting logger.""" + loggable.logger = mock_logger + assert loggable.logger == mock_logger diff --git a/tests/logger/loglevel_test.py b/tests/logger/loglevel_test.py new file mode 100644 index 0000000..339d040 --- /dev/null +++ b/tests/logger/loglevel_test.py @@ -0,0 +1,21 @@ +import pytest + +from src.logger.loglevel import LogLevel + + +class TestLogLevel: + """Test suite for LogLevel enum.""" + + def test_from_str_valid(self) -> None: + """Test that valid strings are converted to LogLevel.""" + assert LogLevel.from_str("DEBUG") == LogLevel.DEBUG + assert LogLevel.from_str("debug") == LogLevel.DEBUG + assert LogLevel.from_str("INFO") == LogLevel.INFO + assert LogLevel.from_str("WARNING") == LogLevel.WARNING + assert LogLevel.from_str("ERROR") == LogLevel.ERROR + assert LogLevel.from_str("CRITICAL") == LogLevel.CRITICAL + + def test_from_str_invalid(self) -> None: + """Test that invalid strings raise ValueError.""" + with pytest.raises(ValueError, match="Invalid log level"): + LogLevel.from_str("INVALID") diff --git a/tests/logger/loguru_test.py b/tests/logger/loguru_test.py new file mode 100644 index 0000000..4864083 --- /dev/null +++ b/tests/logger/loguru_test.py @@ -0,0 +1,109 @@ +import io +from collections.abc import Mapping +from typing import Any + +import pytest +from loguru import logger + +from src.logger.loglevel import LogLevel +from src.logger.loguru import LoguruLogger + + +class TestLoguruLogger: + """Test suite for Loguru logger.""" + + @pytest.fixture + def loguru_logger(self) -> LoguruLogger: + """Create a LoguruLogger instance for testing. + + :return: Configured LoguruLogger instance + """ + return LoguruLogger(level=LogLevel.DEBUG) + + @pytest.fixture + def capture_logs(self) -> io.StringIO: + """Capture logs output to a string buffer. + + :yield: StringIO buffer containing log output + """ + log_stream = io.StringIO() + logger.remove() + logger.add(log_stream, format="{message}") + yield log_stream + logger.remove() + + @pytest.mark.parametrize( + ("level", "message", "context"), + [ + ("debug", "Debug message", {"key": "value"}), + ("info", "Info message", None), + ("warning", "Warning message", {"key": "value"}), + ("error", "Error message", {"error": "value"}), + ("critical", "Critical message", None), + ], + ) + def test_log_levels( + self, + loguru_logger: LoguruLogger, + capture_logs: io.StringIO, + level: str, + message: str, + context: Mapping[str, Any] | None, + ) -> None: + """Test all logging levels with various message/context combinations. + + :param loguru_logger: Logger instance + :param capture_logs: String buffer capturing log output + :param level: Log level to test + :param message: Message to log + :param context: Optional context dictionary + """ + # Get logging method dynamically + log_method = getattr(loguru_logger, level) + + # Log with or without context + log_method(message, context=context) + + # Get logged output + logs = capture_logs.getvalue() + + # Verify message is present + assert message in logs + + # Verify context if provided + if context: + for key, value in context.items(): + assert f"'{key}': '{value}'" in logs + + def test_exception_log( + self, + loguru_logger: LoguruLogger, + capture_logs: io.StringIO, + ) -> None: + """Test exception logging with context. + + :asserting: + - Exception message is properly logged + - Exception type is included + - Additional context is preserved + """ + try: + raise ValueError("Sample exception") # noqa: TRY301 + except ValueError as exc: + loguru_logger.exception( + "An error occurred", + exc=exc, + context={"additional": "info"}, + ) + + logs = capture_logs.getvalue() + assert ( + logs.strip() + == "{'message': 'An error occurred', 'context': {'exception_type': 'ValueError', 'exception_message': 'Sample exception', 'additional': 'info'}}" # noqa: E501 + ) + + def test_logger_level_change(self, capture_logs: io.StringIO) -> None: + """Test that debug messages are not logged at INFO level.""" + logger_info = LoguruLogger(level=LogLevel.INFO) + logger_info.debug("This should not appear") + assert not capture_logs.getvalue() From ec2df8c0eecc29d562099e3cb83b8589e1ff6f10 Mon Sep 17 00:00:00 2001 From: pierrocknroll Date: Fri, 4 Apr 2025 16:50:48 +0200 Subject: [PATCH 2/4] :art: Add an __init__.py file to an easier import --- src/logger/__init__.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/logger/__init__.py diff --git a/src/logger/__init__.py b/src/logger/__init__.py new file mode 100644 index 0000000..dc5061b --- /dev/null +++ b/src/logger/__init__.py @@ -0,0 +1,13 @@ +"""Logger package for standardized logging across projects.""" + +from .contract import LoggerContract +from .loggable_mixin import LoggableMixin +from .loglevel import LogLevel +from .loguru import LoguruLogger + +__all__ = [ + "LogLevel", + "LoggableMixin", + "LoggerContract", + "LoguruLogger", +] From f69379d5b71a4dfb98927c5a367571a7d5692689 Mon Sep 17 00:00:00 2001 From: pierrocknroll Date: Tue, 8 Apr 2025 19:19:56 +0200 Subject: [PATCH 3/4] :memo: Change docstring format in test files --- tests/conftest.py | 3 ++- tests/logger/loguru_test.py | 19 +++++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 98dbdcc..6854ac0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -16,6 +16,7 @@ def mock_logger() -> Mock: """Create a mock logger. - :return: A mock logger conforming to LoggerContract + Returns: + A mock logger conforming to LoggerContract """ return Mock(spec=LoggerContract) diff --git a/tests/logger/loguru_test.py b/tests/logger/loguru_test.py index 4864083..dc1c236 100644 --- a/tests/logger/loguru_test.py +++ b/tests/logger/loguru_test.py @@ -16,7 +16,8 @@ class TestLoguruLogger: def loguru_logger(self) -> LoguruLogger: """Create a LoguruLogger instance for testing. - :return: Configured LoguruLogger instance + Returns: + Configured LoguruLogger instance """ return LoguruLogger(level=LogLevel.DEBUG) @@ -24,7 +25,8 @@ def loguru_logger(self) -> LoguruLogger: def capture_logs(self) -> io.StringIO: """Capture logs output to a string buffer. - :yield: StringIO buffer containing log output + Yields: + StringIO buffer containing log output """ log_stream = io.StringIO() logger.remove() @@ -52,11 +54,12 @@ def test_log_levels( ) -> None: """Test all logging levels with various message/context combinations. - :param loguru_logger: Logger instance - :param capture_logs: String buffer capturing log output - :param level: Log level to test - :param message: Message to log - :param context: Optional context dictionary + Args: + loguru_logger: Logger instance + capture_logs: String buffer capturing log output + level: Log level to test + message: Message to log + context: Optional context dictionary """ # Get logging method dynamically log_method = getattr(loguru_logger, level) @@ -82,7 +85,7 @@ def test_exception_log( ) -> None: """Test exception logging with context. - :asserting: + The test verifies that: - Exception message is properly logged - Exception type is included - Additional context is preserved From bd8d700a3f3a97f7ea0afc61511cb48e8f70bc6c Mon Sep 17 00:00:00 2001 From: pierrocknroll Date: Wed, 9 Apr 2025 11:26:52 +0200 Subject: [PATCH 4/4] :memo: Improve tests docstrings --- tests/logger/loggable_mixin_test.py | 28 +++++++++++++++++++++++++--- tests/logger/loglevel_test.py | 16 ++++++++++++++-- 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/tests/logger/loggable_mixin_test.py b/tests/logger/loggable_mixin_test.py index 9bd50db..f7394bc 100644 --- a/tests/logger/loggable_mixin_test.py +++ b/tests/logger/loggable_mixin_test.py @@ -10,15 +10,37 @@ class TestLoggableMixin: @pytest.fixture def loggable(self) -> LoggableMixin: - """Create a LoggableMixin instance.""" + """Create a LoggableMixin instance for testing. + + Returns: + LoggableMixin: A fresh instance of the mixin for testing. + """ return LoggableMixin() def test_logger_not_set(self, loggable: LoggableMixin) -> None: - """Test that accessing logger before setting raises RuntimeError.""" + """Test error handling when accessing logger before setting it. + + This test verifies that: + - Accessing the logger property before setting it raises a RuntimeError + - The error message correctly indicates "Logger not set" + + Args: + loggable: Fresh LoggableMixin instance without a logger set + """ with pytest.raises(RuntimeError, match="Logger not set"): _ = loggable.logger def test_logger_set(self, loggable: LoggableMixin, mock_logger: Mock) -> None: - """Test setting and getting logger.""" + """Test logger property setter and getter functionality. + + This test verifies that: + - The logger can be successfully assigned through the property setter + - The same logger instance is retrieved through the property getter + - No exceptions are raised when accessing properly set logger + + Args: + loggable: Fresh LoggableMixin instance + mock_logger: Mock object simulating a logger implementation + """ loggable.logger = mock_logger assert loggable.logger == mock_logger diff --git a/tests/logger/loglevel_test.py b/tests/logger/loglevel_test.py index 339d040..99790fd 100644 --- a/tests/logger/loglevel_test.py +++ b/tests/logger/loglevel_test.py @@ -7,7 +7,13 @@ class TestLogLevel: """Test suite for LogLevel enum.""" def test_from_str_valid(self) -> None: - """Test that valid strings are converted to LogLevel.""" + """Test conversion of string representations to LogLevel enum values. + + This test verifies that: + - Uppercase strings match their corresponding enum values + - Case-insensitive matching works (lowercase strings also match) + - All defined log levels can be converted + """ assert LogLevel.from_str("DEBUG") == LogLevel.DEBUG assert LogLevel.from_str("debug") == LogLevel.DEBUG assert LogLevel.from_str("INFO") == LogLevel.INFO @@ -16,6 +22,12 @@ def test_from_str_valid(self) -> None: assert LogLevel.from_str("CRITICAL") == LogLevel.CRITICAL def test_from_str_invalid(self) -> None: - """Test that invalid strings raise ValueError.""" + """Test error handling for invalid string conversions. + + This test verifies that: + - Non-existent log level names raise ValueError + - The error message contains "Invalid log level" to help with debugging + - The validation prevents incorrect string values from being accepted + """ with pytest.raises(ValueError, match="Invalid log level"): LogLevel.from_str("INVALID")