Skip to content

Commit 71aab4b

Browse files
feat(client): add logging setup (#127)
1 parent 88b79b5 commit 71aab4b

File tree

8 files changed

+76
-7
lines changed

8 files changed

+76
-7
lines changed

README.md

+10
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,16 @@ client = Finch(
274274

275275
## Advanced
276276

277+
### Logging
278+
279+
We use the standard library [`logging`](https://docs.python.org/3/library/logging.html) module.
280+
281+
You can enable logging by setting the environment variable `FINCH_LOG` to `debug`.
282+
283+
```shell
284+
$ export FINCH_LOG=debug
285+
```
286+
277287
### How to tell whether `None` means `null` or missing
278288

279289
In an API response, a field may be explicitly `null`, or missing entirely; in either case, its value is `None` in this library. You can differentiate the two cases with `.model_fields_set`:

pyproject.toml

-2
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,6 @@ target-version = ["py37"]
4747
testpaths = ["tests"]
4848
addopts = "--tb=short"
4949
xfail_strict = true
50-
log_cli = true
51-
log_level = "INFO"
5250
asyncio_mode = "auto"
5351
filterwarnings = [
5452
"error"

src/finch/__init__.py

+3
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
UnprocessableEntityError,
3232
APIResponseValidationError,
3333
)
34+
from ._utils._logs import setup_logging as _setup_logging
3435

3536
__all__ = [
3637
"types",
@@ -64,6 +65,8 @@
6465
"file_from_path",
6566
]
6667

68+
_setup_logging()
69+
6770
# Update the __module__ attribute for exported symbols so that
6871
# error messages point to this module instead of the module
6972
# it was originally defined in, e.g.

src/finch/_base_client.py

+15-2
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import uuid
66
import email
77
import inspect
8+
import logging
89
import platform
910
import warnings
1011
import email.utils
@@ -60,7 +61,7 @@
6061
ModelBuilderProtocol,
6162
)
6263
from ._utils import is_dict, is_given, is_mapping
63-
from ._compat import model_copy
64+
from ._compat import model_copy, model_dump
6465
from ._models import (
6566
BaseModel,
6667
GenericModel,
@@ -76,6 +77,8 @@
7677
APIResponseValidationError,
7778
)
7879

80+
log: logging.Logger = logging.getLogger(__name__)
81+
7982
# TODO: make base page type vars covariant
8083
SyncPageT = TypeVar("SyncPageT", bound="BaseSyncPage[Any]")
8184
AsyncPageT = TypeVar("AsyncPageT", bound="BaseAsyncPage[Any]")
@@ -428,7 +431,8 @@ def _build_request(
428431
self,
429432
options: FinalRequestOptions,
430433
) -> httpx.Request:
431-
headers = self._build_headers(options)
434+
if log.isEnabledFor(logging.DEBUG):
435+
log.debug("Request options: %s", model_dump(options, exclude_unset=True))
432436

433437
kwargs: dict[str, Any] = {}
434438

@@ -441,6 +445,7 @@ def _build_request(
441445
else:
442446
raise RuntimeError(f"Unexpected JSON data type, {type(json_data)}, cannot merge with `extra_body`")
443447

448+
headers = self._build_headers(options)
444449
params = _merge_mappings(self._custom_query, options.params)
445450

446451
# If the given Content-Type header is multipart/form-data then it
@@ -893,6 +898,9 @@ def _request(
893898

894899
try:
895900
response = self._client.send(request, auth=self.custom_auth, stream=stream)
901+
log.debug(
902+
'HTTP Request: %s %s "%i %s"', request.method, request.url, response.status_code, response.reason_phrase
903+
)
896904
response.raise_for_status()
897905
except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code
898906
if retries > 0 and self._should_retry(err.response):
@@ -941,6 +949,7 @@ def _retry_request(
941949
) -> ResponseT | _StreamT:
942950
remaining = remaining_retries - 1
943951
timeout = self._calculate_retry_timeout(remaining, options, response_headers)
952+
log.info("Retrying request to %s in %f seconds", options.url, timeout)
944953

945954
# In a synchronous context we are blocking the entire thread. Up to the library user to run the client in a
946955
# different thread if necessary.
@@ -1292,6 +1301,9 @@ async def _request(
12921301

12931302
try:
12941303
response = await self._client.send(request, auth=self.custom_auth, stream=stream)
1304+
log.debug(
1305+
'HTTP Request: %s %s "%i %s"', request.method, request.url, response.status_code, response.reason_phrase
1306+
)
12951307
response.raise_for_status()
12961308
except httpx.HTTPStatusError as err: # thrown on 4xx and 5xx status code
12971309
if retries > 0 and self._should_retry(err.response):
@@ -1350,6 +1362,7 @@ async def _retry_request(
13501362
) -> ResponseT | _AsyncStreamT:
13511363
remaining = remaining_retries - 1
13521364
timeout = self._calculate_retry_timeout(remaining, options, response_headers)
1365+
log.info("Retrying request to %s in %f seconds", options.url, timeout)
13531366

13541367
await anyio.sleep(timeout)
13551368

src/finch/_compat.py

+17-3
Original file line numberDiff line numberDiff line change
@@ -126,10 +126,24 @@ def model_json(model: pydantic.BaseModel, *, indent: int | None = None) -> str:
126126
return model.json(indent=indent) # type: ignore
127127

128128

129-
def model_dump(model: pydantic.BaseModel) -> dict[str, Any]:
129+
def model_dump(
130+
model: pydantic.BaseModel,
131+
*,
132+
exclude_unset: bool = False,
133+
exclude_defaults: bool = False,
134+
) -> dict[str, Any]:
130135
if PYDANTIC_V2:
131-
return model.model_dump()
132-
return cast("dict[str, Any]", model.dict()) # pyright: ignore[reportDeprecated, reportUnnecessaryCast]
136+
return model.model_dump(
137+
exclude_unset=exclude_unset,
138+
exclude_defaults=exclude_defaults,
139+
)
140+
return cast(
141+
"dict[str, Any]",
142+
model.dict( # pyright: ignore[reportDeprecated, reportUnnecessaryCast]
143+
exclude_unset=exclude_unset,
144+
exclude_defaults=exclude_defaults,
145+
),
146+
)
133147

134148

135149
def model_parse(model: type[_ModelT], data: Any) -> _ModelT:

src/finch/_types.py

+3
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,9 @@ def get(timeout: Union[int, NotGiven, None] = NotGiven()) -> Response: ...
104104
def __bool__(self) -> Literal[False]:
105105
return False
106106

107+
def __repr__(self) -> str:
108+
return "NOT_GIVEN"
109+
107110

108111
NotGivenOr = Union[_T, NotGiven]
109112
NOT_GIVEN = NotGiven()

src/finch/_utils/_logs.py

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import os
2+
import logging
3+
4+
logger: logging.Logger = logging.getLogger("finch")
5+
httpx_logger: logging.Logger = logging.getLogger("httpx")
6+
7+
8+
def _basic_config() -> None:
9+
# e.g. [2023-10-05 14:12:26 - finch._base_client:818 - DEBUG] HTTP Request: POST http://127.0.0.1:4010/foo/bar "200 OK"
10+
logging.basicConfig(
11+
format="[%(asctime)s - %(name)s:%(lineno)d - %(levelname)s] %(message)s",
12+
datefmt="%Y-%m-%d %H:%M:%S",
13+
)
14+
15+
16+
def setup_logging() -> None:
17+
env = os.environ.get("FINCH_LOG")
18+
if env == "debug":
19+
_basic_config()
20+
logger.setLevel(logging.DEBUG)
21+
httpx_logger.setLevel(logging.DEBUG)
22+
elif env == "info":
23+
_basic_config()
24+
logger.setLevel(logging.INFO)
25+
httpx_logger.setLevel(logging.INFO)

tests/conftest.py

+3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import asyncio
2+
import logging
23
from typing import Iterator
34

45
import pytest
56

67
pytest.register_assert_rewrite("tests.utils")
78

9+
logging.getLogger("finch").setLevel(logging.DEBUG)
10+
811

912
@pytest.fixture(scope="session")
1013
def event_loop() -> Iterator[asyncio.AbstractEventLoop]:

0 commit comments

Comments
 (0)