mxhttp 1.0.0__tar.gz → 1.1.0__tar.gz
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.
- {mxhttp-1.0.0 → mxhttp-1.1.0}/PKG-INFO +25 -2
- {mxhttp-1.0.0 → mxhttp-1.1.0}/README.md +24 -1
- {mxhttp-1.0.0 → mxhttp-1.1.0}/mxhttp/__init__.py +1 -1
- {mxhttp-1.0.0 → mxhttp-1.1.0}/mxhttp/endpoint.py +4 -2
- {mxhttp-1.0.0 → mxhttp-1.1.0}/mxhttp/markers.py +39 -14
- mxhttp-1.1.0/mxhttp/parse.py +93 -0
- {mxhttp-1.0.0 → mxhttp-1.1.0}/mxhttp/request.py +98 -16
- {mxhttp-1.0.0 → mxhttp-1.1.0}/pyproject.toml +1 -1
- mxhttp-1.1.0/tests/markers/test_inline_query.py +160 -0
- mxhttp-1.1.0/tests/markers/test_literal_enum.py +123 -0
- mxhttp-1.1.0/tests/markers/test_markers.py +424 -0
- mxhttp-1.1.0/tests/markers/test_parse_markers.py +139 -0
- mxhttp-1.1.0/tests/markers/test_parse_path.py +178 -0
- mxhttp-1.1.0/tests/models.py +134 -0
- mxhttp-1.1.0/tests/test_consumer.py +29 -0
- mxhttp-1.1.0/tests/test_decode.py +386 -0
- mxhttp-1.0.0/tests/test_decode_types.py → mxhttp-1.1.0/tests/test_decode_special.py +1 -1
- mxhttp-1.1.0/tests/test_meta.py +31 -0
- mxhttp-1.1.0/tests/test_parse.py +162 -0
- mxhttp-1.1.0/tests/test_sse.py +97 -0
- mxhttp-1.0.0/tests/models.py +0 -388
- mxhttp-1.0.0/tests/test_api.py +0 -921
- {mxhttp-1.0.0 → mxhttp-1.1.0}/LICENSE +0 -0
- {mxhttp-1.0.0 → mxhttp-1.1.0}/mxhttp/consumer.py +0 -0
- {mxhttp-1.0.0 → mxhttp-1.1.0}/mxhttp/py.typed +0 -0
- {mxhttp-1.0.0 → mxhttp-1.1.0}/mxhttp/response.py +0 -0
- {mxhttp-1.0.0 → mxhttp-1.1.0}/mxhttp/sse.py +0 -0
- {mxhttp-1.0.0 → mxhttp-1.1.0}/mxhttp/types.py +0 -0
- {mxhttp-1.0.0 → mxhttp-1.1.0}/tests/conftest.py +0 -0
- {mxhttp-1.0.0 → mxhttp-1.1.0}/tests/test_type_errors.py +0 -0
- {mxhttp-1.0.0 → mxhttp-1.1.0}/tests/test_type_hints.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: mxhttp
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: Simple HTTP API consumer based on `httpx` and `msgspec`.
|
|
5
5
|
Keywords: http,httpx,msgspec,rest,api,client,declarative,typing
|
|
6
6
|
License-Expression: MIT
|
|
@@ -83,6 +83,27 @@ The method body is never run as it is replaced by the decorator. Parameters are
|
|
|
83
83
|
- `None`-valued `Query`, `Field`, `Header`, and `Cookie` parameters are omitted from the request.
|
|
84
84
|
- `Path` parameters cannot be optional as a placeholder cannot be ommited from the URL.
|
|
85
85
|
- Mismatched marker/type combinations raise a `TypeError` as soon as the class body runs, not at call time.
|
|
86
|
+
- `Literal[...]` and `Enum` types are accepted where scalar types are (`Path`, `Query`, `Field`, `Header`, `Cookie`). Every literal value or enum member value must be `str`, `int`, or `float` (plus `bool` outside of `Path`). `Enum` members are serialized by their `.value`.
|
|
87
|
+
|
|
88
|
+
### Inline query parameters
|
|
89
|
+
|
|
90
|
+
A path template can bake query parameters directly into the string:
|
|
91
|
+
|
|
92
|
+
```python
|
|
93
|
+
class Shop(SyncConsumer):
|
|
94
|
+
@get("/items?category={cat}")
|
|
95
|
+
def by_category(self, cat: str) -> list[Item]: ... # type: ignore[empty-body]
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
- An unmarked parameter binds implicitly to a `{name}` placeholder, same mechanism as `Path`.
|
|
99
|
+
- `Query["cat"]` binds it to a different parameter name. Unlike every other `Marker`, the brackets aren't the wire name here — the wire name is whatever query key the template assigned to `{cat}`.
|
|
100
|
+
- A query entry with no placeholder (e.g. `?active=true`) is a static value sent on every call. That key also cannot be reused by a dynamic `Query` parameter.
|
|
101
|
+
- Each query key, and each placeholder field, can only be used once per path template (e.g. `/things?a={x}&a={y}` is rejected).
|
|
102
|
+
- A placeholder field also cannot be reused by a real path segment (`/{id}?other={id}` is rejected).
|
|
103
|
+
- A placeholder cannot be mixed with literal text in the same value (`key=prefix{name}`), and cannot stand in for the key itself (`{name}` with no `=`).
|
|
104
|
+
- Every `{name}` field must be bound by exactly one parameter (implicit, `Path["name"]`, or `Query["name"]`).
|
|
105
|
+
- An inline query field cannot be a `Sequence` as the placeholder reserves exactly one query spot.
|
|
106
|
+
- All of the above raise a `TypeError` as soon as the class body runs, not at call time.
|
|
86
107
|
|
|
87
108
|
### Decoding the response
|
|
88
109
|
|
|
@@ -131,10 +152,12 @@ class Files(SyncConsumer):
|
|
|
131
152
|
for chunk in shop_files.download(file_id=7):
|
|
132
153
|
...
|
|
133
154
|
|
|
134
|
-
|
|
155
|
+
|
|
156
|
+
class AsyncFiles(AsyncConsumer):
|
|
135
157
|
@get("/files/{file_id}")
|
|
136
158
|
def download(self, file_id: int) -> AsyncIterator[bytes]: ... # type: ignore[empty-body]
|
|
137
159
|
|
|
160
|
+
|
|
138
161
|
async for chunk in await shop_async_files.download(file_id=7):
|
|
139
162
|
...
|
|
140
163
|
```
|
|
@@ -59,6 +59,27 @@ The method body is never run as it is replaced by the decorator. Parameters are
|
|
|
59
59
|
- `None`-valued `Query`, `Field`, `Header`, and `Cookie` parameters are omitted from the request.
|
|
60
60
|
- `Path` parameters cannot be optional as a placeholder cannot be ommited from the URL.
|
|
61
61
|
- Mismatched marker/type combinations raise a `TypeError` as soon as the class body runs, not at call time.
|
|
62
|
+
- `Literal[...]` and `Enum` types are accepted where scalar types are (`Path`, `Query`, `Field`, `Header`, `Cookie`). Every literal value or enum member value must be `str`, `int`, or `float` (plus `bool` outside of `Path`). `Enum` members are serialized by their `.value`.
|
|
63
|
+
|
|
64
|
+
### Inline query parameters
|
|
65
|
+
|
|
66
|
+
A path template can bake query parameters directly into the string:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
class Shop(SyncConsumer):
|
|
70
|
+
@get("/items?category={cat}")
|
|
71
|
+
def by_category(self, cat: str) -> list[Item]: ... # type: ignore[empty-body]
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
- An unmarked parameter binds implicitly to a `{name}` placeholder, same mechanism as `Path`.
|
|
75
|
+
- `Query["cat"]` binds it to a different parameter name. Unlike every other `Marker`, the brackets aren't the wire name here — the wire name is whatever query key the template assigned to `{cat}`.
|
|
76
|
+
- A query entry with no placeholder (e.g. `?active=true`) is a static value sent on every call. That key also cannot be reused by a dynamic `Query` parameter.
|
|
77
|
+
- Each query key, and each placeholder field, can only be used once per path template (e.g. `/things?a={x}&a={y}` is rejected).
|
|
78
|
+
- A placeholder field also cannot be reused by a real path segment (`/{id}?other={id}` is rejected).
|
|
79
|
+
- A placeholder cannot be mixed with literal text in the same value (`key=prefix{name}`), and cannot stand in for the key itself (`{name}` with no `=`).
|
|
80
|
+
- Every `{name}` field must be bound by exactly one parameter (implicit, `Path["name"]`, or `Query["name"]`).
|
|
81
|
+
- An inline query field cannot be a `Sequence` as the placeholder reserves exactly one query spot.
|
|
82
|
+
- All of the above raise a `TypeError` as soon as the class body runs, not at call time.
|
|
62
83
|
|
|
63
84
|
### Decoding the response
|
|
64
85
|
|
|
@@ -107,10 +128,12 @@ class Files(SyncConsumer):
|
|
|
107
128
|
for chunk in shop_files.download(file_id=7):
|
|
108
129
|
...
|
|
109
130
|
|
|
110
|
-
|
|
131
|
+
|
|
132
|
+
class AsyncFiles(AsyncConsumer):
|
|
111
133
|
@get("/files/{file_id}")
|
|
112
134
|
def download(self, file_id: int) -> AsyncIterator[bytes]: ... # type: ignore[empty-body]
|
|
113
135
|
|
|
136
|
+
|
|
114
137
|
async for chunk in await shop_async_files.download(file_id=7):
|
|
115
138
|
...
|
|
116
139
|
```
|
|
@@ -16,6 +16,7 @@ from typing import (
|
|
|
16
16
|
overload,
|
|
17
17
|
)
|
|
18
18
|
|
|
19
|
+
from mxhttp.parse import split_path_template
|
|
19
20
|
from mxhttp.request import RequestSpec, build_plan, build_request
|
|
20
21
|
from mxhttp.response import (
|
|
21
22
|
apply_response_handler,
|
|
@@ -93,7 +94,8 @@ def endpoint(method: Method_T, path: str) -> EndpointDecorator: # noqa: C901
|
|
|
93
94
|
Callable[Concatenate[AnyC_T, P], Parsed_T]
|
|
94
95
|
| Callable[Concatenate[AnyC_T, P], Coroutine[Any, Any, Parsed_T]]
|
|
95
96
|
):
|
|
96
|
-
|
|
97
|
+
parsed = split_path_template(path)
|
|
98
|
+
plan, return_type = build_plan(func, parsed)
|
|
97
99
|
sig = inspect.signature(func)
|
|
98
100
|
origin = get_origin(return_type)
|
|
99
101
|
stream_item = get_args(return_type)[0] if origin in (Iterator, AsyncIterator) else None
|
|
@@ -106,7 +108,7 @@ def endpoint(method: Method_T, path: str) -> EndpointDecorator: # noqa: C901
|
|
|
106
108
|
bound = sig.bind(self, *args, **kwargs)
|
|
107
109
|
bound.apply_defaults()
|
|
108
110
|
jar = dict(self.session.cookies) if has_cookies else None
|
|
109
|
-
return build_request(method,
|
|
111
|
+
return build_request(method, parsed, plan, bound.arguments, jar=jar)
|
|
110
112
|
|
|
111
113
|
if inspect.iscoroutinefunction(func):
|
|
112
114
|
if is_sse_stream:
|
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
from collections.abc import Mapping, Sequence
|
|
6
|
+
from enum import Enum
|
|
6
7
|
from types import UnionType
|
|
7
|
-
from typing import TYPE_CHECKING, Union, get_args, get_origin
|
|
8
|
+
from typing import TYPE_CHECKING, Literal, Union, get_args, get_origin
|
|
8
9
|
|
|
9
10
|
from typing_extensions import Self
|
|
10
11
|
|
|
@@ -14,14 +15,37 @@ if TYPE_CHECKING:
|
|
|
14
15
|
from inspect import Parameter
|
|
15
16
|
|
|
16
17
|
|
|
18
|
+
def scalar_value_types(hint: type | None) -> tuple[type, ...] | None:
|
|
19
|
+
"""Returns the concrete runtime types backing a `Literal[...]` or `Enum`, else `None`."""
|
|
20
|
+
if get_origin(hint) is Literal:
|
|
21
|
+
return tuple({type(v) for v in get_args(hint)})
|
|
22
|
+
if isinstance(hint, type) and issubclass(hint, Enum):
|
|
23
|
+
return tuple({type(member.value) for member in hint})
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def is_valid_scalar(
|
|
28
|
+
hint: type | None, allowed: type | UnionType, *, forbid_bool: bool = False
|
|
29
|
+
) -> bool:
|
|
30
|
+
"""Checks whether `hint` (a type, `Literal[...]`, or `Enum` subclass) matches `allowed`."""
|
|
31
|
+
value_types = scalar_value_types(hint)
|
|
32
|
+
if value_types is None:
|
|
33
|
+
if not isinstance(hint, type) or get_origin(hint) is not None:
|
|
34
|
+
return False
|
|
35
|
+
value_types = (hint,)
|
|
36
|
+
if not value_types:
|
|
37
|
+
return False
|
|
38
|
+
if forbid_bool and any(issubclass(t, bool) for t in value_types):
|
|
39
|
+
return False
|
|
40
|
+
return all(issubclass(t, allowed) for t in value_types)
|
|
41
|
+
|
|
42
|
+
|
|
17
43
|
def is_scalar_sequence(hint: type | None) -> bool:
|
|
18
44
|
"""Checks whether `hint` is a `list`, `tuple`, or `Sequence` of scalar `ParamValue` objects."""
|
|
19
45
|
if get_origin(hint) not in (list, tuple, Sequence):
|
|
20
46
|
return False
|
|
21
47
|
item_types = [a for a in get_args(hint) if a is not Ellipsis]
|
|
22
|
-
return bool(item_types) and all(
|
|
23
|
-
isinstance(a, type) and issubclass(a, ParamValue) for a in item_types
|
|
24
|
-
)
|
|
48
|
+
return bool(item_types) and all(is_valid_scalar(a, ParamValue) for a in item_types)
|
|
25
49
|
|
|
26
50
|
|
|
27
51
|
def validate_scalar_arg(
|
|
@@ -30,7 +54,7 @@ def validate_scalar_arg(
|
|
|
30
54
|
"""Verifies that `Query`,`Field`, `Header`, or `Cookie` arguments have a scalar type."""
|
|
31
55
|
if allow_sequence and is_scalar_sequence(scalar_type):
|
|
32
56
|
return
|
|
33
|
-
if not
|
|
57
|
+
if not is_valid_scalar(scalar_type, ParamValue):
|
|
34
58
|
allowed = "str | int | float | bool"
|
|
35
59
|
if allow_sequence:
|
|
36
60
|
allowed += " | Sequence[str | int | float | bool]"
|
|
@@ -99,11 +123,7 @@ class Path(Marker):
|
|
|
99
123
|
@staticmethod
|
|
100
124
|
def validate(py_name: str, path_type: type | None, is_optional: bool, param: Parameter) -> None:
|
|
101
125
|
"""Verifies that path arguments have the correct type."""
|
|
102
|
-
if (
|
|
103
|
-
not isinstance(path_type, type)
|
|
104
|
-
or issubclass(path_type, bool)
|
|
105
|
-
or not issubclass(path_type, ValidPath_T)
|
|
106
|
-
):
|
|
126
|
+
if not is_valid_scalar(path_type, ValidPath_T, forbid_bool=True):
|
|
107
127
|
raise TypeError(f"Path argument {py_name!r} must be str | int | float")
|
|
108
128
|
if param.default is None:
|
|
109
129
|
raise TypeError(f"Path argument {py_name!r} must not default to None")
|
|
@@ -115,9 +135,14 @@ class Query(Marker):
|
|
|
115
135
|
"""Binds a parameter to an URL query string parameter, omitting `None` values."""
|
|
116
136
|
|
|
117
137
|
@classmethod
|
|
118
|
-
def validate(
|
|
119
|
-
|
|
120
|
-
|
|
138
|
+
def validate(
|
|
139
|
+
cls, name: str, resolved_hint: type | None, *, allow_sequence: bool = True
|
|
140
|
+
) -> None:
|
|
141
|
+
"""Verifies that a `Query` argument is a scalar and or a sequence of scalars.
|
|
142
|
+
|
|
143
|
+
`allow_sequence` is `False` for inline queries.
|
|
144
|
+
"""
|
|
145
|
+
validate_scalar_arg(name, resolved_hint, cls.__name__, allow_sequence=allow_sequence)
|
|
121
146
|
|
|
122
147
|
|
|
123
148
|
class Field(Marker):
|
|
@@ -142,7 +167,7 @@ class Part(Marker):
|
|
|
142
167
|
|
|
143
168
|
@staticmethod
|
|
144
169
|
def validate(py_name: str, part_type: type | None) -> None:
|
|
145
|
-
"""Verifies that a `Part` argument matches one of
|
|
170
|
+
"""Verifies that a `Part` argument matches one of the file-upload shapes `httpx` accepts.
|
|
146
171
|
|
|
147
172
|
`part_type` may be a `Union` of accepted shapes (e.g. the `PartValue` alias), so every
|
|
148
173
|
union member must be individually valid.
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Parses a path template into its path portion and any inline query bindings."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import string
|
|
6
|
+
import urllib.parse
|
|
7
|
+
import uuid
|
|
8
|
+
from typing import NamedTuple
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ParsedPath(NamedTuple):
|
|
12
|
+
"""Pieces of a path template split by `split_path_template()`."""
|
|
13
|
+
|
|
14
|
+
path_only: str
|
|
15
|
+
static_query: dict[str, str]
|
|
16
|
+
inline_query_names: dict[str, str]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def reescape(literal_text: str) -> str:
|
|
20
|
+
"""Re-escapes braces `str.Formatter().parse()` unescaped."""
|
|
21
|
+
return literal_text.replace("{", "{{").replace("}", "}}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def unescape(text: str) -> str:
|
|
25
|
+
"""Undoes `reescape()`."""
|
|
26
|
+
return text.replace("{{", "{").replace("}}", "}")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def split_path_template(path: str) -> ParsedPath: # noqa: C901, PLR0912
|
|
30
|
+
"""Splits a path template into its path portion and its query string, if any.
|
|
31
|
+
|
|
32
|
+
Returns:
|
|
33
|
+
`ParsedPath` of the bare path, static `key: value` query entries, and `py_name:
|
|
34
|
+
wire_name` pairs for inline query placeholders.
|
|
35
|
+
|
|
36
|
+
Raises:
|
|
37
|
+
TypeError: If a `{name}` field has a format spec or conversion, is anonymous or
|
|
38
|
+
positional, mixes a placeholder with literal text in a query value, uses a bare
|
|
39
|
+
placeholder as its own query key, reuses a query key or field name, or reuses a
|
|
40
|
+
field name of a path segment as an inline query placeholder.
|
|
41
|
+
"""
|
|
42
|
+
marker = uuid.uuid4().hex
|
|
43
|
+
names: list[str] = []
|
|
44
|
+
clean_parts: list[str] = []
|
|
45
|
+
for literal_text, field_name, format_spec, conversion in string.Formatter().parse(path):
|
|
46
|
+
clean_parts.append(reescape(literal_text))
|
|
47
|
+
if field_name is None:
|
|
48
|
+
continue
|
|
49
|
+
if format_spec or conversion:
|
|
50
|
+
raise TypeError(f"Field {field_name!r} must not have a format spec or conversion")
|
|
51
|
+
if not field_name or field_name.isdigit():
|
|
52
|
+
raise TypeError("Anonymous ('{}') or positional ('{0}') fields are not supported")
|
|
53
|
+
names.append(field_name)
|
|
54
|
+
clean_parts.append(f"{marker}{len(names) - 1}_")
|
|
55
|
+
split = urllib.parse.urlsplit("".join(clean_parts))
|
|
56
|
+
|
|
57
|
+
path_field_count = split.path.count(marker)
|
|
58
|
+
path_only = split.path
|
|
59
|
+
path_field_names = set(names[:path_field_count])
|
|
60
|
+
for index, name in enumerate(names[:path_field_count]):
|
|
61
|
+
path_only = path_only.replace(f"{marker}{index}_", f"{{{name}}}")
|
|
62
|
+
|
|
63
|
+
static_query: dict[str, str] = {}
|
|
64
|
+
inline_query_names: dict[str, str] = {}
|
|
65
|
+
used_keys: set[str] = set()
|
|
66
|
+
for key, value in urllib.parse.parse_qsl(split.query, keep_blank_values=True):
|
|
67
|
+
if marker in key:
|
|
68
|
+
raise TypeError(
|
|
69
|
+
"Inline query parameters must supply an explicit key (e.g. 'key={name}')"
|
|
70
|
+
)
|
|
71
|
+
if key in used_keys:
|
|
72
|
+
raise TypeError(f"Query key {key!r} is used more than once")
|
|
73
|
+
used_keys.add(key)
|
|
74
|
+
|
|
75
|
+
token = value.removeprefix(marker)
|
|
76
|
+
index_text, sep, rest = token.partition("_")
|
|
77
|
+
if value.startswith(marker) and sep and not rest and index_text.isdigit():
|
|
78
|
+
field = names[int(index_text)]
|
|
79
|
+
if field in inline_query_names:
|
|
80
|
+
raise TypeError(f"Field {field!r} must not be bound multiple times")
|
|
81
|
+
if field in path_field_names:
|
|
82
|
+
raise TypeError(
|
|
83
|
+
f"Field {field!r} is used for both a path segment and an inline query "
|
|
84
|
+
"parameter; give one of them a different placeholder name"
|
|
85
|
+
)
|
|
86
|
+
inline_query_names[field] = key
|
|
87
|
+
elif marker in value:
|
|
88
|
+
raise TypeError(
|
|
89
|
+
f"Inline query parameter {key!r} must be a literal value or a bare placeholder"
|
|
90
|
+
)
|
|
91
|
+
else:
|
|
92
|
+
static_query[unescape(key)] = unescape(value)
|
|
93
|
+
return ParsedPath(path_only, static_query, inline_query_names)
|
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
import inspect
|
|
6
6
|
import string
|
|
7
7
|
import urllib.parse
|
|
8
|
+
from enum import Enum
|
|
8
9
|
from types import UnionType
|
|
9
10
|
from typing import (
|
|
10
11
|
TYPE_CHECKING,
|
|
@@ -28,6 +29,8 @@ from mxhttp.types import MISSING, AnyC_T, JsonValue, Param_T, Parsed_T, PartValu
|
|
|
28
29
|
if TYPE_CHECKING:
|
|
29
30
|
from collections.abc import Callable, Collection, Coroutine, Mapping
|
|
30
31
|
from inspect import Parameter
|
|
32
|
+
|
|
33
|
+
from mxhttp.parse import ParsedPath
|
|
31
34
|
P = ParamSpec("P")
|
|
32
35
|
|
|
33
36
|
|
|
@@ -43,7 +46,7 @@ class ParamPlan(msgspec.Struct):
|
|
|
43
46
|
class RequestKwargs(TypedDict):
|
|
44
47
|
"""Stores keyword arguments shared by every `session.request` or `session.stream` call."""
|
|
45
48
|
|
|
46
|
-
params: dict[str, QueryValue]
|
|
49
|
+
params: dict[str, QueryValue] | None
|
|
47
50
|
headers: dict[str, str] | None
|
|
48
51
|
data: dict[str, object] | None
|
|
49
52
|
files: dict[str, PartValue] | None
|
|
@@ -55,7 +58,7 @@ class RequestSpec(msgspec.Struct):
|
|
|
55
58
|
|
|
56
59
|
method: str
|
|
57
60
|
url: str
|
|
58
|
-
params: dict[str, QueryValue]
|
|
61
|
+
params: dict[str, QueryValue] | None
|
|
59
62
|
headers: dict[str, str] | None
|
|
60
63
|
data: dict[str, object] | None
|
|
61
64
|
files: dict[str, PartValue] | None
|
|
@@ -72,6 +75,13 @@ class RequestSpec(msgspec.Struct):
|
|
|
72
75
|
}
|
|
73
76
|
|
|
74
77
|
|
|
78
|
+
def scalar_str(value: object) -> str:
|
|
79
|
+
"""Converts a scalar value to a string, using `.value` from `Enum` object."""
|
|
80
|
+
if isinstance(value, Enum):
|
|
81
|
+
value = value.value
|
|
82
|
+
return str(value)
|
|
83
|
+
|
|
84
|
+
|
|
75
85
|
def unwrap_hint(hint: type | None) -> tuple[type | None, bool, list[object]]:
|
|
76
86
|
"""Unwraps nested `Optional` and `Annotated` layers in any order.
|
|
77
87
|
|
|
@@ -96,15 +106,54 @@ def unwrap_hint(hint: type | None) -> tuple[type | None, bool, list[object]]:
|
|
|
96
106
|
hint = unwrapped
|
|
97
107
|
|
|
98
108
|
|
|
99
|
-
def
|
|
100
|
-
name: str,
|
|
109
|
+
def resolve_inline_query(
|
|
110
|
+
marker: Query, name: str, resolved_hint: type | None, inline_query_names: Mapping[str, str]
|
|
111
|
+
) -> tuple[str, Marker] | None:
|
|
112
|
+
"""Resolves an explicit `Query[...]` marker against an inline query field, if it names one.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
The resolved `(wire_name, marker)` pair, or `None` if the marker doesn't name an inline
|
|
116
|
+
query field.
|
|
117
|
+
|
|
118
|
+
Raises:
|
|
119
|
+
TypeError: If the marker targets wire name of an inline field directly instead of its
|
|
120
|
+
placeholder name, which would bypass own validation of the field.
|
|
121
|
+
"""
|
|
122
|
+
lookup_name = marker.name or name
|
|
123
|
+
if lookup_name in inline_query_names:
|
|
124
|
+
marker.validate(name, resolved_hint, allow_sequence=False)
|
|
125
|
+
return (inline_query_names[lookup_name], marker)
|
|
126
|
+
if lookup_name in inline_query_names.values():
|
|
127
|
+
raise TypeError(
|
|
128
|
+
f"Query argument {name!r} targets wire name {lookup_name!r} directly"
|
|
129
|
+
" which is reserved by an inline query"
|
|
130
|
+
)
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def classify( # noqa: C901
|
|
135
|
+
name: str,
|
|
136
|
+
hint: type | None,
|
|
137
|
+
path_parts: Collection[str],
|
|
138
|
+
inline_query_names: Mapping[str, str],
|
|
139
|
+
param: Parameter,
|
|
101
140
|
) -> tuple[str, Marker | type[Body]]:
|
|
102
|
-
"""Resolves the binding of a parameter:
|
|
141
|
+
"""Resolves the binding of a parameter: with explicit marker or implicit path/inline query."""
|
|
103
142
|
resolved_hint, is_optional, markers = unwrap_hint(hint)
|
|
104
143
|
for extra in markers: # pragma: no branch
|
|
105
144
|
marker = extra() if extra in (Path, Query, Field, Part, Header, Cookie) else extra
|
|
106
145
|
if isinstance(marker, Path):
|
|
146
|
+
lookup_name = marker.name or name
|
|
147
|
+
if lookup_name not in path_parts:
|
|
148
|
+
raise TypeError(
|
|
149
|
+
f"Path argument {name!r} targets field {lookup_name!r}, but it doesn't "
|
|
150
|
+
"appear in the path template"
|
|
151
|
+
)
|
|
107
152
|
marker.validate(name, resolved_hint, is_optional, param)
|
|
153
|
+
if isinstance(marker, Query):
|
|
154
|
+
resolved = resolve_inline_query(marker, name, resolved_hint, inline_query_names)
|
|
155
|
+
if resolved is not None:
|
|
156
|
+
return resolved
|
|
108
157
|
if isinstance(marker, (Query, Field, Header, Cookie, Part)):
|
|
109
158
|
marker.validate(name, resolved_hint)
|
|
110
159
|
if isinstance(marker, Marker):
|
|
@@ -113,6 +162,9 @@ def classify(
|
|
|
113
162
|
Body.validate(name, resolved_hint)
|
|
114
163
|
return (name, Body)
|
|
115
164
|
raise TypeError(f"Unexpected extra: {extra}")
|
|
165
|
+
if name in inline_query_names:
|
|
166
|
+
Query.validate(name, resolved_hint, allow_sequence=False)
|
|
167
|
+
return (inline_query_names[name], Query())
|
|
116
168
|
if name in path_parts:
|
|
117
169
|
Path.validate(name, resolved_hint, is_optional, param)
|
|
118
170
|
return (name, Path())
|
|
@@ -129,10 +181,10 @@ def rejects_union(return_type: type) -> bool:
|
|
|
129
181
|
return False
|
|
130
182
|
|
|
131
183
|
|
|
132
|
-
def build_plan( # noqa: C901
|
|
184
|
+
def build_plan( # noqa: C901, PLR0912
|
|
133
185
|
func: Callable[Concatenate[AnyC_T, P], Parsed_T]
|
|
134
186
|
| Callable[Concatenate[AnyC_T, P], Coroutine[Any, Any, Parsed_T]],
|
|
135
|
-
|
|
187
|
+
parsed: ParsedPath,
|
|
136
188
|
) -> tuple[list[ParamPlan], type[Parsed_T]]:
|
|
137
189
|
"""Builds a parameter plan and return type for a callable."""
|
|
138
190
|
hints: dict[str, type] = get_type_hints(func, include_extras=True)
|
|
@@ -142,15 +194,24 @@ def build_plan( # noqa: C901
|
|
|
142
194
|
if rejects_union(return_type):
|
|
143
195
|
raise TypeError(f"Return type must not be a union: {return_type!r}")
|
|
144
196
|
|
|
145
|
-
path_parts = {name for _, name, _, _ in string.Formatter().parse(
|
|
197
|
+
path_parts = {name for _, name, _, _ in string.Formatter().parse(parsed.path_only) if name}
|
|
146
198
|
|
|
147
199
|
sig = inspect.signature(func)
|
|
148
200
|
plan: list[ParamPlan] = []
|
|
201
|
+
wire_names_seen: dict[Param_T, set[str]] = {
|
|
202
|
+
"path": set(),
|
|
203
|
+
"query": set(parsed.static_query),
|
|
204
|
+
"field": set(),
|
|
205
|
+
"header": set(),
|
|
206
|
+
"cookie": set(),
|
|
207
|
+
}
|
|
149
208
|
kind: Param_T
|
|
150
209
|
for py_name, param in sig.parameters.items():
|
|
151
210
|
if py_name == "self":
|
|
152
211
|
continue
|
|
153
|
-
wire_name, marker = classify(
|
|
212
|
+
wire_name, marker = classify(
|
|
213
|
+
py_name, hints.get(py_name), path_parts, parsed.inline_query_names, param
|
|
214
|
+
)
|
|
154
215
|
if marker is Body:
|
|
155
216
|
kind = "body"
|
|
156
217
|
elif isinstance(marker, Path):
|
|
@@ -165,18 +226,37 @@ def build_plan( # noqa: C901
|
|
|
165
226
|
kind = "cookie"
|
|
166
227
|
else:
|
|
167
228
|
kind = "part"
|
|
229
|
+
if kind in wire_names_seen:
|
|
230
|
+
if wire_name in wire_names_seen[kind]:
|
|
231
|
+
if kind == "query" and wire_name in parsed.static_query:
|
|
232
|
+
raise TypeError(
|
|
233
|
+
f"Parameter {py_name!r} reuses query wire name {wire_name!r}, already "
|
|
234
|
+
"set as a static value in the path template"
|
|
235
|
+
)
|
|
236
|
+
raise TypeError(
|
|
237
|
+
f"Parameter {py_name!r} reuses {kind} wire name {wire_name!r}, already "
|
|
238
|
+
"bound by another parameter"
|
|
239
|
+
)
|
|
240
|
+
wire_names_seen[kind].add(wire_name)
|
|
168
241
|
cookie_override = marker.override if isinstance(marker, Cookie) else False
|
|
169
242
|
plan.append(
|
|
170
243
|
ParamPlan(
|
|
171
244
|
py_name=py_name, wire_name=wire_name, kind=kind, cookie_override=cookie_override
|
|
172
245
|
)
|
|
173
246
|
)
|
|
247
|
+
bound_path_names = {p.wire_name for p in plan if p.kind == "path"}
|
|
248
|
+
for field_name in sorted(path_parts - bound_path_names):
|
|
249
|
+
raise TypeError(f"Path field {field_name!r} is not bound by any parameter")
|
|
250
|
+
bound_query_names = {p.wire_name for p in plan if p.kind == "query"}
|
|
251
|
+
for field_name, wire_name in sorted(parsed.inline_query_names.items()):
|
|
252
|
+
if wire_name not in bound_query_names:
|
|
253
|
+
raise TypeError(f"Inline query field {field_name!r} is not bound by any parameter")
|
|
174
254
|
return plan, return_type
|
|
175
255
|
|
|
176
256
|
|
|
177
257
|
def build_request(
|
|
178
258
|
method: str,
|
|
179
|
-
|
|
259
|
+
parsed: ParsedPath,
|
|
180
260
|
plan: list[ParamPlan],
|
|
181
261
|
values: Mapping[str, object],
|
|
182
262
|
jar: Mapping[str, str] | None = None,
|
|
@@ -185,13 +265,13 @@ def build_request(
|
|
|
185
265
|
|
|
186
266
|
Args:
|
|
187
267
|
method: HTTP method for the request.
|
|
188
|
-
|
|
268
|
+
parsed: Bare path template and static query entries, from `split_path_template()`.
|
|
189
269
|
plan: Parameter plan describing how to map values to the request.
|
|
190
270
|
values: Mapping of parameter names to their values.
|
|
191
271
|
jar: Snapshot of the current cookie jar.
|
|
192
272
|
"""
|
|
193
273
|
path_args: dict[str, object] = {}
|
|
194
|
-
params: dict[str, QueryValue] =
|
|
274
|
+
params: dict[str, QueryValue] = dict(parsed.static_query)
|
|
195
275
|
headers: dict[str, str] = {}
|
|
196
276
|
cookies: dict[str, str] = {}
|
|
197
277
|
fields: dict[str, object] = {}
|
|
@@ -210,10 +290,10 @@ def build_request(
|
|
|
210
290
|
elif p.kind == "part":
|
|
211
291
|
files[p.wire_name] = value # type: ignore[assignment]
|
|
212
292
|
elif p.kind == "header":
|
|
213
|
-
headers[p.wire_name] =
|
|
293
|
+
headers[p.wire_name] = scalar_str(value)
|
|
214
294
|
elif p.kind == "cookie":
|
|
215
295
|
jar_value = None if p.cookie_override else (jar or {}).get(p.wire_name)
|
|
216
|
-
cookies[p.wire_name] = jar_value if jar_value is not None else
|
|
296
|
+
cookies[p.wire_name] = jar_value if jar_value is not None else scalar_str(value)
|
|
217
297
|
else:
|
|
218
298
|
body = msgspec.to_builtins(value)
|
|
219
299
|
if cookies:
|
|
@@ -222,8 +302,10 @@ def build_request(
|
|
|
222
302
|
)
|
|
223
303
|
return RequestSpec(
|
|
224
304
|
method=method,
|
|
225
|
-
url=
|
|
226
|
-
|
|
305
|
+
url=parsed.path_only.format(
|
|
306
|
+
**{k: urllib.parse.quote(scalar_str(v), safe="") for k, v in path_args.items()}
|
|
307
|
+
),
|
|
308
|
+
params=params or None,
|
|
227
309
|
headers=headers or None,
|
|
228
310
|
data=fields or None,
|
|
229
311
|
files=files or None,
|