cloudru-qa-http-client 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
http_client/config.py ADDED
@@ -0,0 +1,82 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+ import os
5
+ from dataclasses import dataclass, field
6
+ from typing import TYPE_CHECKING
7
+
8
+ import httpx
9
+ from httpx._config import DEFAULT_MAX_REDIRECTS
10
+
11
+ if TYPE_CHECKING:
12
+ import ssl
13
+
14
+ from httpx._types import AuthTypes, TimeoutTypes, URLTypes
15
+
16
+ from .types import RequestHook, ResponseHook, SendDecorator, StreamRequestDecorator
17
+
18
+ DEFAULT_OCTET_STREAM_LOG_MAXLENGTH = 1024
19
+
20
+
21
+ @dataclass(kw_only=True, slots=True)
22
+ class HttpClientConfig:
23
+ """Transport settings collected by options before creating the HTTP client."""
24
+
25
+ base_url: URLTypes
26
+ verify: ssl.SSLContext | str | bool = True
27
+ auth: AuthTypes | None = None
28
+ timeout: TimeoutTypes = field(default_factory=lambda: httpx.Timeout(60))
29
+ url_suffix: str = ""
30
+ request_hooks: list[RequestHook] = field(default_factory=list)
31
+ response_hooks: list[ResponseHook] = field(default_factory=list)
32
+ send_decorators: list[SendDecorator] = field(default_factory=list)
33
+ stream_decorators: list[StreamRequestDecorator] = field(default_factory=list)
34
+ follow_redirects: bool = False
35
+ max_redirects: int = DEFAULT_MAX_REDIRECTS
36
+
37
+
38
+ @dataclass(kw_only=True)
39
+ class LoggingConfig:
40
+ """HTTP log formatting and length limit settings."""
41
+
42
+ log_level: int = logging.INFO
43
+ json_indent: int | None = None
44
+ use_line_breaks: bool = False
45
+ octet_stream_log_maxlength: int | None = DEFAULT_OCTET_STREAM_LOG_MAXLENGTH
46
+ plain_text_log_maxlength: int | None = None
47
+ json_log_maxlength: int | None = None
48
+
49
+
50
+ LOGGING_CONFIG = LoggingConfig()
51
+
52
+
53
+ def configure_logging(options: LoggingConfig | None = None) -> None:
54
+ """Configure HTTP log formatting using environment variables.
55
+
56
+ :param options: Default logging settings
57
+ """
58
+ settings = options or LoggingConfig()
59
+ if log_level_env := os.getenv("HTTP_CLIENT_LOG_LEVEL", None):
60
+ LOGGING_CONFIG.log_level = int(log_level_env)
61
+ else:
62
+ LOGGING_CONFIG.log_level = settings.log_level
63
+ if use_line_breaks_env := os.getenv("HTTP_CLIENT_USE_LINE_BREAKS", None):
64
+ LOGGING_CONFIG.use_line_breaks = use_line_breaks_env.lower() in ["true", "y", "yes", "on"]
65
+ else:
66
+ LOGGING_CONFIG.use_line_breaks = settings.use_line_breaks
67
+ if json_indent_env := os.getenv("HTTP_CLIENT_JSON_INDENT", None):
68
+ LOGGING_CONFIG.json_indent = int(json_indent_env)
69
+ else:
70
+ LOGGING_CONFIG.json_indent = settings.json_indent
71
+ if octet_stream_log_maxlength_env := os.getenv("HTTP_CLIENT_OCTET_STREAM_LOG_MAXLENGTH", None):
72
+ LOGGING_CONFIG.octet_stream_log_maxlength = int(octet_stream_log_maxlength_env)
73
+ else:
74
+ LOGGING_CONFIG.octet_stream_log_maxlength = settings.octet_stream_log_maxlength
75
+ if plain_text_log_maxlength_env := os.getenv("HTTP_CLIENT_PLAIN_TEXT_LOG_MAXLENGTH", None):
76
+ LOGGING_CONFIG.plain_text_log_maxlength = int(plain_text_log_maxlength_env)
77
+ else:
78
+ LOGGING_CONFIG.plain_text_log_maxlength = settings.plain_text_log_maxlength
79
+ if json_log_maxlength_env := os.getenv("HTTP_CLIENT_JSON_LOG_MAXLENGTH", None):
80
+ LOGGING_CONFIG.json_log_maxlength = int(json_log_maxlength_env)
81
+ else:
82
+ LOGGING_CONFIG.json_log_maxlength = settings.json_log_maxlength
@@ -0,0 +1,159 @@
1
+ import logging
2
+ from collections.abc import Sequence
3
+ from uuid import uuid4
4
+
5
+ from httpx import Request, Response
6
+
7
+ from .config import LOGGING_CONFIG as CFG
8
+ from .types import RequestHook, ResponseHook
9
+ from .utils import logutils, traceparent
10
+
11
+ REQUEST_ID_HEADER = "X-Request-ID"
12
+ TRACEPARENT_HEADER = "Traceparent"
13
+
14
+ # Always mask these: leaking a single secret in logs is far worse
15
+ # than an unnecessary asterisk.
16
+ DEFAULT_SANITIZE_KEYS: tuple[str, ...] = (
17
+ "authorization",
18
+ "proxy-authorization",
19
+ "cookie",
20
+ "set-cookie",
21
+ "x-api-key",
22
+ "api-key",
23
+ "api_key",
24
+ "access_token",
25
+ "refresh_token",
26
+ "password",
27
+ "secret",
28
+ "token",
29
+ )
30
+
31
+
32
+ def request_logger_hook(
33
+ logger: logging.Logger,
34
+ sanitize_keys: Sequence[str],
35
+ *,
36
+ json_indent: int | None = None,
37
+ use_line_breaks: bool | None = None,
38
+ log_level: int | None = None,
39
+ ) -> RequestHook:
40
+ """Create a hook for safe HTTP request logging.
41
+
42
+ :param logger: Log record recipient
43
+ :param sanitize_keys: Fields to mask
44
+ :param json_indent: JSON indentation
45
+ :param use_line_breaks: Multiline formatting
46
+ :param log_level: Log level
47
+ :returns: Request hook
48
+ """
49
+
50
+ def log_request_hook(request: Request) -> None:
51
+ log_msg = logutils.create_request_log(
52
+ r=request,
53
+ sanitize_keys=sanitize_keys,
54
+ json_indent=_indent(json_indent),
55
+ use_line_breaks=_line_breaks(use_line_breaks=use_line_breaks),
56
+ )
57
+ logger.log(level=_level(log_level), msg=log_msg)
58
+
59
+ return log_request_hook
60
+
61
+
62
+ def response_logger_hook(
63
+ logger: logging.Logger,
64
+ sanitize_keys: Sequence[str],
65
+ *,
66
+ json_indent: int | None = None,
67
+ use_line_breaks: bool | None = None,
68
+ log_level: int | None = None,
69
+ ) -> ResponseHook:
70
+ """Create a hook for safe HTTP response logging.
71
+
72
+ :param logger: Log record recipient
73
+ :param sanitize_keys: Fields to mask
74
+ :param json_indent: JSON indentation
75
+ :param use_line_breaks: Multiline formatting
76
+ :param log_level: Log level
77
+ :returns: Response hook
78
+ """
79
+
80
+ def log_response_hook(response: Response) -> None:
81
+ log_msg = logutils.create_response_log(
82
+ r=response,
83
+ sanitize_keys=sanitize_keys,
84
+ json_indent=_indent(json_indent),
85
+ use_line_breaks=_line_breaks(use_line_breaks=use_line_breaks),
86
+ )
87
+ logger.log(level=_level(log_level), msg=log_msg)
88
+
89
+ return log_response_hook
90
+
91
+
92
+ def async_response_logger_hook(
93
+ logger: logging.Logger,
94
+ sanitize_keys: Sequence[str],
95
+ *,
96
+ json_indent: int | None = None,
97
+ use_line_breaks: bool | None = None,
98
+ log_level: int | None = None,
99
+ ) -> ResponseHook:
100
+ """Build a response logging hook for an asynchronous client.
101
+
102
+ A separate implementation is needed: when the hook runs, the asynchronous
103
+ response body has not been read and its stream is only accessible through await.
104
+ """
105
+
106
+ async def log_response_hook(response: Response) -> None:
107
+ log_msg = await logutils.create_response_log_async(
108
+ r=response,
109
+ sanitize_keys=sanitize_keys,
110
+ json_indent=_indent(json_indent),
111
+ use_line_breaks=_line_breaks(use_line_breaks=use_line_breaks),
112
+ )
113
+ logger.log(level=_level(log_level), msg=log_msg)
114
+
115
+ return log_response_hook
116
+
117
+
118
+ def request_id_hook(request: Request) -> None:
119
+ """Set a request ID if it is missing.
120
+
121
+ Preserve the existing value deliberately. The hook runs every time a request object
122
+ is sent, and retries send the same object: overwriting the ID would give
123
+ each attempt a new ID, preventing correlation in logs. For the same reason,
124
+ preserve the ID supplied by the caller.
125
+ """
126
+ request.headers.setdefault(REQUEST_ID_HEADER, str(uuid4()))
127
+
128
+
129
+ def traceparent_hook(request: Request) -> None:
130
+ """Set traceparent if it is missing. See :func:`request_id_hook` for details."""
131
+ request.headers.setdefault(TRACEPARENT_HEADER, traceparent.generate())
132
+
133
+
134
+ def add_default_headers_hook(headers: dict[str, str]) -> RequestHook:
135
+ """Create a hook to add missing request headers.
136
+
137
+ :param headers: Default headers
138
+ :returns: Request hook
139
+ """
140
+ defaults = dict(headers)
141
+
142
+ def add_default_headers(request: Request) -> None:
143
+ for name, value in defaults.items():
144
+ if name not in request.headers:
145
+ request.headers[name] = value
146
+
147
+ return add_default_headers
148
+
149
+
150
+ def _level(log_level: int | None) -> int:
151
+ return log_level if log_level is not None else CFG.log_level
152
+
153
+
154
+ def _indent(json_indent: int | None) -> int | None:
155
+ return json_indent if json_indent is not None else CFG.json_indent
156
+
157
+
158
+ def _line_breaks(*, use_line_breaks: bool | None) -> bool:
159
+ return use_line_breaks if use_line_breaks is not None else CFG.use_line_breaks
http_client/options.py ADDED
@@ -0,0 +1,226 @@
1
+ import importlib
2
+ import ssl
3
+ from logging import Logger
4
+ from typing import TYPE_CHECKING, Unpack, cast
5
+
6
+ from httpx._config import DEFAULT_MAX_REDIRECTS
7
+ from httpx._types import AuthTypes, TimeoutTypes
8
+
9
+ from .client import AsyncHttpClient, BaseHttpClient, ClientOption, _reject_unknown_options
10
+ from .event_hooks import (
11
+ DEFAULT_SANITIZE_KEYS,
12
+ add_default_headers_hook,
13
+ async_response_logger_hook,
14
+ request_id_hook,
15
+ request_logger_hook,
16
+ response_logger_hook,
17
+ traceparent_hook,
18
+ )
19
+ from .types import LoggingOptions, RequestHook, ResponseHook, SendDecorator, StreamRequestDecorator
20
+
21
+ if TYPE_CHECKING:
22
+ from collections.abc import Callable
23
+
24
+
25
+ def with_timeout(timeout: TimeoutTypes) -> ClientOption:
26
+ """Set the HTTP client timeout.
27
+
28
+ :param timeout: Timeout
29
+ :returns: Option that sets the HTTP client timeout
30
+ """
31
+
32
+ def option(client: BaseHttpClient) -> None:
33
+ client.timeout = timeout
34
+
35
+ return option
36
+
37
+
38
+ def with_verify_context(*, verify: ssl.SSLContext | str | bool) -> ClientOption:
39
+ """Set the SSL certificate verification context for HTTPS connections.
40
+
41
+ `verify=False` disables certificate verification completely and makes the connection
42
+ vulnerable to man-in-the-middle attacks. Use it only for local debugging.
43
+
44
+ :param verify: SSL context, CA certificate path, or False to disable verification
45
+ :returns: Option that sets the HTTP client certificate verification context
46
+ """
47
+ # httpx deprecated string verify values in 0.28, so convert paths to contexts here.
48
+ context = ssl.create_default_context(cafile=verify) if isinstance(verify, str) else verify
49
+
50
+ def option(client: BaseHttpClient) -> None:
51
+ client.verify = context
52
+
53
+ return option
54
+
55
+
56
+ def use_truststore() -> ClientOption:
57
+ """Use the system SSL certificate store to verify HTTPS connections.
58
+
59
+ :raises ImportError: If the truststore module is not installed
60
+ :return: Option that sets the certificate verification context using the system store.
61
+ """
62
+ try:
63
+ truststore = importlib.import_module("truststore")
64
+
65
+ context_factory = cast("Callable[[int], ssl.SSLContext]", truststore.SSLContext)
66
+ ctx = context_factory(ssl.PROTOCOL_TLS_CLIENT)
67
+ return with_verify_context(verify=ctx)
68
+ except ImportError:
69
+ message = "Для использования truststore установите cloudru-qa-http-client[truststore]"
70
+ raise ImportError(message) from None
71
+
72
+
73
+ def with_auth(auth: AuthTypes) -> ClientOption:
74
+ """Set HTTP client authentication.
75
+
76
+ :param auth: Authentication credentials
77
+ :returns: Option that sets HTTP client authentication
78
+ """
79
+
80
+ def option(client: BaseHttpClient) -> None:
81
+ client.auth = auth
82
+
83
+ return option
84
+
85
+
86
+ def with_autogenerated_request_id() -> ClientOption:
87
+ """Set an automatically generated ID for each HTTP client request.
88
+
89
+ :returns: Option that enables automatic request IDs for the HTTP client
90
+ """
91
+
92
+ def option(client: BaseHttpClient) -> None:
93
+ client.add_request_hook(request_id_hook, prepend=True)
94
+
95
+ return option
96
+
97
+
98
+ def with_autogenerated_traceparent() -> ClientOption:
99
+ """Set an automatically generated trace ID for each HTTP client request.
100
+
101
+ :returns: Option that enables automatic trace IDs for the HTTP client
102
+ """
103
+
104
+ def option(client: BaseHttpClient) -> None:
105
+ client.add_request_hook(traceparent_hook, prepend=True)
106
+
107
+ return option
108
+
109
+
110
+ def with_send_decorator(decorator: SendDecorator) -> ClientOption:
111
+ """Add a decorator to the HTTP client send method.
112
+
113
+ :param decorator: Decorator to apply to the send method
114
+ :returns: Option that adds a decorator to the HTTP client
115
+ """
116
+
117
+ def option(client: BaseHttpClient) -> None:
118
+ client.add_send_decorator(decorator)
119
+
120
+ return option
121
+
122
+
123
+ def with_stream_decorator(decorator: StreamRequestDecorator) -> ClientOption:
124
+ """Add a decorator to the HTTP client stream_request method.
125
+
126
+ :param decorator: Decorator to apply to the stream_request method
127
+ :returns: Option that adds a decorator to the HTTP client
128
+ """
129
+
130
+ def option(client: BaseHttpClient) -> None:
131
+ client.add_stream_decorator(decorator)
132
+
133
+ return option
134
+
135
+
136
+ def with_logger(logger: Logger, **settings: Unpack[LoggingOptions]) -> ClientOption:
137
+ """Set the logger for HTTP client requests and responses.
138
+
139
+ :param logger: Logger
140
+ :param sanitize_keys: Keys in [glom](https://glom.readthedocs.io/en/latest/) format
141
+ to mask sensitive data
142
+ :param json_indent: JSON log indentation width (no indentation by default)
143
+ :param use_line_breaks: Whether to add line breaks between request properties (disabled by default)
144
+ :param log_level: HTTP client log level, INFO by default
145
+ :param use_default_sanitize_keys: Whether to mask common secrets
146
+ (see `DEFAULT_SANITIZE_KEYS`) in addition to `sanitize_keys`
147
+ :returns: Option that sets the HTTP client logger and hooks
148
+ """
149
+ _reject_unknown_options(settings, LoggingOptions.__optional_keys__)
150
+ sanitize_keys = settings.get("sanitize_keys")
151
+ json_indent = settings.get("json_indent")
152
+ use_line_breaks = settings.get("use_line_breaks")
153
+ log_level = settings.get("log_level")
154
+ use_default_sanitize_keys = settings.get("use_default_sanitize_keys", True)
155
+ keys = list(sanitize_keys or [])
156
+ if use_default_sanitize_keys:
157
+ keys.extend(key for key in DEFAULT_SANITIZE_KEYS if key not in keys)
158
+
159
+ def option(client: BaseHttpClient) -> None:
160
+ client.add_request_hook(
161
+ request_logger_hook(
162
+ logger=logger,
163
+ sanitize_keys=keys,
164
+ json_indent=json_indent,
165
+ use_line_breaks=use_line_breaks,
166
+ log_level=log_level,
167
+ )
168
+ )
169
+ build_response_hook = (
170
+ async_response_logger_hook if isinstance(client, AsyncHttpClient) else response_logger_hook
171
+ )
172
+ client.add_response_hook(
173
+ build_response_hook(
174
+ logger=logger,
175
+ sanitize_keys=keys,
176
+ json_indent=json_indent,
177
+ use_line_breaks=use_line_breaks,
178
+ log_level=log_level,
179
+ )
180
+ )
181
+
182
+ return option
183
+
184
+
185
+ def with_default_headers(headers: dict[str, str]) -> ClientOption:
186
+ """Set default HTTP client headers.
187
+
188
+ :param headers: Header dictionary
189
+ :returns: Option that sets default HTTP client headers
190
+ """
191
+
192
+ def option(client: BaseHttpClient) -> None:
193
+ client.add_request_hook(add_default_headers_hook(headers))
194
+
195
+ return option
196
+
197
+
198
+ def with_request_hook(hook: RequestHook) -> ClientOption:
199
+ """Set a hook to process HTTP requests before sending them."""
200
+
201
+ def option(client: BaseHttpClient) -> None:
202
+ client.add_request_hook(hook)
203
+
204
+ return option
205
+
206
+
207
+ def with_response_hook(hook: ResponseHook) -> ClientOption:
208
+ """Set a hook to process HTTP responses after receiving them."""
209
+
210
+ def option(client: BaseHttpClient) -> None:
211
+ client.add_response_hook(hook)
212
+
213
+ return option
214
+
215
+
216
+ def with_follow_redirects(max_redirects: int = DEFAULT_MAX_REDIRECTS) -> ClientOption:
217
+ """Enable HTTP client redirects.
218
+
219
+ :param max_redirects: Maximum number of redirects
220
+ :returns: Option that enables HTTP client redirects
221
+ """
222
+
223
+ def option(client: BaseHttpClient) -> None:
224
+ client.enable_redirects(max_redirects)
225
+
226
+ return option
http_client/py.typed ADDED
@@ -0,0 +1 @@
1
+ This marker file indicates that the package supports type checking under PEP 561
http_client/types.py ADDED
@@ -0,0 +1,141 @@
1
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
2
+ from contextlib import AbstractAsyncContextManager, AbstractContextManager
3
+ from typing import Protocol, TypedDict
4
+
5
+ import httpx
6
+ from httpx._client import UseClientDefault
7
+ from httpx._types import (
8
+ AuthTypes,
9
+ CookieTypes,
10
+ HeaderTypes,
11
+ QueryParamTypes,
12
+ RequestContent,
13
+ RequestFiles,
14
+ TimeoutTypes,
15
+ )
16
+
17
+ type EventHook[T = object] = Callable[[T], Awaitable[None] | None]
18
+ type RequestHook = EventHook[httpx.Request]
19
+ type ResponseHook = EventHook[httpx.Response]
20
+ type RequestData = Mapping[str, object]
21
+
22
+
23
+ class LoggingOptions(TypedDict, total=False):
24
+ """Named logging settings for an individual HTTP client."""
25
+
26
+ sanitize_keys: Sequence[str] | None
27
+ json_indent: int | None
28
+ use_line_breaks: bool | None
29
+ log_level: int | None
30
+ use_default_sanitize_keys: bool
31
+
32
+
33
+ class QueryOptions(TypedDict, total=False):
34
+ """Named options for an HTTP request without a body."""
35
+
36
+ params: QueryParamTypes | None
37
+ headers: HeaderTypes | None
38
+ cookies: CookieTypes | None
39
+ auth: AuthTypes | None
40
+ follow_redirects: bool | None
41
+ timeout: TimeoutTypes | UseClientDefault
42
+ extensions: dict[str, object] | None
43
+ anonymous: bool
44
+
45
+
46
+ class RequestOptions(QueryOptions, total=False):
47
+ """Named options for an HTTP request with a body."""
48
+
49
+ content: RequestContent | None
50
+ data: RequestData | None
51
+ files: RequestFiles | None
52
+ json: object
53
+
54
+
55
+ class StreamOptions(TypedDict, total=False):
56
+ """Options for sending a prepared request in streaming mode."""
57
+
58
+ auth: AuthTypes | None
59
+ follow_redirects: bool | None
60
+ anonymous: bool
61
+
62
+
63
+ class SendOptions(StreamOptions, total=False):
64
+ """Options for sending a prepared HTTP request."""
65
+
66
+ stream: bool
67
+
68
+
69
+ class SyncSendMethod(Protocol):
70
+ """Protocol for a synchronous send method."""
71
+
72
+ def __call__(
73
+ self,
74
+ request: httpx.Request,
75
+ *,
76
+ stream: bool = False,
77
+ auth: AuthTypes | None = None,
78
+ follow_redirects: bool | None = None,
79
+ anonymous: bool = False,
80
+ ) -> httpx.Response:
81
+ """Send a prepared HTTP request and return the response."""
82
+ ...
83
+
84
+
85
+ class AsyncSendMethod(Protocol):
86
+ """Protocol for an asynchronous send method."""
87
+
88
+ async def __call__(
89
+ self,
90
+ request: httpx.Request,
91
+ *,
92
+ stream: bool = False,
93
+ auth: AuthTypes | None = None,
94
+ follow_redirects: bool | None = None,
95
+ anonymous: bool = False,
96
+ ) -> httpx.Response:
97
+ """Send a prepared HTTP request asynchronously and return the response."""
98
+ ...
99
+
100
+
101
+ class SyncStreamRequestMethod(Protocol):
102
+ """Protocol for a synchronous stream_request method."""
103
+
104
+ def __call__(
105
+ self,
106
+ request: httpx.Request,
107
+ *,
108
+ anonymous: bool = False,
109
+ auth: AuthTypes | None = None,
110
+ follow_redirects: bool | None = None,
111
+ ) -> AbstractContextManager[httpx.Response]:
112
+ """Open a streaming HTTP response context."""
113
+ ...
114
+
115
+
116
+ class AsyncStreamRequestMethod(Protocol):
117
+ """Protocol for an asynchronous stream_request method."""
118
+
119
+ def __call__(
120
+ self,
121
+ request: httpx.Request,
122
+ *,
123
+ anonymous: bool = False,
124
+ auth: AuthTypes | None = None,
125
+ follow_redirects: bool | None = None,
126
+ ) -> AbstractAsyncContextManager[httpx.Response]:
127
+ """Open an asynchronous streaming HTTP response context."""
128
+ ...
129
+
130
+
131
+ # Types for send method decorators
132
+ SyncSendDecorator = Callable[[SyncSendMethod], SyncSendMethod]
133
+ AsyncSendDecorator = Callable[[AsyncSendMethod], AsyncSendMethod]
134
+
135
+ SendDecorator = SyncSendDecorator | AsyncSendDecorator
136
+
137
+ # Types for stream_request method decorators
138
+ SyncStreamRequestDecorator = Callable[[SyncStreamRequestMethod], SyncStreamRequestMethod]
139
+ AsyncStreamRequestDecorator = Callable[[AsyncStreamRequestMethod], AsyncStreamRequestMethod]
140
+
141
+ StreamRequestDecorator = SyncStreamRequestDecorator | AsyncStreamRequestDecorator
File without changes