Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
15 changes: 8 additions & 7 deletions docs/logging.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,14 @@ logging.getLogger('kclient').addHandler(handler)
In addition to Python's built-in message fields, the `kclient` logger also exposes the following package-specific values.
These fields are passed to all log messages and may be accessed via custom formatters or filters.

| Field Name | Description |
|------------|----------------------------------------------------------------------------|
| `cid` | Per-session logging id used to correlate requests across a client session. |
| `baseurl` | Base API server URL, including http protocol. |
| `method` | HTTP method for outgoing requests, or an empty string if not applicable. |
| `endpoint` | API endpoint for outgoing requests, or an empty string if not applicable. |
| `url` | Full API URL for outgoing requests, or an empty string if not applicable. |
| Field Name | Description |
|---------------|----------------------------------------------------------------------------|
| `cid` | Per-session logging id used to correlate requests across a client session. |
| `baseurl` | Base API server URL, including http protocol. |
| `method` | HTTP method for outgoing requests, or an empty string if not applicable. |
| `endpoint` | API endpoint for outgoing requests, or an empty string if not applicable. |
| `url` | Full API URL for outgoing requests, or an empty string if not applicable. |
| `status_code` | HTTP response status code, or an empty string if not applicable. |

## Session IDs

Expand Down
100 changes: 71 additions & 29 deletions keystone_client/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from urllib.parse import urljoin, urlparse

import httpx
from httpx._types import CertTypes, QueryParamTypes, RequestContent, RequestData, RequestFiles
from httpx._types import QueryParamTypes, RequestContent, RequestData, RequestFiles

from .log import DefaultContextAdapter

