cliV1 0.0.8__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.8.dist-info/METADATA +168 -0
- cliv1-0.0.8.dist-info/RECORD +87 -0
- cliv1-0.0.8.dist-info/WHEEL +4 -0
- cliv1-0.0.8.dist-info/licenses/LICENSE +21 -0
- swagger_petstore_open_api_3_0/__init__.py +15 -0
- swagger_petstore_open_api_3_0/apis/__init__.py +5 -0
- swagger_petstore_open_api_3_0/apis/pet_api.py +883 -0
- swagger_petstore_open_api_3_0/apis/store.py +413 -0
- swagger_petstore_open_api_3_0/apis/user_api.py +794 -0
- swagger_petstore_open_api_3_0/async_client.py +79 -0
- swagger_petstore_open_api_3_0/auth.py +17 -0
- swagger_petstore_open_api_3_0/base_client.py +18 -0
- swagger_petstore_open_api_3_0/client.py +77 -0
- swagger_petstore_open_api_3_0/core/__init__.py +358 -0
- swagger_petstore_open_api_3_0/core/_internal/__init__.py +9 -0
- swagger_petstore_open_api_3_0/core/_internal/content_disposition.py +154 -0
- swagger_petstore_open_api_3_0/core/_internal/flattening.py +139 -0
- swagger_petstore_open_api_3_0/core/_internal/headers.py +144 -0
- swagger_petstore_open_api_3_0/core/_internal/sse_parser.py +124 -0
- swagger_petstore_open_api_3_0/core/_internal/subscripts.py +50 -0
- swagger_petstore_open_api_3_0/core/_internal/urls.py +186 -0
- swagger_petstore_open_api_3_0/core/_internal/wire.py +198 -0
- swagger_petstore_open_api_3_0/core/adapters.py +177 -0
- swagger_petstore_open_api_3_0/core/auth/__init__.py +70 -0
- swagger_petstore_open_api_3_0/core/auth/composition.py +175 -0
- swagger_petstore_open_api_3_0/core/auth/models.py +216 -0
- swagger_petstore_open_api_3_0/core/auth/protocols.py +103 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/__init__.py +94 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/api_key_auth.py +53 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/basic_auth.py +73 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/bearer_auth.py +21 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/oauth2_authorization_code.py +393 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/oauth2_client_credentials.py +127 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/oauth2_password.py +142 -0
- swagger_petstore_open_api_3_0/core/auth/schemes/oauth2_schemes.py +300 -0
- swagger_petstore_open_api_3_0/core/base_raw_response.py +49 -0
- swagger_petstore_open_api_3_0/core/bodies.py +550 -0
- swagger_petstore_open_api_3_0/core/converters/__init__.py +68 -0
- swagger_petstore_open_api_3_0/core/converters/date_time.py +237 -0
- swagger_petstore_open_api_3_0/core/converters/encoded_bytes.py +273 -0
- swagger_petstore_open_api_3_0/core/converters/open_enum.py +35 -0
- swagger_petstore_open_api_3_0/core/decoding.py +498 -0
- swagger_petstore_open_api_3_0/core/discriminators.py +129 -0
- swagger_petstore_open_api_3_0/core/event_streams.py +225 -0
- swagger_petstore_open_api_3_0/core/exceptions.py +38 -0
- swagger_petstore_open_api_3_0/core/file_responses.py +335 -0
- swagger_petstore_open_api_3_0/core/files.py +173 -0
- swagger_petstore_open_api_3_0/core/httpx_transport.py +748 -0
- swagger_petstore_open_api_3_0/core/models.py +143 -0
- swagger_petstore_open_api_3_0/core/optionality.py +130 -0
- swagger_petstore_open_api_3_0/core/params.py +157 -0
- swagger_petstore_open_api_3_0/core/raw_client.py +338 -0
- swagger_petstore_open_api_3_0/core/request_options.py +94 -0
- swagger_petstore_open_api_3_0/core/results.py +123 -0
- swagger_petstore_open_api_3_0/core/runtime_env.py +43 -0
- swagger_petstore_open_api_3_0/core/servers.py +33 -0
- swagger_petstore_open_api_3_0/core/transport.py +191 -0
- swagger_petstore_open_api_3_0/errors/__init__.py +48 -0
- swagger_petstore_open_api_3_0/errors/add_pet_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/delete_order_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/delete_pet_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/delete_user_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/find_pets_by_status_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/find_pets_by_tags_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/get_order_by_id_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/get_pet_by_id_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/get_user_by_name_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/login_user_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/place_order_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/update_pet_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/update_pet_with_form_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/update_user_error.py +21 -0
- swagger_petstore_open_api_3_0/errors/upload_file_error.py +21 -0
- swagger_petstore_open_api_3_0/models/__init__.py +23 -0
- swagger_petstore_open_api_3_0/models/api_response.py +18 -0
- swagger_petstore_open_api_3_0/models/category.py +15 -0
- swagger_petstore_open_api_3_0/models/enums/__init__.py +4 -0
- swagger_petstore_open_api_3_0/models/enums/order_status.py +17 -0
- swagger_petstore_open_api_3_0/models/enums/pet_status.py +17 -0
- swagger_petstore_open_api_3_0/models/order.py +27 -0
- swagger_petstore_open_api_3_0/models/pet.py +28 -0
- swagger_petstore_open_api_3_0/models/tag_model.py +15 -0
- swagger_petstore_open_api_3_0/models/user.py +29 -0
- swagger_petstore_open_api_3_0/py.typed +0 -0
- swagger_petstore_open_api_3_0/server/__init__.py +21 -0
- swagger_petstore_open_api_3_0/server/server.py +17 -0
- swagger_petstore_open_api_3_0/server/server_config.py +52 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""``Content-Disposition``, both directions: the filename a response suggests, and the one a
|
|
2
|
+
request carries.
|
|
3
|
+
|
|
4
|
+
One header, one module. The RFC 6266/8187 grammar is the shared asset, so reading it and writing
|
|
5
|
+
it stay together rather than putting one table in two files. ``headers.py`` remains the
|
|
6
|
+
*parameter*-layer module, resolving the outbound layers a caller and the spec contribute; what
|
|
7
|
+
this module renders is a value a body carries, exactly as its media type is.
|
|
8
|
+
|
|
9
|
+
The standard library gets the reading wrong twice, both reproduced during planning
|
|
10
|
+
(docs/plans/file-handling.md, verified facts). ``Message.get_filename()`` returns the plain
|
|
11
|
+
``filename`` when both forms are present -- RFC 6266 §4.3 requires the extended ``filename*`` to
|
|
12
|
+
win -- and the RFC 2231 machinery percent-decodes the extended value as latin-1 whatever charset
|
|
13
|
+
it declares, so the text must be re-encoded through latin-1 and decoded with the declared charset
|
|
14
|
+
to come back intact.
|
|
15
|
+
|
|
16
|
+
The two readers treat their input as attacker-controlled. The parser is **total**: any malformed
|
|
17
|
+
header yields ``None`` rather than raising, because it runs while a streamed connection is held
|
|
18
|
+
and a raise there would leak it. The sanitiser is the paranoid half: a server-suggested name is
|
|
19
|
+
reduced to a basename, stripped of everything Windows refuses, and refused outright when what
|
|
20
|
+
survives is empty, a reserved device name, or too long to create.
|
|
21
|
+
|
|
22
|
+
The writer needs no such paranoia, and by construction rather than by trust: every byte outside
|
|
23
|
+
RFC 8187's ``attr-char`` is percent-encoded, so no name can splice a header and there is nothing
|
|
24
|
+
to refuse."""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import re
|
|
29
|
+
from email.message import Message
|
|
30
|
+
from typing import Final
|
|
31
|
+
from urllib.parse import quote
|
|
32
|
+
|
|
33
|
+
_SEPARATORS: Final = re.compile(r"[\\/]")
|
|
34
|
+
_WINDOWS_ILLEGAL: Final = frozenset('<>:"|?*')
|
|
35
|
+
_WINDOWS_RESERVED: Final = frozenset(
|
|
36
|
+
{"CON", "PRN", "AUX", "NUL"} | {f"COM{d}" for d in "123456789"} | {f"LPT{d}" for d in "123456789"}
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def content_disposition_filename(header: str | None) -> str | None:
|
|
41
|
+
"""The filename a ``Content-Disposition`` suggests, or ``None``.
|
|
42
|
+
|
|
43
|
+
Prefers the RFC 8187 extended ``filename*`` (RFC 6266 §4.3) and recovers its text from the
|
|
44
|
+
standard library's latin-1 percent-decode; RFC 8187 §3.2.1 permits only UTF-8, so any other
|
|
45
|
+
declared charset falls back to the plain ``filename`` rather than guessing. Continuation
|
|
46
|
+
segments (``filename*0*``, ``filename*1*``) are collapsed by the same machinery.
|
|
47
|
+
|
|
48
|
+
Total by design: a malformed header returns ``None``, never raises -- this runs between a
|
|
49
|
+
streamed response's head arriving and its owner returning, where a raise would leak the
|
|
50
|
+
connection. The result is *not* trusted as a path; see :func:`safe_filename`.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
header: The header's value, or ``None`` when the response carried none.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
The suggested filename, or ``None`` when there is no usable one."""
|
|
57
|
+
if header is None:
|
|
58
|
+
return None
|
|
59
|
+
try:
|
|
60
|
+
message = Message()
|
|
61
|
+
message["content-disposition"] = header
|
|
62
|
+
params = message.get_params(header="content-disposition")
|
|
63
|
+
if params is None:
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
plain: str | None = None
|
|
67
|
+
extended: str | None = None
|
|
68
|
+
for name, raw in params:
|
|
69
|
+
if name != "filename":
|
|
70
|
+
continue
|
|
71
|
+
# The parser hands back a plain parameter as ``str`` and an RFC 2231 extended one as a
|
|
72
|
+
# ``(charset, language, text)`` tuple. A duplicated plain form keeps its first
|
|
73
|
+
# occurrence; duplicated extended forms never reach here -- the stdlib merges them as
|
|
74
|
+
# continuations before ``get_params`` returns.
|
|
75
|
+
value: object = raw
|
|
76
|
+
if isinstance(value, str) and plain is None:
|
|
77
|
+
plain = value
|
|
78
|
+
elif isinstance(value, tuple) and extended is None:
|
|
79
|
+
extended = _decode_extended(value)
|
|
80
|
+
return (extended or plain) or None
|
|
81
|
+
except Exception:
|
|
82
|
+
# Total by design -- the docstring says why. Bad wire data degrades, never raises.
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _decode_extended(value: tuple[object, ...]) -> str | None:
|
|
87
|
+
# (charset, language, text) -- text percent-decoded as latin-1 by the parser regardless of the
|
|
88
|
+
# declared charset, which is the mojibake the round trip below undoes.
|
|
89
|
+
if len(value) != 3:
|
|
90
|
+
return None
|
|
91
|
+
charset, _language, text = value
|
|
92
|
+
if not isinstance(charset, str) or not isinstance(text, str):
|
|
93
|
+
return None
|
|
94
|
+
if charset.lower() not in ("utf-8", "utf8"):
|
|
95
|
+
return None
|
|
96
|
+
try:
|
|
97
|
+
return text.encode("latin-1").decode("utf-8")
|
|
98
|
+
except (UnicodeEncodeError, UnicodeDecodeError):
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def render_content_disposition(filename: str) -> str:
|
|
103
|
+
"""The ``Content-Disposition`` value naming a raw request body.
|
|
104
|
+
|
|
105
|
+
RFC 6266's dual form: a quoted ``filename`` holding what a quoted-string can, and, whenever
|
|
106
|
+
that reduction lost something, the RFC 8187 ``filename*`` that did not. A conformant receiver
|
|
107
|
+
prefers the extended parameter (§4.3) and recovers the name exactly; one reading only the plain
|
|
108
|
+
parameter still gets a usable name.
|
|
109
|
+
|
|
110
|
+
Percent-encoding is what makes this safe rather than a character check: every byte outside
|
|
111
|
+
RFC 8187's ``attr-char`` becomes ``%XX``, so a CR, an LF or a ``"`` cannot reach the header
|
|
112
|
+
however the caller spelled the name. There is nothing to refuse.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
filename: The name the caller gave the file, as ``NamedFile.filename`` holds it.
|
|
116
|
+
|
|
117
|
+
Returns:
|
|
118
|
+
The header value -- ``attachment``, and whichever parameters can carry the name."""
|
|
119
|
+
plain = "".join(c for c in filename if c.isascii() and c.isprintable() and c not in '"\\')
|
|
120
|
+
parameters = [f'filename="{plain}"'] if plain else []
|
|
121
|
+
if plain != filename:
|
|
122
|
+
parameters.append(f"filename*=UTF-8''{quote(filename, safe='', encoding='utf-8')}")
|
|
123
|
+
return "; ".join(["attachment", *parameters])
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def safe_filename(filename: str) -> str:
|
|
127
|
+
"""Reduce a server-supplied filename to a name safe to create in a directory.
|
|
128
|
+
|
|
129
|
+
Basename first, under both separator flavours -- ``Content-Disposition`` is
|
|
130
|
+
attacker-controlled and ``filename="../../etc/passwd"`` reaches the parser unchanged -- then
|
|
131
|
+
strip what Windows refuses: ``<>:"|?*``, unprintable characters, and trailing dots or spaces.
|
|
132
|
+
Dropping the colon also neutralises an NTFS alternate-data-stream name. What remains is
|
|
133
|
+
refused rather than mangled when it cannot safely name a file: a reserved device-name stem
|
|
134
|
+
(``CON``, ``NUL``, ``COM1``... -- Windows 10 resolves even ``CON.pdf`` to the console device,
|
|
135
|
+
and a bare ``NUL`` open "succeeds" while the bytes go to the null device) or a name past 255
|
|
136
|
+
characters, the per-component ceiling on NTFS and most POSIX filesystems.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
filename: The server's suggestion, as :func:`content_disposition_filename` returned it.
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
The reduced name, never empty.
|
|
143
|
+
|
|
144
|
+
Raises:
|
|
145
|
+
ValueError: If nothing safe remains -- an empty reduction, ``.`` or ``..``, a reserved
|
|
146
|
+
device name, or a name too long to create."""
|
|
147
|
+
basename = _SEPARATORS.split(filename)[-1]
|
|
148
|
+
cleaned = "".join(ch for ch in basename if ch not in _WINDOWS_ILLEGAL and ch.isprintable())
|
|
149
|
+
cleaned = cleaned.rstrip(". ")
|
|
150
|
+
# A reserved stem is matched as Windows does: up to the first dot, trailing spaces ignored.
|
|
151
|
+
stem = cleaned.split(".", 1)[0].rstrip(" ")
|
|
152
|
+
if not cleaned or stem.upper() in _WINDOWS_RESERVED or len(cleaned) > 255:
|
|
153
|
+
raise ValueError(f"{filename!r} reduces to nothing safe to create -- pass an explicit file path instead")
|
|
154
|
+
return cleaned
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Flattening parameters into wire pairs.
|
|
2
|
+
|
|
3
|
+
A parameter's value can be a scalar, a model, a mapping, or a collection of any of those, and how a
|
|
4
|
+
*collection* explodes into keys depends on the parameter's :class:`SerializationFormat`. This module
|
|
5
|
+
does that flattening once, for both destinations that need it: query strings
|
|
6
|
+
(``urls.encode_query`` encodes the pairs) and form bodies
|
|
7
|
+
(:func:`to_fields` collects them into a mapping).
|
|
8
|
+
|
|
9
|
+
Each parameter arrives carrying the adapter for its declared type, so the wire *format* is decided
|
|
10
|
+
there rather than here: :func:`wire_value` dumps through that adapter -- the same step the path and
|
|
11
|
+
header renderers use -- and this module is then concerned only with how the result explodes into
|
|
12
|
+
keys.
|
|
13
|
+
|
|
14
|
+
Stateless module functions rather than a class: there is nothing to configure, so there was nothing
|
|
15
|
+
for an instance to hold."""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import Mapping, Sequence
|
|
20
|
+
from typing import Any, Final, TypeAlias
|
|
21
|
+
|
|
22
|
+
from ..params import Param, SerializationFormat
|
|
23
|
+
from .wire import is_collection, to_text, wire_value
|
|
24
|
+
|
|
25
|
+
Pairs: TypeAlias = list[tuple[str, str]]
|
|
26
|
+
|
|
27
|
+
_SEPARATORS: Final = {
|
|
28
|
+
SerializationFormat.CSV: ",",
|
|
29
|
+
SerializationFormat.TSV: "\t",
|
|
30
|
+
SerializationFormat.PSV: "|",
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def flatten(params: Sequence[Param[Any]] | None) -> Pairs:
|
|
35
|
+
"""Flatten every parameter into ``(key, value)`` pairs, in order.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
params: The parameters to flatten; ``None`` is treated as none at all.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
``(key, text)`` pairs, a repeated key appearing once per value.
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
ValueError: If a parameter's value does not match its declared type."""
|
|
45
|
+
pairs: Pairs = []
|
|
46
|
+
for param in params or ():
|
|
47
|
+
pairs.extend(_flatten_value(key=param.key, value=wire_value(param), fmt=param.serialization_format))
|
|
48
|
+
return pairs
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def to_fields(params: Sequence[Param[Any]]) -> dict[str, str | list[str]]:
|
|
52
|
+
"""Flatten ``params`` into a mapping, collecting repeated keys into a list.
|
|
53
|
+
|
|
54
|
+
This is the shape a form body takes: unlike a query string, which can repeat a key, a form
|
|
55
|
+
mapping needs the repeats gathered under one entry.
|
|
56
|
+
|
|
57
|
+
Every value is text or a list of text, never anything richer: :func:`flatten` has already
|
|
58
|
+
reduced each parameter to ``(key, value)`` pairs of ``str``, so the only structure left to
|
|
59
|
+
add here is the list a repeated key collects into.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
params: The parameters to flatten into fields.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Each key mapped to its text, or to the list of texts it collected.
|
|
66
|
+
|
|
67
|
+
Raises:
|
|
68
|
+
ValueError: If a parameter's value does not match its declared type."""
|
|
69
|
+
fields: dict[str, str | list[str]] = {}
|
|
70
|
+
|
|
71
|
+
for key, value in flatten(params):
|
|
72
|
+
if key not in fields:
|
|
73
|
+
fields[key] = value
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
existing = fields[key]
|
|
77
|
+
if isinstance(existing, list):
|
|
78
|
+
existing.append(value)
|
|
79
|
+
else:
|
|
80
|
+
fields[key] = [existing, value]
|
|
81
|
+
|
|
82
|
+
return fields
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _flatten_value(*, key: str, value: object, fmt: SerializationFormat) -> Pairs:
|
|
86
|
+
if value is None:
|
|
87
|
+
return []
|
|
88
|
+
|
|
89
|
+
if isinstance(value, Mapping):
|
|
90
|
+
return [
|
|
91
|
+
pair
|
|
92
|
+
for child_key, child_value in value.items()
|
|
93
|
+
for pair in _flatten_value(key=f"{key}[{child_key}]", value=child_value, fmt=fmt)
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
if is_collection(value):
|
|
97
|
+
values = [v for v in value if v is not None]
|
|
98
|
+
if not values:
|
|
99
|
+
return []
|
|
100
|
+
|
|
101
|
+
joined = _try_join(values, fmt)
|
|
102
|
+
if joined is not None:
|
|
103
|
+
return [(key, joined)]
|
|
104
|
+
|
|
105
|
+
if fmt is SerializationFormat.INDEXED:
|
|
106
|
+
return [
|
|
107
|
+
pair
|
|
108
|
+
for index, item in enumerate(values)
|
|
109
|
+
for pair in _flatten_value(key=f"{key}[{index}]", value=item, fmt=fmt)
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
if fmt is SerializationFormat.UNINDEXED:
|
|
113
|
+
return [pair for item in values for pair in _flatten_value(key=f"{key}[]", value=item, fmt=fmt)]
|
|
114
|
+
|
|
115
|
+
return [pair for item in values for pair in _flatten_value(key=key, value=item, fmt=fmt)]
|
|
116
|
+
|
|
117
|
+
return [(key, to_text(value))]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _try_join(values: Sequence[object], fmt: SerializationFormat) -> str | None:
|
|
121
|
+
"""Join ``values`` with ``fmt``'s separator, or ``None`` if ``fmt`` does not join.
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
values: The already-dumped members to join.
|
|
125
|
+
fmt: The parameter's serialization format.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
The joined text, or ``None`` when ``fmt`` does not join or a member is itself
|
|
129
|
+
structured -- both cases leave the caller to explode the members instead."""
|
|
130
|
+
separator = _SEPARATORS.get(fmt)
|
|
131
|
+
if separator is None:
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
# A nested collection or mapping cannot be represented inside a joined scalar, so the
|
|
135
|
+
# caller falls back to exploding the members under the repeated bare key.
|
|
136
|
+
if any(isinstance(v, Mapping) or is_collection(v) for v in values):
|
|
137
|
+
return None
|
|
138
|
+
|
|
139
|
+
return separator.join(to_text(v) for v in values)
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Rendering, normalizing and resolving request headers.
|
|
2
|
+
|
|
3
|
+
One place decides how header names reach the wire, so the choice is made once rather than at each
|
|
4
|
+
endpoint. Names are lowercased: HTTP/1.1 treats them case-insensitively and HTTP/2 requires
|
|
5
|
+
lowercase, so lowercasing is correct for both and makes the produced mapping comparable.
|
|
6
|
+
|
|
7
|
+
Lowercasing is also what makes :func:`resolve_headers` case-insensitive by construction: once every
|
|
8
|
+
layer is normalized, an ordinary dict merge cannot let a differently-cased duplicate through.
|
|
9
|
+
|
|
10
|
+
The layers are not all the same kind, and that asymmetry is deliberate. The API's and the endpoint's
|
|
11
|
+
headers are :class:`Param`s -- spec-derived, so each names the type it was declared as and is
|
|
12
|
+
rendered through it by :func:`render_headers`. A caller's ``extra_headers`` arrives as a plain
|
|
13
|
+
mapping of strings, because a caller-supplied value carries no declared type for an adapter to act
|
|
14
|
+
on.
|
|
15
|
+
|
|
16
|
+
``Cookie`` is the one field this module knows by name. Every other field takes the later layer and
|
|
17
|
+
discards the earlier; RFC 6265 §5.4 permits at most one ``Cookie``, so its contributors fold into a
|
|
18
|
+
single jar instead."""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections.abc import Mapping, Sequence
|
|
23
|
+
from typing import Any, Final
|
|
24
|
+
|
|
25
|
+
from ..params import Param
|
|
26
|
+
from .wire import text_values
|
|
27
|
+
|
|
28
|
+
COOKIE_HEADER: Final = "cookie"
|
|
29
|
+
"""The one field name this module knows, lowercased as every rendered name is.
|
|
30
|
+
|
|
31
|
+
RFC 6265 §5.4 permits a user agent at most one ``Cookie`` field, so two contributors of a cookie are
|
|
32
|
+
two entries in one jar rather than two claims on one slot -- the only field where a later layer folds
|
|
33
|
+
into an earlier one instead of replacing it."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def fold_cookie_jars(*jars: str | None) -> str:
|
|
37
|
+
"""Join what each layer contributed to the cookie jar, skipping those that contributed nothing.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
jars: Each layer's ``Cookie`` field value, in precedence order.
|
|
41
|
+
|
|
42
|
+
Returns:
|
|
43
|
+
One RFC 6265 §5.4 field value, empty when no layer contributed a cookie."""
|
|
44
|
+
return "; ".join(jar for jar in jars if jar)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def render_headers(headers: Sequence[Param[Any]] | None) -> dict[str, str]:
|
|
48
|
+
"""Render each header parameter into a lowercased name and one field value.
|
|
49
|
+
|
|
50
|
+
Rendering and lowercasing in one pass is what keeps :func:`resolve_headers` an ordinary dict
|
|
51
|
+
merge; within one layer the later of two same-named headers wins, which falls out of the dict.
|
|
52
|
+
``Cookie`` is the exception: its contributors fold into one jar, so an endpoint's own cookie and
|
|
53
|
+
the credential a scheme appended after it survive each other.
|
|
54
|
+
|
|
55
|
+
A header contributing nothing -- a ``None`` value, or a collection with no members -- is omitted
|
|
56
|
+
rather than sent empty, so a lower layer's value survives. Several values fold into one
|
|
57
|
+
comma-separated field value: RFC 9110's own rule for a repeated header, and OpenAPI's default
|
|
58
|
+
``style: simple`` for a header array, with no space after the comma. Folding here is also what
|
|
59
|
+
keeps ``HttpRequest.headers`` a plain ``Mapping[str, str]``, so no transport has to learn that a
|
|
60
|
+
header can be multi-valued.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
headers: The header parameters; ``None`` is treated as none at all.
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
Lowercased header names mapped to one field value each; a header contributing nothing is
|
|
67
|
+
absent.
|
|
68
|
+
|
|
69
|
+
Raises:
|
|
70
|
+
ValueError: If a header's value does not match its declared type, or a flat collection
|
|
71
|
+
holds a ``None``."""
|
|
72
|
+
rendered: dict[str, str] = {}
|
|
73
|
+
for header in headers or ():
|
|
74
|
+
values = text_values(header)
|
|
75
|
+
if values:
|
|
76
|
+
name = header.key.lower()
|
|
77
|
+
field_value = ",".join(values)
|
|
78
|
+
if name == COOKIE_HEADER:
|
|
79
|
+
field_value = fold_cookie_jars(rendered.get(name), field_value)
|
|
80
|
+
rendered[name] = field_value
|
|
81
|
+
return rendered
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def normalize_headers(headers: Mapping[str, str] | None) -> dict[str, str]:
|
|
85
|
+
"""Return already-string headers as a plain dict with lowercased names.
|
|
86
|
+
|
|
87
|
+
One layer reaches :func:`resolve_headers` this way and only one: the caller's ``extra_headers``.
|
|
88
|
+
Every spec-derived layer arrives as a :class:`Param` and goes through :func:`render_headers`.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
headers: Already-string headers, typically a caller's ``extra_headers``.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
The same mapping with every name lowercased."""
|
|
95
|
+
return {key.lower(): value for key, value in (headers or {}).items()}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def resolve_headers(
|
|
99
|
+
global_headers: Sequence[Param[Any]],
|
|
100
|
+
local_headers: Sequence[Param[Any]] | None,
|
|
101
|
+
*,
|
|
102
|
+
extra_headers: Mapping[str, str] | None = None,
|
|
103
|
+
) -> dict[str, str]:
|
|
104
|
+
"""Return the headers for one call: the API's, then the endpoint's, then the caller's.
|
|
105
|
+
|
|
106
|
+
Every layer is normalized before merging, so a global ``X-Tenant`` is overridden by an
|
|
107
|
+
endpoint's ``x-tenant`` rather than reaching the wire alongside it -- and a caller's
|
|
108
|
+
``extra_headers`` wins over both. That it can override a header the endpoint set is the point of
|
|
109
|
+
passing it, not an oversight.
|
|
110
|
+
|
|
111
|
+
``Cookie`` is the one field a later layer does not replace. RFC 6265 §5.4 permits at most one of
|
|
112
|
+
them, so the layers' cookies are several entries in a single jar rather than competing claims on
|
|
113
|
+
one slot: a caller adding a cookie adds to the credential a scheme contributed instead of
|
|
114
|
+
silently discarding it.
|
|
115
|
+
|
|
116
|
+
``extra_headers`` is keyword-only. It no longer shares a type with the two layers below it, so
|
|
117
|
+
an inversion would now be caught -- but it stays keyword-only regardless: the two spec-derived
|
|
118
|
+
layers are still same-typed and positional, and a caller layer reading as a third peer would
|
|
119
|
+
misrepresent it as something the API prescribes.
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
global_headers: What the API prescribes for every call.
|
|
123
|
+
local_headers: What this one endpoint declares, plus whatever its scheme contributed; wins
|
|
124
|
+
over the API's.
|
|
125
|
+
extra_headers: What the caller passed for this one request; wins over both.
|
|
126
|
+
|
|
127
|
+
Returns:
|
|
128
|
+
The effective headers, names lowercased so the merge is case-insensitive.
|
|
129
|
+
|
|
130
|
+
Raises:
|
|
131
|
+
ValueError: If a header's value does not match its declared type."""
|
|
132
|
+
normalized_global_headers = render_headers(global_headers)
|
|
133
|
+
normalized_local_headers = render_headers(local_headers)
|
|
134
|
+
normalized_extra_headers = normalize_headers(extra_headers)
|
|
135
|
+
|
|
136
|
+
resolved = normalized_global_headers | normalized_local_headers | normalized_extra_headers
|
|
137
|
+
jar = fold_cookie_jars(
|
|
138
|
+
normalized_global_headers.get(COOKIE_HEADER),
|
|
139
|
+
normalized_local_headers.get(COOKIE_HEADER),
|
|
140
|
+
normalized_extra_headers.get(COOKIE_HEADER),
|
|
141
|
+
)
|
|
142
|
+
if jar:
|
|
143
|
+
resolved[COOKIE_HEADER] = jar
|
|
144
|
+
return resolved
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Parsing the ``text/event-stream`` grammar: bytes in, event data out.
|
|
2
|
+
|
|
3
|
+
The WHATWG algorithm ("Server-sent events", HTML Standard) written as a push parser, so one
|
|
4
|
+
implementation serves both flavours: a consumer hands over each chunk the transport delivers and takes
|
|
5
|
+
back the events those bytes completed, and nothing here touches the wire. Per the standard: one leading
|
|
6
|
+
UTF-8 BOM is dropped; a line ends at CRLF, LF or CR; a line starting with ``:`` is a comment; ``data``
|
|
7
|
+
accumulates and its values join with LF; a blank line dispatches, but only when a ``data`` line was seen,
|
|
8
|
+
otherwise it resets; ``event``, ``id`` and ``retry`` are recognised so they are ignored correctly, and so
|
|
9
|
+
is any other field -- only ``data`` reaches the consumer.
|
|
10
|
+
|
|
11
|
+
Lines are found with ``bytes.splitlines``, which knows exactly the standard's three terminators and no
|
|
12
|
+
others -- unlike ``str.splitlines``, which also cuts on ``\\x0b``, ``\\x0c``, ``\\x1c`` to ``\\x1e``,
|
|
13
|
+
``\\x85`` and U+2028/9 and would split a payload holding one of them. It is one C pass per chunk,
|
|
14
|
+
measured at a twelfth of a regex loop's cost, and only the new chunk is ever scanned: the unfinished tail
|
|
15
|
+
of the previous chunk is carried and joined onto the first line the next chunk completes, so parsing
|
|
16
|
+
stays linear in the bytes received however small the chunks.
|
|
17
|
+
|
|
18
|
+
Two things the standard leaves to the client. A CR at the very end of a chunk is a terminator on its own,
|
|
19
|
+
and the LF that may follow it in the next chunk is then skipped, so a CRLF split across two reads is one
|
|
20
|
+
terminator, never two -- and the event dispatches the moment its CR arrives rather than waiting for the
|
|
21
|
+
next chunk. And an event still pending at EOF is dropped, as the standard's processing model says and as
|
|
22
|
+
the .NET parser does: a truncated final event is not delivered as if it were whole.
|
|
23
|
+
|
|
24
|
+
Each completed line is decoded as UTF-8 on its own, strictly. That is exact rather than a shortcut: both
|
|
25
|
+
terminator bytes are ASCII, and in UTF-8 an ASCII byte is never part of a multi-byte sequence, so a line
|
|
26
|
+
boundary cannot fall inside a character and no incremental decoder is needed. A line that is not UTF-8
|
|
27
|
+
raises the stdlib's ``UnicodeDecodeError``, unwrapped: it already names the offending byte and position.
|
|
28
|
+
The one bound is on bytes held for an event that has not dispatched -- the carried tail plus the ``data``
|
|
29
|
+
already collected -- so a server that never sends a blank line is refused at 1 MiB with a ``ValueError``
|
|
30
|
+
instead of growing the buffer without limit."""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from typing import Final
|
|
35
|
+
|
|
36
|
+
MAX_EVENT_BYTES: Final = 1 << 20
|
|
37
|
+
"""Bytes held for one undispatched event before the stream is refused (1 MiB, Fern's bound)."""
|
|
38
|
+
|
|
39
|
+
_BOM: Final = b"\xef\xbb\xbf"
|
|
40
|
+
_LINE_ENDS: Final = (b"\n", b"\r")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SseParser:
|
|
44
|
+
"""The push parser: ``parse_chunk`` each chunk as the transport delivers it, take back the events it completed."""
|
|
45
|
+
|
|
46
|
+
__slots__ = ("_at_start", "_data", "_pending", "_skip_lf", "_tail")
|
|
47
|
+
|
|
48
|
+
def __init__(self) -> None:
|
|
49
|
+
self._tail = bytearray() # the unfinished line of the last chunk; never holds a terminator
|
|
50
|
+
self._at_start = True
|
|
51
|
+
self._skip_lf = False
|
|
52
|
+
self._data: list[str] = []
|
|
53
|
+
self._pending = 0
|
|
54
|
+
|
|
55
|
+
def parse_chunk(self, chunk: bytes) -> list[str]:
|
|
56
|
+
"""Consume ``chunk`` and return the data of every event it completed, in order.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
chunk: The next bytes off the wire, of any length including zero.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
Each dispatched event's ``data``, its lines already joined with LF.
|
|
63
|
+
|
|
64
|
+
Raises:
|
|
65
|
+
ValueError: If the pending event outgrows :data:`MAX_EVENT_BYTES`, or -- as a
|
|
66
|
+
``UnicodeDecodeError`` -- if a line is not valid UTF-8."""
|
|
67
|
+
if chunk and self._skip_lf:
|
|
68
|
+
# The previous chunk ended on a CR that was treated as a terminator; a leading LF now is the
|
|
69
|
+
# other half of that CRLF, not a blank line. An empty chunk decides nothing, so the flag waits.
|
|
70
|
+
self._skip_lf = False
|
|
71
|
+
if chunk[:1] == b"\n":
|
|
72
|
+
chunk = chunk[1:]
|
|
73
|
+
if self._at_start:
|
|
74
|
+
self._tail += chunk
|
|
75
|
+
if len(self._tail) < len(_BOM) and _BOM.startswith(self._tail):
|
|
76
|
+
return []
|
|
77
|
+
if self._tail.startswith(_BOM):
|
|
78
|
+
del self._tail[: len(_BOM)]
|
|
79
|
+
self._at_start = False
|
|
80
|
+
chunk = bytes(self._tail)
|
|
81
|
+
self._tail.clear()
|
|
82
|
+
|
|
83
|
+
# bytes.splitlines knows exactly SSE's three terminators; keepends tells the last piece apart --
|
|
84
|
+
# terminated, or an unfinished tail to carry into the next chunk.
|
|
85
|
+
pieces = chunk.splitlines(keepends=True)
|
|
86
|
+
unfinished = pieces.pop() if pieces and not pieces[-1].endswith(_LINE_ENDS) else b""
|
|
87
|
+
if pieces:
|
|
88
|
+
pieces[0] = bytes(self._tail) + pieces[0]
|
|
89
|
+
self._tail.clear()
|
|
90
|
+
self._skip_lf = not unfinished and pieces[-1].endswith(b"\r")
|
|
91
|
+
self._tail += unfinished
|
|
92
|
+
|
|
93
|
+
events: list[str] = []
|
|
94
|
+
for piece in pieces:
|
|
95
|
+
data = self._line(piece)
|
|
96
|
+
if data is not None:
|
|
97
|
+
events.append(data)
|
|
98
|
+
if len(self._tail) + self._pending > MAX_EVENT_BYTES:
|
|
99
|
+
raise ValueError(
|
|
100
|
+
f"an event exceeded {MAX_EVENT_BYTES} bytes without dispatching -- refusing to buffer further"
|
|
101
|
+
)
|
|
102
|
+
return events
|
|
103
|
+
|
|
104
|
+
def _line(self, raw: bytes) -> str | None:
|
|
105
|
+
# ``raw`` is one terminated line, so the strip removes exactly its CRLF, LF or CR. Strict decode:
|
|
106
|
+
# bad bytes raise UnicodeDecodeError straight out of parse_chunk.
|
|
107
|
+
line = raw.rstrip(b"\r\n").decode("utf-8")
|
|
108
|
+
if not line:
|
|
109
|
+
return self._dispatch()
|
|
110
|
+
if line.startswith(":"):
|
|
111
|
+
return None
|
|
112
|
+
name, colon, value = line.partition(":")
|
|
113
|
+
if name == "data":
|
|
114
|
+
self._data.append(value[1:] if colon and value.startswith(" ") else value)
|
|
115
|
+
self._pending += len(raw)
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
def _dispatch(self) -> str | None:
|
|
119
|
+
if not self._data:
|
|
120
|
+
return None
|
|
121
|
+
data = "\n".join(self._data)
|
|
122
|
+
self._data = []
|
|
123
|
+
self._pending = 0
|
|
124
|
+
return data
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""What every subscripted factory shares: refusing to be called.
|
|
2
|
+
|
|
3
|
+
The fence itself is static and lives in each factory's *absence* of a ``__call__``; this module
|
|
4
|
+
carries only the message a caller sees when they trip it at runtime."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class SubscriptOnly:
|
|
12
|
+
"""A factory whose declared type is structurally mandatory.
|
|
13
|
+
|
|
14
|
+
Fourteen factories across ``params``, ``bodies`` and ``decoding`` name their type in a subscript
|
|
15
|
+
(ADR-0013). Thirteen of the fourteen declare no ``__call__`` a type checker can see, so omitting
|
|
16
|
+
the subscript is an ``[operator]`` error at the call site -- that is the fence, and it is entirely
|
|
17
|
+
static; ``decode_text`` is the exception, and the last paragraph says why. What this
|
|
18
|
+
class carries is the *courtesy*: a runtime ``__call__``, invisible to the checker, turning
|
|
19
|
+
Python's bare "object is not callable" into a message that names the right spelling.
|
|
20
|
+
|
|
21
|
+
Subclasses supply the two facts the message is built from, as class attributes or as fields of
|
|
22
|
+
their own. What a base may **not** share is a ``__getitem__``'s *return shape*: it depends on the
|
|
23
|
+
subscripted type, and Python has no higher-kinded types, so no base can parametrise over it --
|
|
24
|
+
the precision ADR-0013 exists for, the reason ``json_decoder[int]`` is rejected where
|
|
25
|
+
``ApiResult[str, ...]`` is declared. Two factories over the *same* shape may share a class and
|
|
26
|
+
tell themselves apart by a field, because what they then differ in -- a body's format, say, or
|
|
27
|
+
which adapter entry point reads an event's ``data`` -- carries no type at all. Only a shape
|
|
28
|
+
does.
|
|
29
|
+
|
|
30
|
+
``decode_text`` is the one member of the family that does **not** inherit this: its bare call is
|
|
31
|
+
the ``str`` default, so its ``__call__`` is real, typed, and meant to be reached."""
|
|
32
|
+
|
|
33
|
+
# Empty rather than naming the two attributes below: a subclass supplies them either as class
|
|
34
|
+
# attributes or as its own dataclass fields, and claiming them here would collide with the
|
|
35
|
+
# latter. Without this line the ``slots=True`` the dataclass subclasses declare buys nothing --
|
|
36
|
+
# a slotted class over an unslotted base still gets a ``__dict__`` per instance.
|
|
37
|
+
__slots__ = ()
|
|
38
|
+
|
|
39
|
+
factory_name: str
|
|
40
|
+
"""How the caller spells the factory -- ``json_decoder``, ``param``."""
|
|
41
|
+
|
|
42
|
+
spelling: str
|
|
43
|
+
"""The correct form, with an example: ``param[T](key, value), e.g. param[bool]("array", True)``."""
|
|
44
|
+
|
|
45
|
+
if not TYPE_CHECKING:
|
|
46
|
+
|
|
47
|
+
def __call__(self, *args, **kwargs):
|
|
48
|
+
raise TypeError(
|
|
49
|
+
f"{self.factory_name} is not called -- name the declared type in its subscript: {self.spelling}"
|
|
50
|
+
)
|