diff --git a/docs/logging.md b/docs/logging.md index 0e4d6a3..a2fbabc 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -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 diff --git a/keystone_client/http.py b/keystone_client/http.py index 3dd555b..5aec950 100644 --- a/keystone_client/http.py +++ b/keystone_client/http.py @@ -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 @@ -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: @@ -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, @@ -69,8 +74,6 @@ def __init__( trust_env=False, ) - atexit.register(self.close) - @property def base_url(self) -> str: """Return the normalized server URL.""" @@ -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).""" @@ -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.""" @@ -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.""" @@ -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.""" @@ -167,7 +170,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.""" @@ -175,7 +178,7 @@ def http_put( 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.""" @@ -183,6 +186,11 @@ def http_delete( 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 @@ -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. @@ -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, @@ -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. @@ -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. @@ -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. @@ -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. @@ -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: @@ -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. @@ -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, @@ -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. @@ -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. @@ -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. @@ -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. @@ -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: diff --git a/keystone_client/log.py b/keystone_client/log.py index 5a6938d..2e4c330 100644 --- a/keystone_client/log.py +++ b/keystone_client/log.py @@ -1,6 +1,6 @@ """Components for integrating with the standard Python logging system. -The `log` module defines extensions to Python’s 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. @@ -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. diff --git a/tests/unit_tests/test_http/test_HTTPBase.py b/tests/unit_tests/test_http/test_HTTPBase.py index 04cf78a..6617332 100644 --- a/tests/unit_tests/test_http/test_HTTPBase.py +++ b/tests/unit_tests/test_http/test_HTTPBase.py @@ -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) diff --git a/tests/unit_tests/test_http/test_HTTPClient.py b/tests/unit_tests/test_http/test_HTTPClient.py index d2bf38c..dadd22f 100644 --- a/tests/unit_tests/test_http/test_HTTPClient.py +++ b/tests/unit_tests/test_http/test_HTTPClient.py @@ -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)