Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 3 additions & 0 deletions src/api_to_dataframe/__init__.py
Original file line number Diff line number Diff line change
@@ -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"]
16 changes: 11 additions & 5 deletions src/api_to_dataframe/controller/client_builder.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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
Expand Down
7 changes: 4 additions & 3 deletions src/api_to_dataframe/models/get_data.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import requests
import logging

import pandas as pd
from api_to_dataframe.utils.logger import logger
import requests


class GetData:
Expand All @@ -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
8 changes: 5 additions & 3 deletions src/api_to_dataframe/models/retainer.py
Original file line number Diff line number Diff line change
@@ -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


Expand All @@ -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)
Expand All @@ -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
89 changes: 81 additions & 8 deletions src/api_to_dataframe/utils/logger.py
Original file line number Diff line number Diff line change
@@ -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):
Comment on lines 1 to 12

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Align default logger with internal loggers

The new configure_logger configures a logger named "api-to-dataframe", but all logging inside the package now uses logging.getLogger(__name__) (e.g. ClientBuilder, GetData, retry_strategies), which produces loggers in the api_to_dataframe.* hierarchy. Calling configure_logger(...) as documented will therefore not influence any of the library’s emitted log messages unless the caller manually injects the returned logger everywhere. This makes the advertised customization hook ineffective and leaves users without a simple way to enable logging. Consider basing DEFAULT_LOGGER_NAME on the package namespace (e.g. "api_to_dataframe") or otherwise wiring configure_logger to the loggers actually used by the code.

Useful? React with 👍 / 👎.

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
22 changes: 17 additions & 5 deletions tests/test_controller_client_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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)


Expand Down
78 changes: 57 additions & 21 deletions tests/test_utils_logger.py
Original file line number Diff line number Diff line change
@@ -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}"

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"