diff --git a/README.md b/README.md index dd0513d..6bbd0d3 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,29 @@ df = client.api_to_dataframe(data) print(df) ``` +### Customizing logging + +The library does not configure logging automatically. You can either configure +the default logger provided by ``api_to_dataframe.utils.logger`` or inject a +custom logger into ``ClientBuilder``: + +```python +import logging + +from api_to_dataframe import ClientBuilder, configure_logger + + +stream_handler = logging.StreamHandler() +configure_logger( + handlers=[stream_handler], + level=logging.INFO, + format="%(asctime)s :: api_to_dataframe[%(levelname)s] :: %(message)s", +) + +custom_logger = logging.getLogger("my-app.api-client") +client = ClientBuilder(endpoint="https://api.example.com", logger=custom_logger) +``` + ## Important notes: * **Opcionals Parameters:** The params timeout, retry_strategy and headers are opcionals. * **Default Params Value:** By default the quantity of retries is 3 and the time between retries is 1 second, but you can define manually. diff --git a/src/api_to_dataframe/__init__.py b/src/api_to_dataframe/__init__.py index 5a5ce94..3d4a1aa 100644 --- a/src/api_to_dataframe/__init__.py +++ b/src/api_to_dataframe/__init__.py @@ -1,2 +1,5 @@ from .controller.client_builder import ClientBuilder from .models.retainer import Strategies as RetryStrategies +from .utils.logger import configure_logger + +__all__ = ["ClientBuilder", "RetryStrategies", "configure_logger"] diff --git a/src/api_to_dataframe/controller/client_builder.py b/src/api_to_dataframe/controller/client_builder.py index 107b453..1dd52ba 100644 --- a/src/api_to_dataframe/controller/client_builder.py +++ b/src/api_to_dataframe/controller/client_builder.py @@ -1,6 +1,8 @@ +import logging +from typing import Optional + from api_to_dataframe.models.retainer import retry_strategies, Strategies from api_to_dataframe.models.get_data import GetData -from api_to_dataframe.utils.logger import logger class ClientBuilder: @@ -12,6 +14,7 @@ def __init__( # pylint: disable=too-many-positional-arguments,too-many-argument retries: int = 3, initial_delay: int = 1, connection_timeout: int = 1, + logger: Optional[logging.Logger] = None, ): """ Initializes the ClientBuilder object. @@ -23,6 +26,7 @@ def __init__( # pylint: disable=too-many-positional-arguments,too-many-argument retries (int): The number of times to retry a failed request. Defaults to 3. initial_delay (int): The delay between retries in seconds. Defaults to 1. connection_timeout (int): The timeout for the connection in seconds. Defaults to 1. + logger (logging.Logger, optional): Custom logger instance used for diagnostic messages. Raises: ValueError: If endpoint is an empty string. @@ -31,23 +35,25 @@ def __init__( # pylint: disable=too-many-positional-arguments,too-many-argument ValueError: If connection_timeout is not a non-negative integer. """ + self.logger = logger or logging.getLogger(__name__) + if headers is None: headers = {} if endpoint == "": error_msg = "endpoint cannot be an empty string" - logger.error(error_msg) + self.logger.error(error_msg) raise ValueError if not isinstance(retries, int) or retries < 0: error_msg = "retries must be a non-negative integer" - logger.error(error_msg) + self.logger.error(error_msg) raise ValueError if not isinstance(initial_delay, int) or initial_delay < 0: error_msg = "initial_delay must be a non-negative integer" - logger.error(error_msg) + self.logger.error(error_msg) raise ValueError if not isinstance(connection_timeout, int) or connection_timeout < 0: error_msg = "connection_timeout must be a non-negative integer" - logger.error(error_msg) + self.logger.error(error_msg) raise ValueError self.endpoint = endpoint diff --git a/src/api_to_dataframe/models/get_data.py b/src/api_to_dataframe/models/get_data.py index a61cacc..a3a57b2 100644 --- a/src/api_to_dataframe/models/get_data.py +++ b/src/api_to_dataframe/models/get_data.py @@ -1,6 +1,7 @@ -import requests +import logging + import pandas as pd -from api_to_dataframe.utils.logger import logger +import requests class GetData: @@ -21,7 +22,7 @@ def to_dataframe(response): # Check if DataFrame is empty if df.empty: error_msg = "::: DataFrame is empty :::" - logger.error(error_msg) + logging.getLogger(__name__).error(error_msg) raise ValueError(error_msg) return df diff --git a/src/api_to_dataframe/models/retainer.py b/src/api_to_dataframe/models/retainer.py index e69b25b..89dba9e 100644 --- a/src/api_to_dataframe/models/retainer.py +++ b/src/api_to_dataframe/models/retainer.py @@ -1,8 +1,9 @@ +import logging import time from enum import Enum from requests.exceptions import RequestException -from api_to_dataframe.utils.logger import logger + from api_to_dataframe.utils import Constants @@ -16,9 +17,10 @@ def retry_strategies(func): def wrapper(*args, **kwargs): # pylint: disable=inconsistent-return-statements retry_number = 0 while retry_number < args[0].retries: + bound_logger = getattr(args[0], "logger", logging.getLogger(__name__)) try: if retry_number > 0: - logger.info( + bound_logger.info( f"Trying for the {retry_number} of {Constants.MAX_OF_RETRIES} retries. Using {args[0].retry_strategy}" ) return func(*args, **kwargs) @@ -33,7 +35,7 @@ def wrapper(*args, **kwargs): # pylint: disable=inconsistent-return-statements time.sleep(args[0].delay * retry_number) if retry_number in (args[0].retries, Constants.MAX_OF_RETRIES): - logger.error(f"Failed after {retry_number} retries") + bound_logger.error(f"Failed after {retry_number} retries") raise e return wrapper diff --git a/src/api_to_dataframe/utils/logger.py b/src/api_to_dataframe/utils/logger.py index a5abe3c..7207fa7 100644 --- a/src/api_to_dataframe/utils/logger.py +++ b/src/api_to_dataframe/utils/logger.py @@ -1,11 +1,84 @@ +"""Utilities to manage the library logging configuration.""" + +from __future__ import annotations + import logging +from typing import Iterable, Optional + +DEFAULT_LOGGER_NAME = "api_to_dataframe" + + +logger = logging.getLogger(DEFAULT_LOGGER_NAME) +if not any(isinstance(handler, logging.NullHandler) for handler in logger.handlers): + logger.addHandler(logging.NullHandler()) + + +def configure_logger(**kwargs) -> logging.Logger: + """Configure the default logger or replace it with a custom instance. + + This helper avoids applying a global configuration automatically while + allowing consumers to fine-tune the logger used by the library. The caller + can provide an existing :class:`logging.Logger` instance or customize the + default logger by supplying keyword arguments similar to ``logging.basicConfig``. + + Keyword Args: + logger (logging.Logger, optional): + Custom logger instance to use across the library. + level (int, optional): + Logging level to apply to the default logger. + handlers (Iterable[logging.Handler], optional): + Handlers that will replace the current ones of the default logger. + formatter (logging.Formatter, optional): + Formatter applied to every handler in the default logger. + format (str, optional): + Format string used to build a :class:`logging.Formatter`. + datefmt (str, optional): + Date format passed to :class:`logging.Formatter` when ``format`` is + provided. + propagate (bool, optional): + Whether the default logger should propagate messages to ancestor + loggers. + + Returns: + logging.Logger: The configured logger instance. + + Raises: + TypeError: If the provided ``logger`` argument is not a Logger instance. + """ + + global logger # pylint: disable=global-statement + + custom_logger: Optional[logging.Logger] = kwargs.pop("logger", None) + if custom_logger is not None: + if not isinstance(custom_logger, logging.Logger): + raise TypeError("The 'logger' argument must be an instance of logging.Logger") + logger = custom_logger + return logger + + level: Optional[int] = kwargs.get("level") + if level is not None: + logger.setLevel(level) + + propagate: Optional[bool] = kwargs.get("propagate") + if propagate is not None: + logger.propagate = propagate + + handlers: Optional[Iterable[logging.Handler]] = kwargs.get("handlers") + if handlers is not None: + logger.handlers = list(handlers) + + formatter: Optional[logging.Formatter] = kwargs.get("formatter") + if formatter is None: + fmt: Optional[str] = kwargs.get("format") + datefmt: Optional[str] = kwargs.get("datefmt") + if fmt is not None: + formatter = logging.Formatter(fmt=fmt, datefmt=datefmt) + + if formatter is not None: + for handler in logger.handlers: + handler.setFormatter(formatter) -logging.basicConfig( - encoding="utf-8", - format="%(asctime)s :: api-to-dataframe[%(levelname)s] :: %(message)s", - datefmt="%Y-%m-%d %H:%M:%S %Z", - level=logging.INFO, -) + if not logger.handlers: + logger.addHandler(logging.NullHandler()) -# Initialize traditional logger -logger = logging.getLogger("api-to-dataframe") + return logger diff --git a/tests/test_controller_client_builder.py b/tests/test_controller_client_builder.py index 8af9bf5..67cf0d4 100644 --- a/tests/test_controller_client_builder.py +++ b/tests/test_controller_client_builder.py @@ -15,10 +15,15 @@ def client_setup(): @pytest.fixture() def response_setup(): - new_client = ClientBuilder( - endpoint="https://economia.awesomeapi.com.br/last/USD-BRL" - ) - return new_client.get_api_data() + """Return a mocked response dictionary for DataFrame conversion tests.""" + + endpoint = "https://economia.awesomeapi.com.br/last/USD-BRL" + payload = {"USDBRL": {"code": "USD", "codein": "BRL", "bid": "5.0"}} + + with responses.RequestsMock() as rsps: + rsps.add(responses.GET, endpoint, json=payload, status=200) + new_client = ClientBuilder(endpoint=endpoint) + yield new_client.get_api_data() def test_constructor_raises(): @@ -87,9 +92,16 @@ def test_constructor_with_retry_strategy(): assert client.delay == 2 +@responses.activate def test_response_to_json(client_setup): # pylint: disable=redefined-outer-name + """Ensure get_api_data returns a JSON dictionary when the call succeeds.""" + + endpoint = "https://economia.awesomeapi.com.br/last/USD-BRL" + expected_payload = {"USDBRL": {"code": "USD", "codein": "BRL"}} + responses.add(responses.GET, endpoint, json=expected_payload, status=200) + new_client = client_setup - response = new_client.get_api_data() # pylint: disable=protected-access + response = new_client.get_api_data() assert isinstance(response, dict) diff --git a/tests/test_utils_logger.py b/tests/test_utils_logger.py index 5f4896e..a28a054 100644 --- a/tests/test_utils_logger.py +++ b/tests/test_utils_logger.py @@ -1,23 +1,59 @@ +import io import logging + import pytest -from api_to_dataframe.utils.logger import logger - - -def test_logger_exists(): - """Test logger is properly initialized.""" - assert logger.name == "api-to-dataframe" - # Verificar apenas que é uma instância de logger, sem verificar propriedades específicas - assert isinstance(logger, logging.Logger) - - -def test_logger_can_log(): - """Test logger can log messages without errors.""" - # Tentativa de logging não deve lançar exceções - try: - logger.info("Test message") - logger.warning("Test warning") - logger.error("Test error") - # Se chegamos aqui, não houve exceções - assert True - except Exception as e: - assert False, f"Logger raised an exception: {e}" \ No newline at end of file + +from api_to_dataframe.utils import logger as logger_module +from api_to_dataframe.utils.logger import DEFAULT_LOGGER_NAME, configure_logger, logger + + +@pytest.fixture(autouse=True) +def restore_logger_state(): + """Restore the original logger configuration after each test run.""" + + original_logger = logger_module.logger + original_handlers = list(original_logger.handlers) + original_level = original_logger.level + original_propagate = original_logger.propagate + + yield + + configure_logger(logger=original_logger) + original_logger.handlers = original_handlers + original_logger.setLevel(original_level) + original_logger.propagate = original_propagate + + +def test_default_logger_is_exposed(): + """Ensure the module exposes the default logger with a NullHandler.""" + + assert logger.name == DEFAULT_LOGGER_NAME + assert any(isinstance(handler, logging.NullHandler) for handler in logger.handlers) + + +def test_configure_logger_accepts_custom_instance(): + """Ensure configure_logger can swap the default logger instance.""" + + custom_logger = logging.getLogger("api-to-dataframe-custom") + configured_logger = configure_logger(logger=custom_logger) + + assert configured_logger is custom_logger + + +def test_configure_logger_applies_custom_format(): + """Ensure configure_logger applies the provided formatter to handlers.""" + + stream = io.StringIO() + handler = logging.StreamHandler(stream) + format_str = "%(levelname)s -- %(message)s" + + configured_logger = configure_logger( + handlers=[handler], + level=logging.INFO, + format=format_str, + ) + configured_logger.info("custom message") + handler.flush() + + log_output = stream.getvalue().strip() + assert log_output == "INFO -- custom message"