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.
Files changed (31) hide show
  1. {mxhttp-1.0.0 → mxhttp-1.1.0}/PKG-INFO +25 -2
  2. {mxhttp-1.0.0 → mxhttp-1.1.0}/README.md +24 -1
  3. {mxhttp-1.0.0 → mxhttp-1.1.0}/mxhttp/__init__.py +1 -1
  4. {mxhttp-1.0.0 → mxhttp-1.1.0}/mxhttp/endpoint.py +4 -2
  5. {mxhttp-1.0.0 → mxhttp-1.1.0}/mxhttp/markers.py +39 -14
  6. mxhttp-1.1.0/mxhttp/parse.py +93 -0
  7. {mxhttp-1.0.0 → mxhttp-1.1.0}/mxhttp/request.py +98 -16
  8. {mxhttp-1.0.0 → mxhttp-1.1.0}/pyproject.toml +1 -1
  9. mxhttp-1.1.0/tests/markers/test_inline_query.py +160 -0
  10. mxhttp-1.1.0/tests/markers/test_literal_enum.py +123 -0
  11. mxhttp-1.1.0/tests/markers/test_markers.py +424 -0
  12. mxhttp-1.1.0/tests/markers/test_parse_markers.py +139 -0
  13. mxhttp-1.1.0/tests/markers/test_parse_path.py +178 -0
  14. mxhttp-1.1.0/tests/models.py +134 -0
  15. mxhttp-1.1.0/tests/test_consumer.py +29 -0
  16. mxhttp-1.1.0/tests/test_decode.py +386 -0
  17. mxhttp-1.0.0/tests/test_decode_types.py → mxhttp-1.1.0/tests/test_decode_special.py +1 -1
  18. mxhttp-1.1.0/tests/test_meta.py +31 -0
  19. mxhttp-1.1.0/tests/test_parse.py +162 -0
  20. mxhttp-1.1.0/tests/test_sse.py +97 -0
  21. mxhttp-1.0.0/tests/models.py +0 -388
  22. mxhttp-1.0.0/tests/test_api.py +0 -921
  23. {mxhttp-1.0.0 → mxhttp-1.1.0}/LICENSE +0 -0
  24. {mxhttp-1.0.0 → mxhttp-1.1.0}/mxhttp/consumer.py +0 -0
  25. {mxhttp-1.0.0 → mxhttp-1.1.0}/mxhttp/py.typed +0 -0
  26. {mxhttp-1.0.0 → mxhttp-1.1.0}/mxhttp/response.py +0 -0
  27. {mxhttp-1.0.0 → mxhttp-1.1.0}/mxhttp/sse.py +0 -0
  28. {mxhttp-1.0.0 → mxhttp-1.1.0}/mxhttp/types.py +0 -0
  29. {mxhttp-1.0.0 → mxhttp-1.1.0}/tests/conftest.py +0 -0
  30. {mxhttp-1.0.0 → mxhttp-1.1.0}/tests/test_type_errors.py +0 -0
  31. {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.0.0
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
- class AsyncFiles(AsyncConsumer)
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
- class AsyncFiles(AsyncConsumer)
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
  ```
@@ -9,7 +9,7 @@ from mxhttp.response import Response, response_handler, streaming_response_handl
9
9
  from mxhttp.sse import Event
10
10
  from mxhttp.types import PartValue
11
11
 
12
- __version__ = "1.0.0"
12
+ __version__ = "1.1.0"
13
13
 
14
14
  __all__ = [
15
15
  "AsyncConsumer",
@@ -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
- plan, return_type = build_plan(func, path)
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, path, plan, bound.arguments, jar=jar)
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 isinstance(scalar_type, type) or not issubclass(scalar_type, ParamValue):
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(cls, name: str, resolved_hint: type | None) -> None:
119
- """Verifies that a `Query` argument is a scalar and or a sequence of scalars."""
120
- validate_scalar_arg(name, resolved_hint, cls.__name__, allow_sequence=True)
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 `httpx`'s accepted file-upload shapes.
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 classify(
100
- name: str, hint: type | None, path_parts: Collection[str], param: Parameter
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: an explicit marker or an implicit path 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
- path: str,
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(path) if name}
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(py_name, hints.get(py_name), path_parts, param)
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
- path: str,
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
- path: URL path template with placeholders.
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] = str(value)
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 str(value)
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=path.format(**{k: urllib.parse.quote(str(v), safe="") for k, v in path_args.items()}),
226
- params=params,
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,
@@ -35,7 +35,7 @@ dependencies = [
35
35
  "msgspec",
36
36
  "typing-extensions",
37
37
  ]
38
- version = "1.0.0"
38
+ version = "1.1.0"
39
39
 
40
40
  [project.optional-dependencies]
41
41
  pydantic = [