cliV1 0.0.7__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.
- cliv1-0.0.7.dist-info/METADATA +212 -0
- cliv1-0.0.7.dist-info/RECORD +92 -0
- cliv1-0.0.7.dist-info/WHEEL +4 -0
- cliv1-0.0.7.dist-info/licenses/LICENSE +21 -0
- swagger_petstore_open_api_3_1_0/__init__.py +15 -0
- swagger_petstore_open_api_3_1_0/apis/__init__.py +5 -0
- swagger_petstore_open_api_3_1_0/apis/pet_api.py +922 -0
- swagger_petstore_open_api_3_1_0/apis/store.py +432 -0
- swagger_petstore_open_api_3_1_0/apis/user_api.py +829 -0
- swagger_petstore_open_api_3_1_0/async_client.py +87 -0
- swagger_petstore_open_api_3_1_0/auth.py +17 -0
- swagger_petstore_open_api_3_1_0/base_client.py +27 -0
- swagger_petstore_open_api_3_1_0/client.py +85 -0
- swagger_petstore_open_api_3_1_0/core/__init__.py +395 -0
- swagger_petstore_open_api_3_1_0/core/_internal/__init__.py +9 -0
- swagger_petstore_open_api_3_1_0/core/_internal/content_disposition.py +154 -0
- swagger_petstore_open_api_3_1_0/core/_internal/flattening.py +104 -0
- swagger_petstore_open_api_3_1_0/core/_internal/headers.py +243 -0
- swagger_petstore_open_api_3_1_0/core/_internal/redaction.py +208 -0
- swagger_petstore_open_api_3_1_0/core/_internal/sse_parser.py +124 -0
- swagger_petstore_open_api_3_1_0/core/_internal/subscripts.py +50 -0
- swagger_petstore_open_api_3_1_0/core/_internal/urls.py +172 -0
- swagger_petstore_open_api_3_1_0/core/_internal/wire.py +201 -0
- swagger_petstore_open_api_3_1_0/core/adapters.py +177 -0
- swagger_petstore_open_api_3_1_0/core/auth/__init__.py +70 -0
- swagger_petstore_open_api_3_1_0/core/auth/composition.py +175 -0
- swagger_petstore_open_api_3_1_0/core/auth/models.py +216 -0
- swagger_petstore_open_api_3_1_0/core/auth/protocols.py +103 -0
- swagger_petstore_open_api_3_1_0/core/auth/schemes/__init__.py +94 -0
- swagger_petstore_open_api_3_1_0/core/auth/schemes/api_key_auth.py +53 -0
- swagger_petstore_open_api_3_1_0/core/auth/schemes/basic_auth.py +73 -0
- swagger_petstore_open_api_3_1_0/core/auth/schemes/bearer_auth.py +21 -0
- swagger_petstore_open_api_3_1_0/core/auth/schemes/oauth2_authorization_code.py +395 -0
- swagger_petstore_open_api_3_1_0/core/auth/schemes/oauth2_client_credentials.py +130 -0
- swagger_petstore_open_api_3_1_0/core/auth/schemes/oauth2_password.py +145 -0
- swagger_petstore_open_api_3_1_0/core/auth/schemes/oauth2_schemes.py +300 -0
- swagger_petstore_open_api_3_1_0/core/base_raw_response.py +49 -0
- swagger_petstore_open_api_3_1_0/core/bodies.py +590 -0
- swagger_petstore_open_api_3_1_0/core/converters/__init__.py +68 -0
- swagger_petstore_open_api_3_1_0/core/converters/date_time.py +237 -0
- swagger_petstore_open_api_3_1_0/core/converters/encoded_bytes.py +273 -0
- swagger_petstore_open_api_3_1_0/core/converters/open_enum.py +35 -0
- swagger_petstore_open_api_3_1_0/core/decoding.py +498 -0
- swagger_petstore_open_api_3_1_0/core/discriminators.py +129 -0
- swagger_petstore_open_api_3_1_0/core/event_streams.py +225 -0
- swagger_petstore_open_api_3_1_0/core/exceptions.py +78 -0
- swagger_petstore_open_api_3_1_0/core/file_responses.py +337 -0
- swagger_petstore_open_api_3_1_0/core/files.py +174 -0
- swagger_petstore_open_api_3_1_0/core/httpx2_transport.py +748 -0
- swagger_petstore_open_api_3_1_0/core/logging.py +472 -0
- swagger_petstore_open_api_3_1_0/core/models.py +143 -0
- swagger_petstore_open_api_3_1_0/core/optionality.py +144 -0
- swagger_petstore_open_api_3_1_0/core/pagination.py +452 -0
- swagger_petstore_open_api_3_1_0/core/paginator.py +55 -0
- swagger_petstore_open_api_3_1_0/core/params.py +157 -0
- swagger_petstore_open_api_3_1_0/core/raw_client.py +480 -0
- swagger_petstore_open_api_3_1_0/core/request_options.py +172 -0
- swagger_petstore_open_api_3_1_0/core/results.py +178 -0
- swagger_petstore_open_api_3_1_0/core/retries.py +237 -0
- swagger_petstore_open_api_3_1_0/core/runtime_env.py +43 -0
- swagger_petstore_open_api_3_1_0/core/servers.py +33 -0
- swagger_petstore_open_api_3_1_0/core/transport.py +218 -0
- swagger_petstore_open_api_3_1_0/errors/__init__.py +48 -0
- swagger_petstore_open_api_3_1_0/errors/add_pet_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/delete_order_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/delete_pet_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/delete_user_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/find_pets_by_status_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/find_pets_by_tags_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/get_order_by_id_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/get_pet_by_id_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/get_user_by_name_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/login_user_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/place_order_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/update_pet_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/update_pet_with_form_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/update_user_error.py +21 -0
- swagger_petstore_open_api_3_1_0/errors/upload_file_error.py +21 -0
- swagger_petstore_open_api_3_1_0/models/__init__.py +23 -0
- swagger_petstore_open_api_3_1_0/models/api_response.py +18 -0
- swagger_petstore_open_api_3_1_0/models/category.py +15 -0
- swagger_petstore_open_api_3_1_0/models/enums/__init__.py +4 -0
- swagger_petstore_open_api_3_1_0/models/enums/order_status.py +17 -0
- swagger_petstore_open_api_3_1_0/models/enums/pet_status.py +17 -0
- swagger_petstore_open_api_3_1_0/models/order.py +27 -0
- swagger_petstore_open_api_3_1_0/models/pet.py +28 -0
- swagger_petstore_open_api_3_1_0/models/tag_model.py +15 -0
- swagger_petstore_open_api_3_1_0/models/user.py +29 -0
- swagger_petstore_open_api_3_1_0/py.typed +0 -0
- swagger_petstore_open_api_3_1_0/server/__init__.py +21 -0
- swagger_petstore_open_api_3_1_0/server/server.py +17 -0
- swagger_petstore_open_api_3_1_0/server/server_config.py +52 -0
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
"""HTTP logging: what a caller may ask the runtime to say, and the logger it says it through.
|
|
2
|
+
|
|
3
|
+
Everything reaches the standard ``logging`` module under this module's own logger, so an application
|
|
4
|
+
configures the SDK's output as it configures any library's -- a level and a handler on the package
|
|
5
|
+
logger -- and nothing here adds a handler to any other logger, sets a level, or touches the root.
|
|
6
|
+
Off by default: the ``NullHandler`` below means that with no configuration a record is dropped here
|
|
7
|
+
rather than reaching ``logging``'s last-resort stderr handler. The one exception is the operator's own
|
|
8
|
+
word: ``<PACKAGE>_LOG``, read by :meth:`LoggingOptions.coerce` once per client when the generated client
|
|
9
|
+
names its package, does to the package logger what the application would -- sets its level and gives it
|
|
10
|
+
the stderr fallback, a handler that prints a record only where no other handler would receive it, by
|
|
11
|
+
``logging.lastResort``'s own rule, decided as each record is written -- and turns the flags on for
|
|
12
|
+
``debug``. It never touches another logger, and never turns a flag off.
|
|
13
|
+
|
|
14
|
+
The options are caller input and are validated once, at the client. What is written, and when, is
|
|
15
|
+
the business of one object, :class:`HttpLog`: ``execute`` opens one per call and reports a call that
|
|
16
|
+
ends in an exception, the callable that builds and sends an attempt reports each request to it, and the
|
|
17
|
+
retry loop reports each response head and each retry. A token-endpoint request is logged like any other,
|
|
18
|
+
because it runs through the same loop."""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import logging
|
|
23
|
+
import os
|
|
24
|
+
import time
|
|
25
|
+
from collections.abc import Mapping, Sequence
|
|
26
|
+
from collections.abc import Set as AbstractSet
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from http import HTTPStatus
|
|
29
|
+
from typing import TypeAlias
|
|
30
|
+
|
|
31
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
32
|
+
from typing_extensions import NotRequired, TypedDict
|
|
33
|
+
|
|
34
|
+
from ._internal.redaction import DEFAULT_PLACEHOLDER, base_name, redact_form, redact_headers, redact_url
|
|
35
|
+
from .bodies import BinaryBody, FormBody, JsonBody, MultipartBody, MultipartFile, MultipartPart, MultipartText, TextBody
|
|
36
|
+
from .files import AsyncBinaryContent
|
|
37
|
+
from .transport import HttpRequest
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger(__name__)
|
|
40
|
+
"""The one logger the runtime writes through. Configure it by name; the SDK never does."""
|
|
41
|
+
logger.addHandler(logging.NullHandler())
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _only_the_fallback_listens(record: logging.LogRecord) -> bool:
|
|
45
|
+
"""``logging.lastResort``'s rule, for the stderr fallback: a record passes only when no handler but the fallback
|
|
46
|
+
is attached on the path ``callHandlers`` walks -- the record's logger, then its ancestors while ``propagate``
|
|
47
|
+
holds. A ``NullHandler`` is never a listener, since the SDK's own would otherwise silence the fallback; any
|
|
48
|
+
other handler is one by being attached, whatever its level or filters say, because either is the
|
|
49
|
+
application's choice. Decided per record, so an application that configures logging after the client was
|
|
50
|
+
built is seen the moment it does. Runs as a filter, before the handler's lock, so asking ``getLogger`` for the
|
|
51
|
+
module lock never nests under it."""
|
|
52
|
+
node: logging.Logger | None = logging.getLogger(record.name)
|
|
53
|
+
while node is not None:
|
|
54
|
+
if any(h is not _stderr_fallback and not isinstance(h, logging.NullHandler) for h in node.handlers):
|
|
55
|
+
return False
|
|
56
|
+
if not node.propagate:
|
|
57
|
+
break
|
|
58
|
+
node = node.parent
|
|
59
|
+
return True
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
_stderr_fallback = logging.StreamHandler()
|
|
63
|
+
"""The one handler ``<PACKAGE>_LOG`` attaches to the package logger: a module-level object, so ``addHandler`` --
|
|
64
|
+
idempotent under ``logging``'s own lock -- makes a second client, or a second thread, add nothing."""
|
|
65
|
+
_stderr_fallback.setFormatter(logging.Formatter(logging.BASIC_FORMAT))
|
|
66
|
+
_stderr_fallback.addFilter(_only_the_fallback_listens)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class LoggingOptions(BaseModel):
|
|
70
|
+
"""What the SDK may put in its log records, beyond the request and response lines.
|
|
71
|
+
|
|
72
|
+
Caller input, so a pydantic model: the bounds on ``body_size_limit`` and ``redaction_placeholder`` and the
|
|
73
|
+
unknown-key rejection are declared, not handwritten, exactly as on :class:`~.retries.RetryOptions`. The
|
|
74
|
+
three flags default off -- headers and a body are opt-in; the placeholder defaults to the standard's
|
|
75
|
+
``REDACTED`` and may not be empty. Every name set is lowercased once here, so the comparisons downstream
|
|
76
|
+
are case-insensitive by construction."""
|
|
77
|
+
|
|
78
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
79
|
+
|
|
80
|
+
log_request_headers: bool = False
|
|
81
|
+
"""Dump the sent headers at DEBUG, masked. Off by default."""
|
|
82
|
+
|
|
83
|
+
log_response_headers: bool = False
|
|
84
|
+
"""Dump the received headers at DEBUG, masked. Off by default."""
|
|
85
|
+
|
|
86
|
+
log_request_body: bool = False
|
|
87
|
+
"""Log the request body at DEBUG: its text when in memory, a description when streamed. Off by default."""
|
|
88
|
+
|
|
89
|
+
body_size_limit: int = Field(default=32 * 1024, gt=0)
|
|
90
|
+
"""Bytes of a printed body kept before it is cut, on a character boundary; a multipart body is cut part by part."""
|
|
91
|
+
|
|
92
|
+
redaction_placeholder: str = Field(default=DEFAULT_PLACEHOLDER, min_length=1)
|
|
93
|
+
"""What a hidden header value, query value or form field prints as; never empty."""
|
|
94
|
+
|
|
95
|
+
unmask_headers: AbstractSet[str] = frozenset()
|
|
96
|
+
"""Header names shown even though they are not on the safe list."""
|
|
97
|
+
|
|
98
|
+
mask_headers: AbstractSet[str] = frozenset()
|
|
99
|
+
"""Header names hidden even though they are on the safe list."""
|
|
100
|
+
|
|
101
|
+
mask_keys: AbstractSet[str] = frozenset(
|
|
102
|
+
{
|
|
103
|
+
"sig",
|
|
104
|
+
"signature",
|
|
105
|
+
"access_token",
|
|
106
|
+
"apikey",
|
|
107
|
+
"api_key",
|
|
108
|
+
"client_secret",
|
|
109
|
+
"password",
|
|
110
|
+
"refresh_token",
|
|
111
|
+
"code",
|
|
112
|
+
"assertion",
|
|
113
|
+
"client_assertion",
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
"""Query-string, form-body and multipart-field keys whose values are always hidden: the OAuth 2.0 credential
|
|
117
|
+
and assertion parameters, the two common api-key spellings and a signed URL's signature. A URL's query hides
|
|
118
|
+
every key outside the known-safe vocabulary anyway, so this list matters there only for a known-safe key."""
|
|
119
|
+
|
|
120
|
+
@field_validator("unmask_headers", "mask_headers", "mask_keys")
|
|
121
|
+
@classmethod
|
|
122
|
+
def _lowercase_names(cls, names: AbstractSet[str]) -> frozenset[str]:
|
|
123
|
+
return frozenset(name.lower() for name in names)
|
|
124
|
+
|
|
125
|
+
@classmethod
|
|
126
|
+
def coerce(cls, options: LoggingOptionsOrDict | None, *, package: str | None = None) -> LoggingOptions:
|
|
127
|
+
"""Return ``options`` as a :class:`LoggingOptions`, whichever spelling it arrived in, with the
|
|
128
|
+
environment's word laid over it where ``package`` is named.
|
|
129
|
+
|
|
130
|
+
``<PACKAGE>_LOG`` -- ``ACME_LOG`` for the package ``acme`` -- is read once here, trimmed and
|
|
131
|
+
lowercased. ``info`` sets the package logger to INFO; ``debug`` sets it to DEBUG and turns the three
|
|
132
|
+
flags on. Either attaches the stderr fallback to that logger, which prints a record only when no other
|
|
133
|
+
handler would receive it, decided as the record is written -- so a bare script prints, and an application
|
|
134
|
+
that configures logging, before or after the client is built, keeps its own output once. The level is the
|
|
135
|
+
word's, whatever was set before; the flags only turn on, so a flag the caller set stays, and the masks,
|
|
136
|
+
the limit and the placeholder are the caller's. Any other value, or none, changes nothing. The fallback
|
|
137
|
+
is one object, so a second client, or a second thread, adds nothing. An application's ``dictConfig`` with
|
|
138
|
+
``disable_existing_loggers`` left on silences the package logger entirely, which is its choice.
|
|
139
|
+
|
|
140
|
+
Args:
|
|
141
|
+
options: The typed options, the dict spelling, or ``None`` for the defaults.
|
|
142
|
+
package: The top-level package whose logger the environment may switch on -- the name an
|
|
143
|
+
application configures. ``None`` consults no environment.
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
A validated :class:`LoggingOptions`; ``LoggingOptions()`` when ``options`` is ``None``; the flags on
|
|
147
|
+
over either when the environment says ``debug``.
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
ValidationError: If ``options`` carries an unknown key or an out-of-range value."""
|
|
151
|
+
if options is None:
|
|
152
|
+
resolved = cls()
|
|
153
|
+
elif isinstance(options, cls):
|
|
154
|
+
resolved = options
|
|
155
|
+
else:
|
|
156
|
+
resolved = cls.model_validate(options)
|
|
157
|
+
if package is None:
|
|
158
|
+
return resolved
|
|
159
|
+
|
|
160
|
+
level_name = os.environ.get(f"{package.upper()}_LOG", "").strip().lower()
|
|
161
|
+
if level_name not in ("info", "debug"):
|
|
162
|
+
return resolved
|
|
163
|
+
package_logger = logging.getLogger(package)
|
|
164
|
+
package_logger.setLevel(level_name.upper())
|
|
165
|
+
package_logger.addHandler(_stderr_fallback)
|
|
166
|
+
if level_name == "info":
|
|
167
|
+
return resolved
|
|
168
|
+
return resolved.model_copy(
|
|
169
|
+
update={"log_request_headers": True, "log_response_headers": True, "log_request_body": True}
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class LoggingOptionsDict(TypedDict):
|
|
174
|
+
"""The dict-shaped spelling of :class:`LoggingOptions`, mirroring it field for field."""
|
|
175
|
+
|
|
176
|
+
log_request_headers: NotRequired[bool]
|
|
177
|
+
log_response_headers: NotRequired[bool]
|
|
178
|
+
log_request_body: NotRequired[bool]
|
|
179
|
+
body_size_limit: NotRequired[int]
|
|
180
|
+
redaction_placeholder: NotRequired[str]
|
|
181
|
+
unmask_headers: NotRequired[AbstractSet[str]]
|
|
182
|
+
mask_headers: NotRequired[AbstractSet[str]]
|
|
183
|
+
mask_keys: NotRequired[AbstractSet[str]]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
LoggingOptionsOrDict: TypeAlias = LoggingOptions | LoggingOptionsDict
|
|
187
|
+
"""The typed options, or a dict carrying the same keys."""
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass(slots=True, kw_only=True)
|
|
191
|
+
class HttpLog:
|
|
192
|
+
"""One call's log scope: the attempt it last reported, and the four events that write its records.
|
|
193
|
+
|
|
194
|
+
``execute`` opens one per call and reports a call that ends in an exception (:meth:`failed`); the callable that
|
|
195
|
+
builds and sends an attempt reports each request (:meth:`request_sending`); the retry loop reports each response
|
|
196
|
+
head, saying whether it is a success (:meth:`response_received`, which keeps no status rule of its own), and
|
|
197
|
+
each retry (:meth:`retrying`). It is mutable for one reason: it remembers the method, the printable URL and the
|
|
198
|
+
attempt count, so the loop never holds a request. Every record first asks :meth:`_is_enabled_for`, and a dump or
|
|
199
|
+
the body adds its own flag; the call's ``log_level_override`` is a floor that silences and never turns a dump on
|
|
200
|
+
(ADR-0068). The masking rules live in ``_internal/redaction.py``."""
|
|
201
|
+
|
|
202
|
+
options: LoggingOptions
|
|
203
|
+
log_level_override: int | None
|
|
204
|
+
started_at: float
|
|
205
|
+
|
|
206
|
+
method: str = field(default="", init=False)
|
|
207
|
+
url: str = field(default="", init=False)
|
|
208
|
+
attempts: int = field(default=0, init=False)
|
|
209
|
+
sent_at: float = field(default=0.0, init=False)
|
|
210
|
+
|
|
211
|
+
def request_sending(self, request: HttpRequest) -> None:
|
|
212
|
+
self.method = request.method
|
|
213
|
+
# Rendered when any record could pass the level checks. ERROR is the highest level this scope writes at,
|
|
214
|
+
# so a record added above ERROR must raise this gate.
|
|
215
|
+
self.url = (
|
|
216
|
+
redact_url(request.url, mask_keys=self.options.mask_keys, placeholder=self.options.redaction_placeholder)
|
|
217
|
+
if self._is_enabled_for(logging.ERROR)
|
|
218
|
+
else ""
|
|
219
|
+
)
|
|
220
|
+
self.attempts += 1
|
|
221
|
+
|
|
222
|
+
if self._is_enabled_for(logging.INFO):
|
|
223
|
+
fields: dict[str, object] = {"http.request.method": self.method, "url.full": self.url}
|
|
224
|
+
if self.attempts > 1:
|
|
225
|
+
fields["http.request.resend_count"] = self.attempts - 1
|
|
226
|
+
logger.info("HTTP %(http.request.method)s %(url.full)s", fields, extra=fields)
|
|
227
|
+
|
|
228
|
+
if self.options.log_request_headers and self._is_enabled_for(logging.DEBUG):
|
|
229
|
+
fields = {"http.request.method": self.method, "url.full": self.url}
|
|
230
|
+
if self.attempts > 1:
|
|
231
|
+
fields["http.request.resend_count"] = self.attempts - 1
|
|
232
|
+
redacted_headers = redact_headers(
|
|
233
|
+
request.headers,
|
|
234
|
+
unmask=self.options.unmask_headers,
|
|
235
|
+
mask=self.options.mask_headers,
|
|
236
|
+
placeholder=self.options.redaction_placeholder,
|
|
237
|
+
)
|
|
238
|
+
header_fields = {
|
|
239
|
+
**fields,
|
|
240
|
+
**{f"http.request.header.{name}": [value] for name, value in redacted_headers.items()},
|
|
241
|
+
}
|
|
242
|
+
header_lines = "".join(f"\n {name}: {value}" for name, value in redacted_headers.items())
|
|
243
|
+
logger.debug(
|
|
244
|
+
"request headers:%(header_lines)s",
|
|
245
|
+
{**header_fields, "header_lines": header_lines},
|
|
246
|
+
extra=header_fields,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
if self.options.log_request_body and self._is_enabled_for(logging.DEBUG):
|
|
250
|
+
fields = {"http.request.method": self.method, "url.full": self.url}
|
|
251
|
+
if self.attempts > 1:
|
|
252
|
+
fields["http.request.resend_count"] = self.attempts - 1
|
|
253
|
+
content_type = request.headers.get("content-type", "")
|
|
254
|
+
match request.body:
|
|
255
|
+
case JsonBody(content, _):
|
|
256
|
+
body_fields = {
|
|
257
|
+
**fields,
|
|
258
|
+
"http.request.header.content-type": [content_type],
|
|
259
|
+
"http.request.body.content": _body_text(content, "utf-8", self.options.body_size_limit),
|
|
260
|
+
"http.request.body.size": len(content),
|
|
261
|
+
}
|
|
262
|
+
logger.debug(
|
|
263
|
+
"request body (%(content_type)s): %(http.request.body.content)s",
|
|
264
|
+
{**body_fields, "content_type": content_type},
|
|
265
|
+
extra=body_fields,
|
|
266
|
+
)
|
|
267
|
+
case FormBody(content):
|
|
268
|
+
redacted_form = redact_form(
|
|
269
|
+
content, mask_keys=self.options.mask_keys, placeholder=self.options.redaction_placeholder
|
|
270
|
+
)
|
|
271
|
+
body_fields = {
|
|
272
|
+
**fields,
|
|
273
|
+
"http.request.header.content-type": [content_type],
|
|
274
|
+
"http.request.body.content": _body_text(redacted_form, "utf-8", self.options.body_size_limit),
|
|
275
|
+
"http.request.body.size": len(content),
|
|
276
|
+
}
|
|
277
|
+
logger.debug(
|
|
278
|
+
"request body (%(content_type)s): %(http.request.body.content)s",
|
|
279
|
+
{**body_fields, "content_type": content_type},
|
|
280
|
+
extra=body_fields,
|
|
281
|
+
)
|
|
282
|
+
case TextBody(content, _, charset):
|
|
283
|
+
body_fields = {
|
|
284
|
+
**fields,
|
|
285
|
+
"http.request.header.content-type": [content_type],
|
|
286
|
+
"http.request.body.content": _body_text(content, charset, self.options.body_size_limit),
|
|
287
|
+
"http.request.body.size": len(content),
|
|
288
|
+
}
|
|
289
|
+
logger.debug(
|
|
290
|
+
"request body (%(content_type)s): %(http.request.body.content)s",
|
|
291
|
+
{**body_fields, "content_type": content_type},
|
|
292
|
+
extra=body_fields,
|
|
293
|
+
)
|
|
294
|
+
case BinaryBody(content, filename, _):
|
|
295
|
+
size = _buffered_size(content)
|
|
296
|
+
if size is not None:
|
|
297
|
+
fields["http.request.body.size"] = size
|
|
298
|
+
logger.debug(
|
|
299
|
+
"request body (%(content_type)s): %(description)s",
|
|
300
|
+
{**fields, "content_type": content_type, "description": _describe_file(content, filename)},
|
|
301
|
+
extra=fields,
|
|
302
|
+
)
|
|
303
|
+
case MultipartBody(parts):
|
|
304
|
+
logger.debug(
|
|
305
|
+
"request body (%(content_type)s): %(description)s",
|
|
306
|
+
{
|
|
307
|
+
**fields,
|
|
308
|
+
"content_type": "multipart/form-data",
|
|
309
|
+
"description": _describe_parts(parts, self.options),
|
|
310
|
+
},
|
|
311
|
+
extra=fields,
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
# The response line's clock starts here, after this attempt's records, so their cost is not in it.
|
|
315
|
+
self.sent_at = time.perf_counter()
|
|
316
|
+
|
|
317
|
+
def response_received(self, status_code: int, headers: Mapping[str, str], *, is_success: bool) -> None:
|
|
318
|
+
level = logging.INFO if is_success else logging.WARNING
|
|
319
|
+
if self._is_enabled_for(level):
|
|
320
|
+
elapsed_seconds = time.perf_counter() - self.sent_at
|
|
321
|
+
fields: dict[str, object] = {
|
|
322
|
+
"http.request.method": self.method,
|
|
323
|
+
"url.full": self.url,
|
|
324
|
+
"http.response.status_code": status_code,
|
|
325
|
+
"elapsed_seconds": elapsed_seconds,
|
|
326
|
+
}
|
|
327
|
+
if self.attempts > 1:
|
|
328
|
+
fields["http.request.resend_count"] = self.attempts - 1
|
|
329
|
+
logger.log(
|
|
330
|
+
level,
|
|
331
|
+
"HTTP %(http.request.method)s %(url.full)s -> %(http.response.status_code)d (%(elapsed_ms)d ms)",
|
|
332
|
+
{**fields, "elapsed_ms": round(elapsed_seconds * 1000)},
|
|
333
|
+
extra=fields,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
if self.options.log_response_headers and self._is_enabled_for(logging.DEBUG):
|
|
337
|
+
fields = {"http.request.method": self.method, "url.full": self.url}
|
|
338
|
+
if self.attempts > 1:
|
|
339
|
+
fields["http.request.resend_count"] = self.attempts - 1
|
|
340
|
+
redacted_headers = redact_headers(
|
|
341
|
+
headers,
|
|
342
|
+
unmask=self.options.unmask_headers,
|
|
343
|
+
mask=self.options.mask_headers,
|
|
344
|
+
placeholder=self.options.redaction_placeholder,
|
|
345
|
+
)
|
|
346
|
+
header_fields = {
|
|
347
|
+
**fields,
|
|
348
|
+
**{f"http.response.header.{name}": [value] for name, value in redacted_headers.items()},
|
|
349
|
+
}
|
|
350
|
+
header_lines = "".join(f"\n {name}: {value}" for name, value in redacted_headers.items())
|
|
351
|
+
logger.debug(
|
|
352
|
+
"response headers:%(header_lines)s",
|
|
353
|
+
{**header_fields, "header_lines": header_lines},
|
|
354
|
+
extra=header_fields,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
def retrying(self, *, max_retries: int, delay: float, reason: int | BaseException) -> None:
|
|
358
|
+
if not self._is_enabled_for(logging.WARNING):
|
|
359
|
+
return
|
|
360
|
+
|
|
361
|
+
if isinstance(reason, int):
|
|
362
|
+
reason_text, error = _status_text(reason), None
|
|
363
|
+
else:
|
|
364
|
+
reason_text, error = type(reason).__name__, reason
|
|
365
|
+
fields: dict[str, object] = {
|
|
366
|
+
"http.request.method": self.method,
|
|
367
|
+
"url.full": self.url,
|
|
368
|
+
"attempt": self.attempts,
|
|
369
|
+
"max_retries": max_retries,
|
|
370
|
+
"delay_seconds": delay,
|
|
371
|
+
"reason": reason_text,
|
|
372
|
+
}
|
|
373
|
+
if self.attempts > 1:
|
|
374
|
+
fields["http.request.resend_count"] = self.attempts - 1
|
|
375
|
+
if error is not None:
|
|
376
|
+
fields["error.type"] = type(error).__qualname__
|
|
377
|
+
logger.warning(
|
|
378
|
+
"HTTP %(http.request.method)s %(url.full)s retrying in %(delay_seconds).1f s"
|
|
379
|
+
" (attempt %(attempt)d/%(max_retries)d): %(reason)s",
|
|
380
|
+
fields,
|
|
381
|
+
exc_info=error,
|
|
382
|
+
extra=fields,
|
|
383
|
+
)
|
|
384
|
+
|
|
385
|
+
def failed(self, error: Exception) -> None:
|
|
386
|
+
# Before the first request is reported nothing reached the wire, and there is no URL to name.
|
|
387
|
+
if self.attempts == 0 or not self._is_enabled_for(logging.ERROR):
|
|
388
|
+
return
|
|
389
|
+
|
|
390
|
+
elapsed_seconds = time.perf_counter() - self.started_at
|
|
391
|
+
fields: dict[str, object] = {
|
|
392
|
+
"http.request.method": self.method,
|
|
393
|
+
"url.full": self.url,
|
|
394
|
+
"elapsed_seconds": elapsed_seconds,
|
|
395
|
+
"error.type": type(error).__qualname__,
|
|
396
|
+
}
|
|
397
|
+
if self.attempts > 1:
|
|
398
|
+
fields["http.request.resend_count"] = self.attempts - 1
|
|
399
|
+
logger.error(
|
|
400
|
+
"HTTP %(http.request.method)s %(url.full)s failed after %(elapsed_ms)d ms: %(error.type)s",
|
|
401
|
+
{**fields, "elapsed_ms": round(elapsed_seconds * 1000)},
|
|
402
|
+
exc_info=error,
|
|
403
|
+
extra=fields,
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
def _is_enabled_for(self, level: int) -> bool:
|
|
407
|
+
"""Whether a record at ``level`` passes both level checks: the call's ``log_level`` override, if one
|
|
408
|
+
was named, and the logger's own -- the standard library's ``isEnabledFor``, a cached dict read."""
|
|
409
|
+
return (self.log_level_override is None or self.log_level_override <= level) and logger.isEnabledFor(level)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _status_text(status_code: int) -> str:
|
|
413
|
+
"""The status as the retry line prints it: ``503 Service Unavailable``, or ``599`` when the phrase is unknown."""
|
|
414
|
+
try:
|
|
415
|
+
return f"{status_code} {HTTPStatus(status_code).phrase}"
|
|
416
|
+
except ValueError:
|
|
417
|
+
return str(status_code)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _body_text(content: bytes, charset: str, limit: int) -> str:
|
|
421
|
+
"""``content`` as a body line prints it, decoded once: whole when it fits in ``limit`` bytes, otherwise its
|
|
422
|
+
first ``limit`` bytes followed by `` ... (N bytes total)``, N being its own size.
|
|
423
|
+
|
|
424
|
+
Only what is printed is decoded, so a large body costs a ``limit``-sized decode. The slice can end only
|
|
425
|
+
inside the last character, and ``errors="ignore"`` drops exactly that partial character."""
|
|
426
|
+
if len(content) <= limit:
|
|
427
|
+
return content.decode(charset, errors="replace")
|
|
428
|
+
head = content[:limit].decode(charset, errors="ignore")
|
|
429
|
+
return f"{head} ... ({len(content)} bytes total)"
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _describe_file(content: AsyncBinaryContent, filename: str | None) -> str:
|
|
433
|
+
"""``<file "report.pdf", 1024 bytes>`` or ``<file, streamed>``, from facts the body holds; nothing is read.
|
|
434
|
+
|
|
435
|
+
The media type is not here: it is the label of the line the description sits on, as the body's own is."""
|
|
436
|
+
name = f' "{filename}"' if filename is not None else ""
|
|
437
|
+
size = _buffered_size(content)
|
|
438
|
+
extent = f"{size} bytes" if size is not None else "streamed"
|
|
439
|
+
return f"<file{name}, {extent}>"
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _buffered_size(content: AsyncBinaryContent) -> int | None:
|
|
443
|
+
"""The size of a buffered source -- ``bytes`` or ``bytearray`` already in memory.
|
|
444
|
+
|
|
445
|
+
``None`` for anything the transport has yet to read: a path, a reader, an iterator."""
|
|
446
|
+
return len(content) if isinstance(content, (bytes, bytearray)) else None
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _describe_parts(parts: Sequence[MultipartPart], options: LoggingOptions) -> str:
|
|
450
|
+
"""A multipart body's part count, then one line per part in the body line's own shape, ``name (media type): value``.
|
|
451
|
+
|
|
452
|
+
RFC 7578 gives a part a name, an optional filename, an optional ``Content-Type`` and its content;
|
|
453
|
+
the dump prints exactly those, laid out as the header dump lays out its lines."""
|
|
454
|
+
heading = f"{len(parts)} parts" if len(parts) != 1 else "1 part"
|
|
455
|
+
return heading + "".join(f"\n {_part_label(part)}: {_part_text(part, options)}" for part in parts)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _part_label(part: MultipartPart) -> str:
|
|
459
|
+
"""A part's name, with its media type in parentheses where it declares one -- a form field declares none."""
|
|
460
|
+
return part.name if part.media_type is None else f"{part.name} ({part.media_type})"
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _part_text(part: MultipartPart, options: LoggingOptions) -> str:
|
|
464
|
+
"""One multipart part's value as the dump prints it: a form field ``mask_keys`` names hidden, as a form
|
|
465
|
+
body's would be; any other text part's bytes decoded and cut on their own; a file part described."""
|
|
466
|
+
match part:
|
|
467
|
+
case MultipartText(name, _, None) if base_name(name) in options.mask_keys:
|
|
468
|
+
return options.redaction_placeholder
|
|
469
|
+
case MultipartText(_, content, _):
|
|
470
|
+
return _body_text(content, "utf-8", options.body_size_limit)
|
|
471
|
+
case MultipartFile(_, content, filename, _):
|
|
472
|
+
return _describe_file(content, filename)
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""The base model every domain model subclasses."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Literal
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, ConfigDict
|
|
9
|
+
|
|
10
|
+
from .optionality import strip_unset
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class SdkBaseModel(BaseModel):
|
|
14
|
+
"""Base class for every domain model.
|
|
15
|
+
|
|
16
|
+
Immutable (``frozen``), **preserves** unknown JSON fields rather than dropping
|
|
17
|
+
them (``extra="allow"``), accepts either the Python field name or its wire alias
|
|
18
|
+
on input, and serializes declared fields back under the wire alias. Attribute
|
|
19
|
+
docstrings on fields are lifted into their schema ``description``.
|
|
20
|
+
|
|
21
|
+
A field the server adds after this SDK was generated survives on the instance --
|
|
22
|
+
readable via ``model_extra``, or by attribute access unless the key collides with
|
|
23
|
+
the model API -- and is re-emitted under the key it arrived with, at every nesting
|
|
24
|
+
level. Preservation is deliberately symmetric: an unknown key a caller supplies is
|
|
25
|
+
likewise sent, so a model received from the server can be echoed back without
|
|
26
|
+
silently discarding server-side state. See
|
|
27
|
+
``docs/designs/unknown-field-preservation.md``.
|
|
28
|
+
|
|
29
|
+
``frozen`` blocks rebinding an attribute; it does not deep-freeze values. The
|
|
30
|
+
contents of a ``list``/``dict`` field and the ``model_extra`` mapping remain
|
|
31
|
+
mutable in place.
|
|
32
|
+
|
|
33
|
+
:meth:`to_dict` and :meth:`to_json` are the ergonomic front door to serialization;
|
|
34
|
+
``model_dump``/``model_dump_json`` remain available for the full option set. See
|
|
35
|
+
``docs/designs/model-serialization-helpers.md``."""
|
|
36
|
+
|
|
37
|
+
model_config = ConfigDict(
|
|
38
|
+
frozen=True,
|
|
39
|
+
extra="allow",
|
|
40
|
+
validate_by_name=True,
|
|
41
|
+
validate_by_alias=True,
|
|
42
|
+
serialize_by_alias=True,
|
|
43
|
+
use_attribute_docstrings=True,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
def to_dict(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
mode: Literal["json", "python"] = "json",
|
|
50
|
+
by_alias: bool = True,
|
|
51
|
+
exclude_unset: bool = False,
|
|
52
|
+
exclude_defaults: bool = False,
|
|
53
|
+
exclude_none: bool = False,
|
|
54
|
+
) -> dict[str, Any]:
|
|
55
|
+
"""Return this model as a dictionary in the shape the API exchanges.
|
|
56
|
+
|
|
57
|
+
Defaults produce JSON-safe values under the wire alias of every field, so the
|
|
58
|
+
result is ``json.dumps``-able, equals ``json.loads(self.to_json())``, and
|
|
59
|
+
validates back into an equal model -- including through a discriminated-union
|
|
60
|
+
alias. Unknown fields the server sent are included (see
|
|
61
|
+
``docs/designs/unknown-field-preservation.md``).
|
|
62
|
+
|
|
63
|
+
This is a convenience wrapper over ``model_dump``; reach for that directly when
|
|
64
|
+
you need the options this deliberately does not surface (``include``/``exclude``,
|
|
65
|
+
``context``, ``round_trip``, ``warnings``, ``serialize_as_any``, ``fallback``).
|
|
66
|
+
|
|
67
|
+
A field typed ``OptionalNullable[...]`` (e.g. an omit-vs-null field) that the caller
|
|
68
|
+
never touched is always omitted here, even though plain ``model_dump`` shows it as
|
|
69
|
+
``null`` -- this is the one documented exception to "exactly the underlying dump".
|
|
70
|
+
See ``docs/designs/optional-nullable-fields.md``.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
mode: ``"json"`` coerces values to JSON-safe types (``datetime`` becomes a
|
|
74
|
+
string); ``"python"`` keeps the Python objects.
|
|
75
|
+
by_alias: Emit each field under its wire alias rather than its Python name.
|
|
76
|
+
The base config already serializes by alias, so this restates the
|
|
77
|
+
contract; pass ``False`` for Python-name keys.
|
|
78
|
+
exclude_unset: Omit fields that were never explicitly set, narrowing the
|
|
79
|
+
output to what the server actually sent. **Opt-in, not the default**:
|
|
80
|
+
a variant that pins its tag in its own schema carries it as a
|
|
81
|
+
*defaulted* field, so on a locally constructed model this drops the tag
|
|
82
|
+
and the result no longer validates against the union alias.
|
|
83
|
+
exclude_defaults: Omit fields still holding their default value.
|
|
84
|
+
exclude_none: Omit fields whose value is ``None``. The round-trip-safe way
|
|
85
|
+
to suppress nulls -- it keeps defaulted discriminators.
|
|
86
|
+
|
|
87
|
+
Returns:
|
|
88
|
+
The model's fields keyed by wire alias, plus any unknown fields it preserved."""
|
|
89
|
+
return strip_unset(
|
|
90
|
+
self,
|
|
91
|
+
self.model_dump(
|
|
92
|
+
mode=mode,
|
|
93
|
+
by_alias=by_alias,
|
|
94
|
+
exclude_unset=exclude_unset,
|
|
95
|
+
exclude_defaults=exclude_defaults,
|
|
96
|
+
exclude_none=exclude_none,
|
|
97
|
+
),
|
|
98
|
+
)
|
|
99
|
+
|
|
100
|
+
def to_json(
|
|
101
|
+
self,
|
|
102
|
+
*,
|
|
103
|
+
indent: int | None = 2,
|
|
104
|
+
by_alias: bool = True,
|
|
105
|
+
exclude_unset: bool = False,
|
|
106
|
+
exclude_defaults: bool = False,
|
|
107
|
+
exclude_none: bool = False,
|
|
108
|
+
) -> str:
|
|
109
|
+
"""Return this model as a JSON string in the shape the API exchanges.
|
|
110
|
+
|
|
111
|
+
Indented by default for logs and debugging, and otherwise identical in content
|
|
112
|
+
to :meth:`to_dict` -- ``json.loads(self.to_json()) == self.to_dict()``.
|
|
113
|
+
|
|
114
|
+
This is a convenience wrapper over ``model_dump``/``model_dump_json``; reach for
|
|
115
|
+
those directly when you need the options this deliberately does not surface
|
|
116
|
+
(``include``/``exclude``, ``context``, ``round_trip``, ``warnings``,
|
|
117
|
+
``ensure_ascii``, ``serialize_as_any``, ``fallback``).
|
|
118
|
+
|
|
119
|
+
Built on :meth:`to_dict` (rather than delegating to ``model_dump_json`` directly)
|
|
120
|
+
so the pair can never drift apart -- including the ``OptionalNullable`` omission
|
|
121
|
+
documented there.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
indent: Spaces of indentation; ``None`` emits compact JSON.
|
|
125
|
+
by_alias: Emit each field under its wire alias rather than its Python name.
|
|
126
|
+
exclude_unset: Omit fields that were never explicitly set. Carries the same
|
|
127
|
+
discriminator caveat as :meth:`to_dict`.
|
|
128
|
+
exclude_defaults: Omit fields still holding their default value.
|
|
129
|
+
exclude_none: Omit fields whose value is ``None``.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
The JSON text the API exchanges, indented unless ``indent`` is ``None``."""
|
|
133
|
+
return json.dumps(
|
|
134
|
+
self.to_dict(
|
|
135
|
+
mode="json",
|
|
136
|
+
by_alias=by_alias,
|
|
137
|
+
exclude_unset=exclude_unset,
|
|
138
|
+
exclude_defaults=exclude_defaults,
|
|
139
|
+
exclude_none=exclude_none,
|
|
140
|
+
),
|
|
141
|
+
indent=indent,
|
|
142
|
+
ensure_ascii=False,
|
|
143
|
+
)
|