httpware 0.1.0__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.
httpware/client.py ADDED
@@ -0,0 +1,620 @@
1
+ """AsyncClient — the v0.1.0 public surface of httpware."""
2
+
3
+ import dataclasses
4
+ import json as _json
5
+ import typing
6
+ from collections.abc import Mapping, Sequence
7
+
8
+ from httpware._internal.chain import compose
9
+ from httpware.config import ClientConfig, Limits, Timeout
10
+ from httpware.decoders import ResponseDecoder
11
+ from httpware.decoders.pydantic import PydanticDecoder
12
+ from httpware.middleware import Middleware, Next
13
+ from httpware.request import Request
14
+ from httpware.response import Response
15
+ from httpware.transports import Transport
16
+ from httpware.transports.httpx2 import Httpx2Transport
17
+
18
+
19
+ _UNSET: typing.Any = object()
20
+
21
+ T = typing.TypeVar("T")
22
+
23
+ # Recursive type alias for any JSON-serializable Python value. Used for the `json=` body parameter
24
+ # on HTTP methods so we avoid `Any` while still accepting arbitrary nested structures.
25
+ JsonValue: typing.TypeAlias = Mapping[str, "JsonValue"] | Sequence["JsonValue"] | str | int | float | bool | None
26
+
27
+
28
+ def _normalize_timeout(value: Timeout | float | None) -> Timeout:
29
+ if value is None:
30
+ return Timeout()
31
+ if isinstance(value, Timeout):
32
+ return value
33
+ return Timeout(connect=value, read=value, write=value, pool=value)
34
+
35
+
36
+ def _build_body(
37
+ json_value: JsonValue,
38
+ content: bytes | None,
39
+ ) -> tuple[bytes | None, str | None]:
40
+ if json_value is not None and content is not None:
41
+ msg = "pass either `json` or `content`, not both"
42
+ raise TypeError(msg)
43
+ if json_value is not None:
44
+ return _json.dumps(json_value).encode("utf-8"), "application/json"
45
+ return content, None
46
+
47
+
48
+ class AsyncClient:
49
+ """Async HTTP client with typed response decoding and middleware composition."""
50
+
51
+ _config: ClientConfig
52
+ _transport: Transport
53
+ _dispatch: Next
54
+ _owns_transport: bool
55
+
56
+ def __init__(
57
+ self,
58
+ *,
59
+ base_url: str | None = None,
60
+ default_headers: Mapping[str, str] | None = None,
61
+ default_query: Mapping[str, str] | None = None,
62
+ timeout: Timeout | float | None = None,
63
+ limits: Limits | None = None,
64
+ transport: Transport | None = None,
65
+ decoder: ResponseDecoder | None = None,
66
+ middleware: Sequence[Middleware] | None = None,
67
+ ) -> None:
68
+ normalized_timeout = _normalize_timeout(timeout)
69
+ resolved_limits = limits or Limits()
70
+ resolved_transport: Transport = transport or Httpx2Transport(limits=resolved_limits, timeout=normalized_timeout)
71
+ resolved_decoder = decoder or PydanticDecoder()
72
+ resolved_middleware = tuple(middleware) if middleware is not None else ()
73
+
74
+ self._config = ClientConfig(
75
+ base_url=base_url,
76
+ default_headers=dict(default_headers or {}),
77
+ default_query=dict(default_query or {}),
78
+ timeout=normalized_timeout,
79
+ limits=resolved_limits,
80
+ decoder=resolved_decoder,
81
+ middleware=resolved_middleware,
82
+ )
83
+ self._transport = resolved_transport
84
+ self._dispatch = compose(resolved_middleware, resolved_transport)
85
+ self._owns_transport = True
86
+
87
+ @classmethod
88
+ def from_url(cls, base_url: str, **kwargs: object) -> "AsyncClient":
89
+ """Construct an AsyncClient with a base URL prefix."""
90
+ return cls(base_url=base_url, **kwargs) # ty: ignore[invalid-argument-type]
91
+
92
+ async def __aenter__(self) -> typing.Self:
93
+ return self
94
+
95
+ async def __aexit__(
96
+ self,
97
+ exc_type: type[BaseException] | None,
98
+ exc: BaseException | None,
99
+ tb: object,
100
+ ) -> None:
101
+ if self._owns_transport:
102
+ await self._transport.aclose()
103
+
104
+ def _resolve_url(self, path: str) -> str:
105
+ if path.startswith(("http://", "https://")):
106
+ return path
107
+ base = self._config.base_url
108
+ if base is None:
109
+ return path
110
+ return f"{base.rstrip('/')}/{path.lstrip('/')}"
111
+
112
+ def _build_request(
113
+ self,
114
+ method: str,
115
+ path: str,
116
+ *,
117
+ headers: Mapping[str, str] | None,
118
+ params: Mapping[str, str] | None,
119
+ cookies: Mapping[str, str] | None,
120
+ timeout: Timeout | float | None,
121
+ body: bytes | None,
122
+ content_type: str | None,
123
+ ) -> Request:
124
+ merged_headers: dict[str, str] = {**self._config.default_headers, **(headers or {})}
125
+ if content_type is not None and "content-type" not in {k.lower() for k in merged_headers}:
126
+ merged_headers["content-type"] = content_type
127
+ merged_params: dict[str, str] = {**self._config.default_query, **(params or {})}
128
+ extensions: dict[str, typing.Any] = {}
129
+ if timeout is not None:
130
+ extensions["timeout"] = _normalize_timeout(timeout)
131
+ return Request(
132
+ method=method,
133
+ url=self._resolve_url(path),
134
+ headers=merged_headers,
135
+ params=merged_params,
136
+ cookies=dict(cookies or {}),
137
+ body=body,
138
+ extensions=extensions,
139
+ )
140
+
141
+ async def _send(
142
+ self,
143
+ method: str,
144
+ path: str,
145
+ *,
146
+ headers: Mapping[str, str] | None,
147
+ params: Mapping[str, str] | None,
148
+ cookies: Mapping[str, str] | None,
149
+ timeout: Timeout | float | None,
150
+ body: bytes | None,
151
+ content_type: str | None,
152
+ response_model: type[T] | None,
153
+ ) -> Response | T:
154
+ request = self._build_request(
155
+ method,
156
+ path,
157
+ headers=headers,
158
+ params=params,
159
+ cookies=cookies,
160
+ timeout=timeout,
161
+ body=body,
162
+ content_type=content_type,
163
+ )
164
+ response = await self._dispatch(request)
165
+ if response_model is None:
166
+ return response
167
+ return self._config.decoder.decode(response.content, response_model)
168
+
169
+ @typing.overload
170
+ async def get(
171
+ self,
172
+ path: str,
173
+ *,
174
+ headers: Mapping[str, str] | None = None,
175
+ params: Mapping[str, str] | None = None,
176
+ cookies: Mapping[str, str] | None = None,
177
+ timeout: Timeout | float | None = None,
178
+ response_model: None = None,
179
+ ) -> Response: ...
180
+
181
+ @typing.overload
182
+ async def get(
183
+ self,
184
+ path: str,
185
+ *,
186
+ headers: Mapping[str, str] | None = None,
187
+ params: Mapping[str, str] | None = None,
188
+ cookies: Mapping[str, str] | None = None,
189
+ timeout: Timeout | float | None = None,
190
+ response_model: type[T],
191
+ ) -> T: ...
192
+
193
+ async def get(
194
+ self,
195
+ path: str,
196
+ *,
197
+ headers: Mapping[str, str] | None = None,
198
+ params: Mapping[str, str] | None = None,
199
+ cookies: Mapping[str, str] | None = None,
200
+ timeout: Timeout | float | None = None,
201
+ response_model: type[T] | None = None,
202
+ ) -> Response | T:
203
+ """Send a GET request."""
204
+ return await self._send(
205
+ "GET",
206
+ path,
207
+ headers=headers,
208
+ params=params,
209
+ cookies=cookies,
210
+ timeout=timeout,
211
+ body=None,
212
+ content_type=None,
213
+ response_model=response_model,
214
+ )
215
+
216
+ @typing.overload
217
+ async def post(
218
+ self,
219
+ path: str,
220
+ *,
221
+ headers: Mapping[str, str] | None = None,
222
+ params: Mapping[str, str] | None = None,
223
+ cookies: Mapping[str, str] | None = None,
224
+ timeout: Timeout | float | None = None,
225
+ json: JsonValue = None,
226
+ content: bytes | None = None,
227
+ response_model: None = None,
228
+ ) -> Response: ...
229
+
230
+ @typing.overload
231
+ async def post(
232
+ self,
233
+ path: str,
234
+ *,
235
+ headers: Mapping[str, str] | None = None,
236
+ params: Mapping[str, str] | None = None,
237
+ cookies: Mapping[str, str] | None = None,
238
+ timeout: Timeout | float | None = None,
239
+ json: JsonValue = None,
240
+ content: bytes | None = None,
241
+ response_model: type[T],
242
+ ) -> T: ...
243
+
244
+ async def post(
245
+ self,
246
+ path: str,
247
+ *,
248
+ headers: Mapping[str, str] | None = None,
249
+ params: Mapping[str, str] | None = None,
250
+ cookies: Mapping[str, str] | None = None,
251
+ timeout: Timeout | float | None = None,
252
+ json: JsonValue = None,
253
+ content: bytes | None = None,
254
+ response_model: type[T] | None = None,
255
+ ) -> Response | T:
256
+ """Send a POST request."""
257
+ body, content_type = _build_body(json, content)
258
+ return await self._send(
259
+ "POST",
260
+ path,
261
+ headers=headers,
262
+ params=params,
263
+ cookies=cookies,
264
+ timeout=timeout,
265
+ body=body,
266
+ content_type=content_type,
267
+ response_model=response_model,
268
+ )
269
+
270
+ @typing.overload
271
+ async def put(
272
+ self,
273
+ path: str,
274
+ *,
275
+ headers: Mapping[str, str] | None = None,
276
+ params: Mapping[str, str] | None = None,
277
+ cookies: Mapping[str, str] | None = None,
278
+ timeout: Timeout | float | None = None,
279
+ json: JsonValue = None,
280
+ content: bytes | None = None,
281
+ response_model: None = None,
282
+ ) -> Response: ...
283
+
284
+ @typing.overload
285
+ async def put(
286
+ self,
287
+ path: str,
288
+ *,
289
+ headers: Mapping[str, str] | None = None,
290
+ params: Mapping[str, str] | None = None,
291
+ cookies: Mapping[str, str] | None = None,
292
+ timeout: Timeout | float | None = None,
293
+ json: JsonValue = None,
294
+ content: bytes | None = None,
295
+ response_model: type[T],
296
+ ) -> T: ...
297
+
298
+ async def put(
299
+ self,
300
+ path: str,
301
+ *,
302
+ headers: Mapping[str, str] | None = None,
303
+ params: Mapping[str, str] | None = None,
304
+ cookies: Mapping[str, str] | None = None,
305
+ timeout: Timeout | float | None = None,
306
+ json: JsonValue = None,
307
+ content: bytes | None = None,
308
+ response_model: type[T] | None = None,
309
+ ) -> Response | T:
310
+ """Send a PUT request."""
311
+ body, content_type = _build_body(json, content)
312
+ return await self._send(
313
+ "PUT",
314
+ path,
315
+ headers=headers,
316
+ params=params,
317
+ cookies=cookies,
318
+ timeout=timeout,
319
+ body=body,
320
+ content_type=content_type,
321
+ response_model=response_model,
322
+ )
323
+
324
+ @typing.overload
325
+ async def patch(
326
+ self,
327
+ path: str,
328
+ *,
329
+ headers: Mapping[str, str] | None = None,
330
+ params: Mapping[str, str] | None = None,
331
+ cookies: Mapping[str, str] | None = None,
332
+ timeout: Timeout | float | None = None,
333
+ json: JsonValue = None,
334
+ content: bytes | None = None,
335
+ response_model: None = None,
336
+ ) -> Response: ...
337
+
338
+ @typing.overload
339
+ async def patch(
340
+ self,
341
+ path: str,
342
+ *,
343
+ headers: Mapping[str, str] | None = None,
344
+ params: Mapping[str, str] | None = None,
345
+ cookies: Mapping[str, str] | None = None,
346
+ timeout: Timeout | float | None = None,
347
+ json: JsonValue = None,
348
+ content: bytes | None = None,
349
+ response_model: type[T],
350
+ ) -> T: ...
351
+
352
+ async def patch(
353
+ self,
354
+ path: str,
355
+ *,
356
+ headers: Mapping[str, str] | None = None,
357
+ params: Mapping[str, str] | None = None,
358
+ cookies: Mapping[str, str] | None = None,
359
+ timeout: Timeout | float | None = None,
360
+ json: JsonValue = None,
361
+ content: bytes | None = None,
362
+ response_model: type[T] | None = None,
363
+ ) -> Response | T:
364
+ """Send a PATCH request."""
365
+ body, content_type = _build_body(json, content)
366
+ return await self._send(
367
+ "PATCH",
368
+ path,
369
+ headers=headers,
370
+ params=params,
371
+ cookies=cookies,
372
+ timeout=timeout,
373
+ body=body,
374
+ content_type=content_type,
375
+ response_model=response_model,
376
+ )
377
+
378
+ @typing.overload
379
+ async def delete(
380
+ self,
381
+ path: str,
382
+ *,
383
+ headers: Mapping[str, str] | None = None,
384
+ params: Mapping[str, str] | None = None,
385
+ cookies: Mapping[str, str] | None = None,
386
+ timeout: Timeout | float | None = None,
387
+ response_model: None = None,
388
+ ) -> Response: ...
389
+
390
+ @typing.overload
391
+ async def delete(
392
+ self,
393
+ path: str,
394
+ *,
395
+ headers: Mapping[str, str] | None = None,
396
+ params: Mapping[str, str] | None = None,
397
+ cookies: Mapping[str, str] | None = None,
398
+ timeout: Timeout | float | None = None,
399
+ response_model: type[T],
400
+ ) -> T: ...
401
+
402
+ async def delete(
403
+ self,
404
+ path: str,
405
+ *,
406
+ headers: Mapping[str, str] | None = None,
407
+ params: Mapping[str, str] | None = None,
408
+ cookies: Mapping[str, str] | None = None,
409
+ timeout: Timeout | float | None = None,
410
+ response_model: type[T] | None = None,
411
+ ) -> Response | T:
412
+ """Send a DELETE request."""
413
+ return await self._send(
414
+ "DELETE",
415
+ path,
416
+ headers=headers,
417
+ params=params,
418
+ cookies=cookies,
419
+ timeout=timeout,
420
+ body=None,
421
+ content_type=None,
422
+ response_model=response_model,
423
+ )
424
+
425
+ @typing.overload
426
+ async def head(
427
+ self,
428
+ path: str,
429
+ *,
430
+ headers: Mapping[str, str] | None = None,
431
+ params: Mapping[str, str] | None = None,
432
+ cookies: Mapping[str, str] | None = None,
433
+ timeout: Timeout | float | None = None,
434
+ response_model: None = None,
435
+ ) -> Response: ...
436
+
437
+ @typing.overload
438
+ async def head(
439
+ self,
440
+ path: str,
441
+ *,
442
+ headers: Mapping[str, str] | None = None,
443
+ params: Mapping[str, str] | None = None,
444
+ cookies: Mapping[str, str] | None = None,
445
+ timeout: Timeout | float | None = None,
446
+ response_model: type[T],
447
+ ) -> T: ...
448
+
449
+ async def head(
450
+ self,
451
+ path: str,
452
+ *,
453
+ headers: Mapping[str, str] | None = None,
454
+ params: Mapping[str, str] | None = None,
455
+ cookies: Mapping[str, str] | None = None,
456
+ timeout: Timeout | float | None = None,
457
+ response_model: type[T] | None = None,
458
+ ) -> Response | T:
459
+ """Send a HEAD request."""
460
+ return await self._send(
461
+ "HEAD",
462
+ path,
463
+ headers=headers,
464
+ params=params,
465
+ cookies=cookies,
466
+ timeout=timeout,
467
+ body=None,
468
+ content_type=None,
469
+ response_model=response_model,
470
+ )
471
+
472
+ @typing.overload
473
+ async def options(
474
+ self,
475
+ path: str,
476
+ *,
477
+ headers: Mapping[str, str] | None = None,
478
+ params: Mapping[str, str] | None = None,
479
+ cookies: Mapping[str, str] | None = None,
480
+ timeout: Timeout | float | None = None,
481
+ response_model: None = None,
482
+ ) -> Response: ...
483
+
484
+ @typing.overload
485
+ async def options(
486
+ self,
487
+ path: str,
488
+ *,
489
+ headers: Mapping[str, str] | None = None,
490
+ params: Mapping[str, str] | None = None,
491
+ cookies: Mapping[str, str] | None = None,
492
+ timeout: Timeout | float | None = None,
493
+ response_model: type[T],
494
+ ) -> T: ...
495
+
496
+ async def options(
497
+ self,
498
+ path: str,
499
+ *,
500
+ headers: Mapping[str, str] | None = None,
501
+ params: Mapping[str, str] | None = None,
502
+ cookies: Mapping[str, str] | None = None,
503
+ timeout: Timeout | float | None = None,
504
+ response_model: type[T] | None = None,
505
+ ) -> Response | T:
506
+ """Send an OPTIONS request."""
507
+ return await self._send(
508
+ "OPTIONS",
509
+ path,
510
+ headers=headers,
511
+ params=params,
512
+ cookies=cookies,
513
+ timeout=timeout,
514
+ body=None,
515
+ content_type=None,
516
+ response_model=response_model,
517
+ )
518
+
519
+ @typing.overload
520
+ async def request(
521
+ self,
522
+ method: str,
523
+ path: str,
524
+ *,
525
+ headers: Mapping[str, str] | None = None,
526
+ params: Mapping[str, str] | None = None,
527
+ cookies: Mapping[str, str] | None = None,
528
+ timeout: Timeout | float | None = None,
529
+ json: JsonValue = None,
530
+ content: bytes | None = None,
531
+ response_model: None = None,
532
+ ) -> Response: ...
533
+
534
+ @typing.overload
535
+ async def request(
536
+ self,
537
+ method: str,
538
+ path: str,
539
+ *,
540
+ headers: Mapping[str, str] | None = None,
541
+ params: Mapping[str, str] | None = None,
542
+ cookies: Mapping[str, str] | None = None,
543
+ timeout: Timeout | float | None = None,
544
+ json: JsonValue = None,
545
+ content: bytes | None = None,
546
+ response_model: type[T],
547
+ ) -> T: ...
548
+
549
+ async def request(
550
+ self,
551
+ method: str,
552
+ path: str,
553
+ *,
554
+ headers: Mapping[str, str] | None = None,
555
+ params: Mapping[str, str] | None = None,
556
+ cookies: Mapping[str, str] | None = None,
557
+ timeout: Timeout | float | None = None,
558
+ json: JsonValue = None,
559
+ content: bytes | None = None,
560
+ response_model: type[T] | None = None,
561
+ ) -> Response | T:
562
+ """Send a request with an arbitrary HTTP method."""
563
+ body, content_type = _build_body(json, content)
564
+ return await self._send(
565
+ method,
566
+ path,
567
+ headers=headers,
568
+ params=params,
569
+ cookies=cookies,
570
+ timeout=timeout,
571
+ body=body,
572
+ content_type=content_type,
573
+ response_model=response_model,
574
+ )
575
+
576
+ def with_options(
577
+ self,
578
+ *,
579
+ base_url: str | None = _UNSET,
580
+ default_headers: Mapping[str, str] | None = _UNSET,
581
+ default_query: Mapping[str, str] | None = _UNSET,
582
+ timeout: Timeout | float | None = _UNSET,
583
+ decoder: ResponseDecoder | None = _UNSET,
584
+ middleware: Sequence[Middleware] | None = _UNSET,
585
+ ) -> "AsyncClient":
586
+ """Return a new AsyncClient sharing the same transport with overridden config.
587
+
588
+ The returned client is a "view": it does NOT own the transport lifecycle.
589
+ Closing it via `async with` is a no-op. The original client should be the
590
+ one inside the outermost `async with` block.
591
+
592
+ `limits` and `transport` are NOT overridable here — both bind to the
593
+ transport, which is shared. Construct a fresh AsyncClient for those.
594
+ """
595
+ changes: dict[str, typing.Any] = {}
596
+ if base_url is not _UNSET:
597
+ changes["base_url"] = base_url
598
+ if default_headers is not _UNSET:
599
+ changes["default_headers"] = dict(default_headers or {})
600
+ if default_query is not _UNSET:
601
+ changes["default_query"] = dict(default_query or {})
602
+ if timeout is not _UNSET:
603
+ changes["timeout"] = _normalize_timeout(timeout)
604
+ if decoder is not _UNSET:
605
+ changes["decoder"] = decoder or PydanticDecoder()
606
+ if middleware is not _UNSET:
607
+ changes["middleware"] = tuple(middleware) if middleware is not None else ()
608
+
609
+ new_config = dataclasses.replace(self._config, **changes)
610
+ return AsyncClient._from_view(new_config, self._transport)
611
+
612
+ @classmethod
613
+ def _from_view(cls, config: ClientConfig, transport: Transport) -> "AsyncClient":
614
+ """Construct a view sharing an existing transport. Bypasses __init__."""
615
+ client = cls.__new__(cls)
616
+ client._config = config # noqa: SLF001
617
+ client._transport = transport # noqa: SLF001
618
+ client._dispatch = compose(config.middleware, transport) # noqa: SLF001
619
+ client._owns_transport = False # noqa: SLF001
620
+ return client
httpware/config.py ADDED
@@ -0,0 +1,40 @@
1
+ """Immutable configuration value types: Limits, Timeout, ClientConfig."""
2
+
3
+ from collections.abc import Mapping
4
+ from dataclasses import dataclass, field
5
+
6
+ from httpware.decoders import ResponseDecoder
7
+ from httpware.decoders.pydantic import PydanticDecoder
8
+ from httpware.middleware import Middleware
9
+
10
+
11
+ @dataclass(frozen=True, slots=True)
12
+ class Timeout:
13
+ """Per-phase request timeout configuration (seconds)."""
14
+
15
+ connect: float = 5.0
16
+ read: float = 30.0
17
+ write: float = 30.0
18
+ pool: float = 5.0
19
+
20
+
21
+ @dataclass(frozen=True, slots=True)
22
+ class Limits:
23
+ """Connection-pool limits."""
24
+
25
+ max_connections: int = 100
26
+ max_keepalive_connections: int = 20
27
+ keepalive_expiry: float = 5.0
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class ClientConfig:
32
+ """Immutable client configuration bag."""
33
+
34
+ base_url: str | None = None
35
+ default_headers: Mapping[str, str] = field(default_factory=dict)
36
+ default_query: Mapping[str, str] = field(default_factory=dict)
37
+ timeout: Timeout = field(default_factory=Timeout)
38
+ limits: Limits = field(default_factory=Limits)
39
+ decoder: ResponseDecoder = field(default_factory=PydanticDecoder)
40
+ middleware: tuple[Middleware, ...] = ()
@@ -0,0 +1,18 @@
1
+ """ResponseDecoder protocol — the AsyncClient ↔ ResponseDecoder seam (Seam 3)."""
2
+
3
+ from typing import Protocol, TypeVar, runtime_checkable
4
+
5
+
6
+ T = TypeVar("T")
7
+
8
+
9
+ @runtime_checkable
10
+ class ResponseDecoder(Protocol):
11
+ """Structural protocol every response-body decoder satisfies."""
12
+
13
+ def decode(self, content: bytes, model: type[T]) -> T:
14
+ """Decode `content` (raw response bytes) into an instance of `model`."""
15
+ ...
16
+
17
+
18
+ __all__ = ["ResponseDecoder"]
@@ -0,0 +1,32 @@
1
+ """MsgspecDecoder — opt-in ResponseDecoder backed by msgspec.json.decode."""
2
+
3
+ from typing import TypeVar
4
+
5
+ from httpware._internal import import_checker
6
+
7
+
8
+ if import_checker.is_msgspec_installed:
9
+ import msgspec
10
+
11
+
12
+ MISSING_DEPENDENCY_MESSAGE = "MsgspecDecoder requires the 'msgspec' extra. Install with: pip install httpware[msgspec]"
13
+
14
+ T = TypeVar("T")
15
+
16
+
17
+ class MsgspecDecoder:
18
+ """Decode raw response bytes via `msgspec.json.decode(content, type=model)`.
19
+
20
+ Requires the `msgspec` extra: `pip install httpware[msgspec]`. Importing
21
+ this module without the extra works (the `msgspec` import is guarded by a
22
+ `find_spec` check), but instantiating the decoder raises `ImportError` with
23
+ the install hint.
24
+ """
25
+
26
+ def __init__(self) -> None:
27
+ if not import_checker.is_msgspec_installed:
28
+ raise ImportError(MISSING_DEPENDENCY_MESSAGE)
29
+
30
+ def decode(self, content: bytes, model: type[T]) -> T:
31
+ """Validate `content` as JSON against `model` in a single parse pass."""
32
+ return msgspec.json.decode(content, type=model)