Expand All @@ -38,7 +38,7 @@ def __init__(
verify_ssl: bool = True,
follow_redirects: bool = False,
max_redirects: int = 10,
timeout: int | None = 15,
timeout: int | float | None = 15,
limits: httpx.Limits = httpx.Limits(max_connections=100, max_keepalive_connections=20),
transport: httpx.BaseTransport | None = None,
) -> None:
Expand All @@ -54,10 +54,15 @@ def __init__(
transport: Optional custom HTTPX transport.
"""

# Connection state
self._closed = False

# Logging context
self._cid = str(uuid.uuid4())
self._base_url = self.normalize_url(base_url)
self._log = DefaultContextAdapter(logger, extra={"cid": self._cid, "baseurl": self._base_url})
self._log = DefaultContextAdapter(logger, extra={"cid": self._cid, "baseurl": self._base_url, "status_code": ""})

# Underlying httpx client
self._client = self._client_factory(
base_url=self._base_url,
verify=verify_ssl,
Expand All @@ -69,8 +74,6 @@ def __init__(
trust_env=False,
)

atexit.register(self.close)

@property
def base_url(self) -> str:
"""Return the normalized server URL."""
Expand Down Expand Up @@ -128,7 +131,7 @@ def send_request(
json: RequestContent | None = None,
files: RequestFiles | None = None,
params: QueryParamTypes | None = None,
timeout: int = httpx.USE_CLIENT_DEFAULT,
timeout: int | float | None = httpx.USE_CLIENT_DEFAULT,
) -> httpx.Response:
"""Send an HTTP request (sync or async depending on the implementation)."""

Expand All @@ -137,7 +140,7 @@ def http_get(
self,
endpoint: str,
params: QueryParamTypes | None = None,
timeout: int = httpx.USE_CLIENT_DEFAULT,
timeout: int | float | None = httpx.USE_CLIENT_DEFAULT,
) -> httpx.Response:
"""Send a GET request."""

Expand All @@ -147,7 +150,7 @@ def http_post(
endpoint: str,
json: RequestData | None = None,
files: RequestFiles | None = None,
timeout: int = httpx.USE_CLIENT_DEFAULT,
timeout: int | float | None = httpx.USE_CLIENT_DEFAULT,
) -> httpx.Response:
"""Send a POST request."""

Expand All @@ -157,7 +160,7 @@ def http_patch(
endpoint: str,
json: RequestData | None = None,
files: RequestFiles | None = None,
timeout: int = httpx.USE_CLIENT_DEFAULT,
timeout: int | float | None = httpx.USE_CLIENT_DEFAULT,
) -> httpx.Response:
"""Send a PATCH request."""

Expand All @@ -167,22 +170,27 @@ def http_put(
endpoint: str,
json: RequestData | None = None,
files: RequestFiles | None = None,
timeout: int = httpx.USE_CLIENT_DEFAULT,
timeout: int | float | None = httpx.USE_CLIENT_DEFAULT,
) -> httpx.Response:
"""Send a PUT request."""

@abc.abstractmethod
def http_delete(
self,
endpoint: str,
timeout: int = httpx.USE_CLIENT_DEFAULT,
timeout: int | float | None = httpx.USE_CLIENT_DEFAULT,
) -> httpx.Response:
"""Send a DELETE request."""


class HTTPClient(HTTPBase):
"""Synchronous HTTP Client."""

def __init__(self, *args, **kwargs) -> None:

super().__init__(*args, **kwargs)
atexit.register(self.close)

def __enter__(self) -> 'HTTPClient':
return self

Expand All @@ -198,19 +206,23 @@ def _client_factory(self, **kwargs) -> httpx.Client:
def close(self) -> None:
"""Close any open server connections."""

if self._closed:
return

self._log.info("Closing HTTP session")
self._client.close()
self._closed = True

def send_request(
self,
method: HttpMethod,
endpoint: str,
*,
headers: dict = None,
headers: dict | None = None,
json: RequestContent | None = None,
files: RequestFiles | None = None,
params: QueryParamTypes | None = None,
timeout: int = httpx.USE_CLIENT_DEFAULT,
timeout: int | float | None = httpx.USE_CLIENT_DEFAULT,
) -> httpx.Response:
"""Send an HTTP request.

Expand All @@ -228,10 +240,10 @@ def send_request(
"""

url = self.normalize_url(urljoin(self.base_url, endpoint))
self._log.info("Sending HTTP request", extra={"method": method, "endpoint": endpoint, "url": url})
self._log.debug("HTTP Request", extra={"method": method, "endpoint": endpoint, "url": url})

application_headers = self.get_application_headers(headers)
return self._client.request(
response = self._client.request(
method=method,
url=url,
headers=application_headers,
Expand All @@ -241,11 +253,20 @@ def send_request(
timeout=timeout,
)

self._log.info("HTTP Response", extra={
"method": method,
"endpoint": endpoint,
"url": url,
"status_code": response.status_code
})

return response

def http_get(
self,
endpoint: str,
params: QueryParamTypes | None = None,
timeout: int = httpx.USE_CLIENT_DEFAULT,
timeout: int | float | None = httpx.USE_CLIENT_DEFAULT,
) -> httpx.Response:
"""Send a GET request to an API endpoint.

Expand All @@ -265,7 +286,7 @@ def http_post(
endpoint: str,
json: RequestData | None = None,
files: RequestFiles | None = None,
timeout: int = httpx.USE_CLIENT_DEFAULT,
timeout: int | float | None = httpx.USE_CLIENT_DEFAULT,
) -> httpx.Response:
"""Send a POST request to an API endpoint.

Expand All @@ -286,7 +307,7 @@ def http_patch(
endpoint: str,
json: RequestData | None = None,
files: RequestFiles | None = None,
timeout: int = httpx.USE_CLIENT_DEFAULT,
timeout: int | float | None = httpx.USE_CLIENT_DEFAULT,
) -> httpx.Response:
"""Send a PATCH request to an API endpoint.

Expand All @@ -307,7 +328,7 @@ def http_put(
endpoint: str,
json: RequestData | None = None,
files: RequestFiles | None = None,
timeout: int = httpx.USE_CLIENT_DEFAULT,
timeout: int | float | None = httpx.USE_CLIENT_DEFAULT,
) -> httpx.Response:
"""Send a PUT request to an API endpoint.

Expand All @@ -323,7 +344,11 @@ def http_put(

return self.send_request("put", endpoint, json=json, files=files, timeout=timeout)

def http_delete(self, endpoint: str, timeout: int = httpx.USE_CLIENT_DEFAULT) -> httpx.Response:
def http_delete(
self,
endpoint: str,
timeout: int | float | None = httpx.USE_CLIENT_DEFAULT,
) -> httpx.Response:
"""Send a DELETE request to an endpoint.

Args:
Expand Down Expand Up @@ -355,19 +380,23 @@ def _client_factory(self, **kwargs) -> httpx.AsyncClient:
async def close(self) -> None:
"""Close any open server connections."""

if self._closed:
return

self._log.info("Closing asynchronous HTTP session")
await self._client.aclose()
self._closed = True

async def send_request(
self,
method: HttpMethod,
endpoint: str,
*,
headers: dict = None,
headers: dict | None = None,
json: dict | None = None,
files: RequestFiles | None = None,
params: QueryParamTypes | None = None,
timeout: int = httpx.USE_CLIENT_DEFAULT,
timeout: int | float | None = httpx.USE_CLIENT_DEFAULT,
) -> httpx.Response:
"""Send an HTTP request.

Expand All @@ -385,10 +414,10 @@ async def send_request(
"""

url = self.normalize_url(urljoin(self.base_url, endpoint))
self._log.info("Sending asynchronous HTTP request", extra={"method": method, "endpoint": endpoint, "url": url})
self._log.debug("Async HTTP request", extra={"method": method, "endpoint": endpoint, "url": url})

application_headers = self.get_application_headers(headers)
return await self._client.request(
response = await self._client.request(
method=method,
url=url,
headers=application_headers,
Expand All @@ -398,11 +427,20 @@ async def send_request(
timeout=timeout
)

self._log.info("Async HTTP Response", extra={
"method": method,
"endpoint": endpoint,
"url": url,
"status_code": response.status_code
})

return response

async def http_get(
self,
endpoint: str,
params: QueryParamTypes | None = None,
timeout: int = httpx.USE_CLIENT_DEFAULT,
timeout: int | float | None = httpx.USE_CLIENT_DEFAULT,
) -> httpx.Response:
"""Send an asynchronous GET request to an API endpoint.

Expand All @@ -422,7 +460,7 @@ async def http_post(
endpoint: str,
json: RequestData | None = None,
files: RequestFiles | None = None,
timeout: int = httpx.USE_CLIENT_DEFAULT,
timeout: int | float | None = httpx.USE_CLIENT_DEFAULT,
) -> httpx.Response:
"""Send an asynchronous POST request to an API endpoint.

Expand All @@ -443,7 +481,7 @@ async def http_patch(
endpoint: str,
json: RequestData | None = None,
files: RequestFiles | None = None,
timeout: int = httpx.USE_CLIENT_DEFAULT,
timeout: int | float | None = httpx.USE_CLIENT_DEFAULT,
) -> httpx.Response:
"""Send an asynchronous PATCH request to an API endpoint.

Expand All @@ -464,7 +502,7 @@ async def http_put(
endpoint: str,
json: RequestData | None = None,
files: RequestFiles | None = None,
timeout: int = httpx.USE_CLIENT_DEFAULT,
timeout: int | float | None = httpx.USE_CLIENT_DEFAULT,
) -> httpx.Response:
"""Send an asynchronous PUT request to an API endpoint.

Expand All @@ -480,7 +518,11 @@ async def http_put(

return await self.send_request("put", endpoint, json=json, files=files, timeout=timeout)

async def http_delete(self, endpoint: str, timeout: int = httpx.USE_CLIENT_DEFAULT) -> httpx.Response:
async def http_delete(
self,
endpoint: str,
timeout: int | float | None = httpx.USE_CLIENT_DEFAULT,
) -> httpx.Response:
"""Send an asynchronous DELETE request to an endpoint.

Args:
Expand Down
5 changes: 3 additions & 2 deletions keystone_client/log.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Components for integrating with the standard Python logging system.

The `log` module defines extensions to Pythons logging framework that
The `log` module defines extensions to Python's logging framework that
extend log records with application-specific context. These components
are initialized automatically with the package and are not intended
for direct import.
Expand Down Expand Up @@ -29,9 +29,10 @@ class ContextFilter(logging.Filter):
- method
- endpoint
- url
- status_code
"""

required_attr = ("cid", "baseurl", "method", "endpoint", "url")
required_attr = ("cid", "baseurl", "method", "endpoint", "url", "status_code")

def filter(self, record: logging.LogRecord) -> Literal[True]:
"""Ensure a log record has all required contextual attributes.
Expand Down
11 changes: 0 additions & 11 deletions tests/unit_tests/test_http/test_HTTPBase.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,14 +138,3 @@ def test_header_overrides_replace_existing_header(self) -> None:
headers = self.http_base.get_application_headers(overrides)

self.assertEqual(custom_cid, headers[HTTPBase.CID_HEADER])


class CloseAtExit(TestCase):
"""Test resource cleanup at application exit."""

@patch('atexit.register')
def test_close_registered_with_atexit(self, mock_atexit_register: MagicMock) -> None:
"""Verify the `close` method is registered with `atexit` on initialization."""

client = DummyHTTPBase('https://example.com/')
mock_atexit_register.assert_called_once_with(client.close)
12 changes: 12 additions & 0 deletions tests/unit_tests/test_http/test_HTTPClient.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,15 @@ def test_logs_request(self) -> None:
self.assertEqual(expected_method, record.method)
self.assertEqual(expected_endpoint, record.endpoint)
self.assertEqual(expected_url, record.url)



class CloseAtExit(TestCase):
"""Test resource cleanup at application exit."""

@patch('atexit.register')
def test_close_registered_with_atexit(self, mock_atexit_register: MagicMock) -> None:
"""Verify the `close` method is registered with `atexit` on initialization."""

client = HTTPClient(base_url="https://example.com")
mock_atexit_register.assert_any_call(client.close)
Loading