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.
Files changed (87) hide show
  1. cliv1-0.0.8.dist-info/METADATA +168 -0
  2. cliv1-0.0.8.dist-info/RECORD +87 -0
  3. cliv1-0.0.8.dist-info/WHEEL +4 -0
  4. cliv1-0.0.8.dist-info/licenses/LICENSE +21 -0
  5. swagger_petstore_open_api_3_0/__init__.py +15 -0
  6. swagger_petstore_open_api_3_0/apis/__init__.py +5 -0
  7. swagger_petstore_open_api_3_0/apis/pet_api.py +883 -0
  8. swagger_petstore_open_api_3_0/apis/store.py +413 -0
  9. swagger_petstore_open_api_3_0/apis/user_api.py +794 -0
  10. swagger_petstore_open_api_3_0/async_client.py +79 -0
  11. swagger_petstore_open_api_3_0/auth.py +17 -0
  12. swagger_petstore_open_api_3_0/base_client.py +18 -0
  13. swagger_petstore_open_api_3_0/client.py +77 -0
  14. swagger_petstore_open_api_3_0/core/__init__.py +358 -0
  15. swagger_petstore_open_api_3_0/core/_internal/__init__.py +9 -0
  16. swagger_petstore_open_api_3_0/core/_internal/content_disposition.py +154 -0
  17. swagger_petstore_open_api_3_0/core/_internal/flattening.py +139 -0
  18. swagger_petstore_open_api_3_0/core/_internal/headers.py +144 -0
  19. swagger_petstore_open_api_3_0/core/_internal/sse_parser.py +124 -0
  20. swagger_petstore_open_api_3_0/core/_internal/subscripts.py +50 -0
  21. swagger_petstore_open_api_3_0/core/_internal/urls.py +186 -0
  22. swagger_petstore_open_api_3_0/core/_internal/wire.py +198 -0
  23. swagger_petstore_open_api_3_0/core/adapters.py +177 -0
  24. swagger_petstore_open_api_3_0/core/auth/__init__.py +70 -0
  25. swagger_petstore_open_api_3_0/core/auth/composition.py +175 -0
  26. swagger_petstore_open_api_3_0/core/auth/models.py +216 -0
  27. swagger_petstore_open_api_3_0/core/auth/protocols.py +103 -0
  28. swagger_petstore_open_api_3_0/core/auth/schemes/__init__.py +94 -0
  29. swagger_petstore_open_api_3_0/core/auth/schemes/api_key_auth.py +53 -0
  30. swagger_petstore_open_api_3_0/core/auth/schemes/basic_auth.py +73 -0
  31. swagger_petstore_open_api_3_0/core/auth/schemes/bearer_auth.py +21 -0
  32. swagger_petstore_open_api_3_0/core/auth/schemes/oauth2_authorization_code.py +393 -0
  33. swagger_petstore_open_api_3_0/core/auth/schemes/oauth2_client_credentials.py +127 -0
  34. swagger_petstore_open_api_3_0/core/auth/schemes/oauth2_password.py +142 -0
  35. swagger_petstore_open_api_3_0/core/auth/schemes/oauth2_schemes.py +300 -0
  36. swagger_petstore_open_api_3_0/core/base_raw_response.py +49 -0
  37. swagger_petstore_open_api_3_0/core/bodies.py +550 -0
  38. swagger_petstore_open_api_3_0/core/converters/__init__.py +68 -0
  39. swagger_petstore_open_api_3_0/core/converters/date_time.py +237 -0
  40. swagger_petstore_open_api_3_0/core/converters/encoded_bytes.py +273 -0
  41. swagger_petstore_open_api_3_0/core/converters/open_enum.py +35 -0
  42. swagger_petstore_open_api_3_0/core/decoding.py +498 -0
  43. swagger_petstore_open_api_3_0/core/discriminators.py +129 -0
  44. swagger_petstore_open_api_3_0/core/event_streams.py +225 -0
  45. swagger_petstore_open_api_3_0/core/exceptions.py +38 -0
  46. swagger_petstore_open_api_3_0/core/file_responses.py +335 -0
  47. swagger_petstore_open_api_3_0/core/files.py +173 -0
  48. swagger_petstore_open_api_3_0/core/httpx_transport.py +748 -0
  49. swagger_petstore_open_api_3_0/core/models.py +143 -0
  50. swagger_petstore_open_api_3_0/core/optionality.py +130 -0
  51. swagger_petstore_open_api_3_0/core/params.py +157 -0
  52. swagger_petstore_open_api_3_0/core/raw_client.py +338 -0
  53. swagger_petstore_open_api_3_0/core/request_options.py +94 -0
  54. swagger_petstore_open_api_3_0/core/results.py +123 -0
  55. swagger_petstore_open_api_3_0/core/runtime_env.py +43 -0
  56. swagger_petstore_open_api_3_0/core/servers.py +33 -0
  57. swagger_petstore_open_api_3_0/core/transport.py +191 -0
  58. swagger_petstore_open_api_3_0/errors/__init__.py +48 -0
  59. swagger_petstore_open_api_3_0/errors/add_pet_error.py +21 -0
  60. swagger_petstore_open_api_3_0/errors/delete_order_error.py +21 -0
  61. swagger_petstore_open_api_3_0/errors/delete_pet_error.py +21 -0
  62. swagger_petstore_open_api_3_0/errors/delete_user_error.py +21 -0
  63. swagger_petstore_open_api_3_0/errors/find_pets_by_status_error.py +21 -0
  64. swagger_petstore_open_api_3_0/errors/find_pets_by_tags_error.py +21 -0
  65. swagger_petstore_open_api_3_0/errors/get_order_by_id_error.py +21 -0
  66. swagger_petstore_open_api_3_0/errors/get_pet_by_id_error.py +21 -0
  67. swagger_petstore_open_api_3_0/errors/get_user_by_name_error.py +21 -0
  68. swagger_petstore_open_api_3_0/errors/login_user_error.py +21 -0
  69. swagger_petstore_open_api_3_0/errors/place_order_error.py +21 -0
  70. swagger_petstore_open_api_3_0/errors/update_pet_error.py +21 -0
  71. swagger_petstore_open_api_3_0/errors/update_pet_with_form_error.py +21 -0
  72. swagger_petstore_open_api_3_0/errors/update_user_error.py +21 -0
  73. swagger_petstore_open_api_3_0/errors/upload_file_error.py +21 -0
  74. swagger_petstore_open_api_3_0/models/__init__.py +23 -0
  75. swagger_petstore_open_api_3_0/models/api_response.py +18 -0
  76. swagger_petstore_open_api_3_0/models/category.py +15 -0
  77. swagger_petstore_open_api_3_0/models/enums/__init__.py +4 -0
  78. swagger_petstore_open_api_3_0/models/enums/order_status.py +17 -0
  79. swagger_petstore_open_api_3_0/models/enums/pet_status.py +17 -0
  80. swagger_petstore_open_api_3_0/models/order.py +27 -0
  81. swagger_petstore_open_api_3_0/models/pet.py +28 -0
  82. swagger_petstore_open_api_3_0/models/tag_model.py +15 -0
  83. swagger_petstore_open_api_3_0/models/user.py +29 -0
  84. swagger_petstore_open_api_3_0/py.typed +0 -0
  85. swagger_petstore_open_api_3_0/server/__init__.py +21 -0
  86. swagger_petstore_open_api_3_0/server/server.py +17 -0
  87. 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
+ )