Skip to content

Commit 482e361

Browse files
feat: permitir configuracao flexivel de logging
1 parent 0086240 commit 482e361

File tree

8 files changed

+201
-45
lines changed

8 files changed

+201
-45
lines changed

README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,29 @@ df = client.api_to_dataframe(data)
7878
print(df)
7979
```
8080

81+
### Customizing logging
82+
83+
The library does not configure logging automatically. You can either configure
84+
the default logger provided by ``api_to_dataframe.utils.logger`` or inject a
85+
custom logger into ``ClientBuilder``:
86+
87+
```python
88+
import logging
89+
90+
from api_to_dataframe import ClientBuilder, configure_logger
91+
92+
93+
stream_handler = logging.StreamHandler()
94+
configure_logger(
95+
handlers=[stream_handler],
96+
level=logging.INFO,
97+
format="%(asctime)s :: api-to-dataframe[%(levelname)s] :: %(message)s",
98+
)
99+
100+
custom_logger = logging.getLogger("my-app.api-client")
101+
client = ClientBuilder(endpoint="https://api.example.com", logger=custom_logger)
102+
```
103+
81104
## Important notes:
82105
* **Opcionals Parameters:** The params timeout, retry_strategy and headers are opcionals.
83106
* **Default Params Value:** By default the quantity of retries is 3 and the time between retries is 1 second, but you can define manually.

src/api_to_dataframe/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
11
from .controller.client_builder import ClientBuilder
22
from .models.retainer import Strategies as RetryStrategies
3+
from .utils.logger import configure_logger
4+
5+
__all__ = ["ClientBuilder", "RetryStrategies", "configure_logger"]

src/api_to_dataframe/controller/client_builder.py

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
import logging
2+
from typing import Optional
3+
14
from api_to_dataframe.models.retainer import retry_strategies, Strategies
25
from api_to_dataframe.models.get_data import GetData
3-
from api_to_dataframe.utils.logger import logger
46

57

68
class ClientBuilder:
@@ -12,6 +14,7 @@ def __init__( # pylint: disable=too-many-positional-arguments,too-many-argument
1214
retries: int = 3,
1315
initial_delay: int = 1,
1416
connection_timeout: int = 1,
17+
logger: Optional[logging.Logger] = None,
1518
):
1619
"""
1720
Initializes the ClientBuilder object.
@@ -23,6 +26,7 @@ def __init__( # pylint: disable=too-many-positional-arguments,too-many-argument
2326
retries (int): The number of times to retry a failed request. Defaults to 3.
2427
initial_delay (int): The delay between retries in seconds. Defaults to 1.
2528
connection_timeout (int): The timeout for the connection in seconds. Defaults to 1.
29+
logger (logging.Logger, optional): Custom logger instance used for diagnostic messages.
2630
2731
Raises:
2832
ValueError: If endpoint is an empty string.
@@ -31,23 +35,25 @@ def __init__( # pylint: disable=too-many-positional-arguments,too-many-argument
3135
ValueError: If connection_timeout is not a non-negative integer.
3236
"""
3337

38+
self.logger = logger or logging.getLogger(__name__)
39+
3440
if headers is None:
3541
headers = {}
3642
if endpoint == "":
3743
error_msg = "endpoint cannot be an empty string"
38-
logger.error(error_msg)
44+
self.logger.error(error_msg)
3945
raise ValueError
4046
if not isinstance(retries, int) or retries < 0:
4147
error_msg = "retries must be a non-negative integer"
42-
logger.error(error_msg)
48+
self.logger.error(error_msg)
4349
raise ValueError
4450
if not isinstance(initial_delay, int) or initial_delay < 0:
4551
error_msg = "initial_delay must be a non-negative integer"
46-
logger.error(error_msg)
52+
self.logger.error(error_msg)
4753
raise ValueError
4854
if not isinstance(connection_timeout, int) or connection_timeout < 0:
4955
error_msg = "connection_timeout must be a non-negative integer"
50-
logger.error(error_msg)
56+
self.logger.error(error_msg)
5157
raise ValueError
5258

5359
self.endpoint = endpoint

src/api_to_dataframe/models/get_data.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import requests
1+
import logging
2+
23
import pandas as pd
3-
from api_to_dataframe.utils.logger import logger
4+
import requests
45

56

67
class GetData:
@@ -21,7 +22,7 @@ def to_dataframe(response):
2122
# Check if DataFrame is empty
2223
if df.empty:
2324
error_msg = "::: DataFrame is empty :::"
24-
logger.error(error_msg)
25+
logging.getLogger(__name__).error(error_msg)
2526
raise ValueError(error_msg)
2627

2728
return df

src/api_to_dataframe/models/retainer.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
import logging
12
import time
23
from enum import Enum
34

45
from requests.exceptions import RequestException
5-
from api_to_dataframe.utils.logger import logger
6+
67
from api_to_dataframe.utils import Constants
78

89

@@ -16,9 +17,10 @@ def retry_strategies(func):
1617
def wrapper(*args, **kwargs): # pylint: disable=inconsistent-return-statements
1718
retry_number = 0
1819
while retry_number < args[0].retries:
20+
bound_logger = getattr(args[0], "logger", logging.getLogger(__name__))
1921
try:
2022
if retry_number > 0:
21-
logger.info(
23+
bound_logger.info(
2224
f"Trying for the {retry_number} of {Constants.MAX_OF_RETRIES} retries. Using {args[0].retry_strategy}"
2325
)
2426
return func(*args, **kwargs)
@@ -33,7 +35,7 @@ def wrapper(*args, **kwargs): # pylint: disable=inconsistent-return-statements
3335
time.sleep(args[0].delay * retry_number)
3436

3537
if retry_number in (args[0].retries, Constants.MAX_OF_RETRIES):
36-
logger.error(f"Failed after {retry_number} retries")
38+
bound_logger.error(f"Failed after {retry_number} retries")
3739
raise e
3840

3941
return wrapper
Lines changed: 81 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,84 @@
1+
"""Utilities to manage the library logging configuration."""
2+
3+
from __future__ import annotations
4+
15
import logging
6+
from typing import Iterable, Optional
7+
8+
DEFAULT_LOGGER_NAME = "api-to-dataframe"
9+
10+
11+
logger = logging.getLogger(DEFAULT_LOGGER_NAME)
12+
if not any(isinstance(handler, logging.NullHandler) for handler in logger.handlers):
13+
logger.addHandler(logging.NullHandler())
14+
15+
16+
def configure_logger(**kwargs) -> logging.Logger:
17+
"""Configure the default logger or replace it with a custom instance.
18+
19+
This helper avoids applying a global configuration automatically while
20+
allowing consumers to fine-tune the logger used by the library. The caller
21+
can provide an existing :class:`logging.Logger` instance or customize the
22+
default logger by supplying keyword arguments similar to ``logging.basicConfig``.
23+
24+
Keyword Args:
25+
logger (logging.Logger, optional):
26+
Custom logger instance to use across the library.
27+
level (int, optional):
28+
Logging level to apply to the default logger.
29+
handlers (Iterable[logging.Handler], optional):
30+
Handlers that will replace the current ones of the default logger.
31+
formatter (logging.Formatter, optional):
32+
Formatter applied to every handler in the default logger.
33+
format (str, optional):
34+
Format string used to build a :class:`logging.Formatter`.
35+
datefmt (str, optional):
36+
Date format passed to :class:`logging.Formatter` when ``format`` is
37+
provided.
38+
propagate (bool, optional):
39+
Whether the default logger should propagate messages to ancestor
40+
loggers.
41+
42+
Returns:
43+
logging.Logger: The configured logger instance.
44+
45+
Raises:
46+
TypeError: If the provided ``logger`` argument is not a Logger instance.
47+
"""
48+
49+
global logger # pylint: disable=global-statement
50+
51+
custom_logger: Optional[logging.Logger] = kwargs.pop("logger", None)
52+
if custom_logger is not None:
53+
if not isinstance(custom_logger, logging.Logger):
54+
raise TypeError("The 'logger' argument must be an instance of logging.Logger")
55+
logger = custom_logger
56+
return logger
57+
58+
level: Optional[int] = kwargs.get("level")
59+
if level is not None:
60+
logger.setLevel(level)
61+
62+
propagate: Optional[bool] = kwargs.get("propagate")
63+
if propagate is not None:
64+
logger.propagate = propagate
65+
66+
handlers: Optional[Iterable[logging.Handler]] = kwargs.get("handlers")
67+
if handlers is not None:
68+
logger.handlers = list(handlers)
69+
70+
formatter: Optional[logging.Formatter] = kwargs.get("formatter")
71+
if formatter is None:
72+
fmt: Optional[str] = kwargs.get("format")
73+
datefmt: Optional[str] = kwargs.get("datefmt")
74+
if fmt is not None:
75+
formatter = logging.Formatter(fmt=fmt, datefmt=datefmt)
76+
77+
if formatter is not None:
78+
for handler in logger.handlers:
79+
handler.setFormatter(formatter)
280

3-
logging.basicConfig(
4-
encoding="utf-8",
5-
format="%(asctime)s :: api-to-dataframe[%(levelname)s] :: %(message)s",
6-
datefmt="%Y-%m-%d %H:%M:%S %Z",
7-
level=logging.INFO,
8-
)
81+
if not logger.handlers:
82+
logger.addHandler(logging.NullHandler())
983

10-
# Initialize traditional logger
11-
logger = logging.getLogger("api-to-dataframe")
84+
return logger

tests/test_controller_client_builder.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,15 @@ def client_setup():
1515

1616
@pytest.fixture()
1717
def response_setup():
18-
new_client = ClientBuilder(
19-
endpoint="https://economia.awesomeapi.com.br/last/USD-BRL"
20-
)
21-
return new_client.get_api_data()
18+
"""Return a mocked response dictionary for DataFrame conversion tests."""
19+
20+
endpoint = "https://economia.awesomeapi.com.br/last/USD-BRL"
21+
payload = {"USDBRL": {"code": "USD", "codein": "BRL", "bid": "5.0"}}
22+
23+
with responses.RequestsMock() as rsps:
24+
rsps.add(responses.GET, endpoint, json=payload, status=200)
25+
new_client = ClientBuilder(endpoint=endpoint)
26+
yield new_client.get_api_data()
2227

2328

2429
def test_constructor_raises():
@@ -87,9 +92,16 @@ def test_constructor_with_retry_strategy():
8792
assert client.delay == 2
8893

8994

95+
@responses.activate
9096
def test_response_to_json(client_setup): # pylint: disable=redefined-outer-name
97+
"""Ensure get_api_data returns a JSON dictionary when the call succeeds."""
98+
99+
endpoint = "https://economia.awesomeapi.com.br/last/USD-BRL"
100+
expected_payload = {"USDBRL": {"code": "USD", "codein": "BRL"}}
101+
responses.add(responses.GET, endpoint, json=expected_payload, status=200)
102+
91103
new_client = client_setup
92-
response = new_client.get_api_data() # pylint: disable=protected-access
104+
response = new_client.get_api_data()
93105
assert isinstance(response, dict)
94106

95107

tests/test_utils_logger.py

Lines changed: 57 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,59 @@
1+
import io
12
import logging
3+
24
import pytest
3-
from api_to_dataframe.utils.logger import logger
4-
5-
6-
def test_logger_exists():
7-
"""Test logger is properly initialized."""
8-
assert logger.name == "api-to-dataframe"
9-
# Verificar apenas que é uma instância de logger, sem verificar propriedades específicas
10-
assert isinstance(logger, logging.Logger)
11-
12-
13-
def test_logger_can_log():
14-
"""Test logger can log messages without errors."""
15-
# Tentativa de logging não deve lançar exceções
16-
try:
17-
logger.info("Test message")
18-
logger.warning("Test warning")
19-
logger.error("Test error")
20-
# Se chegamos aqui, não houve exceções
21-
assert True
22-
except Exception as e:
23-
assert False, f"Logger raised an exception: {e}"
5+
6+
from api_to_dataframe.utils import logger as logger_module
7+
from api_to_dataframe.utils.logger import DEFAULT_LOGGER_NAME, configure_logger, logger
8+
9+
10+
@pytest.fixture(autouse=True)
11+
def restore_logger_state():
12+
"""Restore the original logger configuration after each test run."""
13+
14+
original_logger = logger_module.logger
15+
original_handlers = list(original_logger.handlers)
16+
original_level = original_logger.level
17+
original_propagate = original_logger.propagate
18+
19+
yield
20+
21+
configure_logger(logger=original_logger)
22+
original_logger.handlers = original_handlers
23+
original_logger.setLevel(original_level)
24+
original_logger.propagate = original_propagate
25+
26+
27+
def test_default_logger_is_exposed():
28+
"""Ensure the module exposes the default logger with a NullHandler."""
29+
30+
assert logger.name == DEFAULT_LOGGER_NAME
31+
assert any(isinstance(handler, logging.NullHandler) for handler in logger.handlers)
32+
33+
34+
def test_configure_logger_accepts_custom_instance():
35+
"""Ensure configure_logger can swap the default logger instance."""
36+
37+
custom_logger = logging.getLogger("api-to-dataframe-custom")
38+
configured_logger = configure_logger(logger=custom_logger)
39+
40+
assert configured_logger is custom_logger
41+
42+
43+
def test_configure_logger_applies_custom_format():
44+
"""Ensure configure_logger applies the provided formatter to handlers."""
45+
46+
stream = io.StringIO()
47+
handler = logging.StreamHandler(stream)
48+
format_str = "%(levelname)s -- %(message)s"
49+
50+
configured_logger = configure_logger(
51+
handlers=[handler],
52+
level=logging.INFO,
53+
format=format_str,
54+
)
55+
configured_logger.info("custom message")
56+
handler.flush()
57+
58+
log_output = stream.getvalue().strip()
59+
assert log_output == "INFO -- custom message"

0 commit comments

Comments
 (0)