dhttpy 0.1.0__cp313-cp313-win_amd64.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.
dhttpy/__init__.py ADDED
@@ -0,0 +1,32 @@
1
+ """Python bindings for DHTTP."""
2
+
3
+ from . import _native
4
+ from .authority import LocalAuthority, RemoteAuthority
5
+ from .certificate import CertificateChainKey, DhttpSubjectKeyIdentifier
6
+ from .endpoint import Endpoint, QueryParams, ServerRequest, Service
7
+ from .response import ClientResponse, Headers, Response, StreamContent, json_response
8
+
9
+ DhttpHome = _native.DhttpHome
10
+ IdentityProfile = _native.IdentityProfile
11
+ Identity = _native.Identity
12
+ ServeHandle = _native.ServeHandle
13
+
14
+ __all__ = [
15
+ "CertificateChainKey",
16
+ "ClientResponse",
17
+ "DhttpHome",
18
+ "DhttpSubjectKeyIdentifier",
19
+ "Endpoint",
20
+ "Headers",
21
+ "Identity",
22
+ "IdentityProfile",
23
+ "LocalAuthority",
24
+ "QueryParams",
25
+ "RemoteAuthority",
26
+ "Response",
27
+ "ServeHandle",
28
+ "ServerRequest",
29
+ "Service",
30
+ "StreamContent",
31
+ "json_response",
32
+ ]
Binary file
dhttpy/authority.py ADDED
@@ -0,0 +1,8 @@
1
+ """Authority capability classes for Python bindings."""
2
+
3
+ from . import _native
4
+
5
+ LocalAuthority = _native.LocalAuthority
6
+ RemoteAuthority = _native.RemoteAuthority
7
+
8
+ __all__ = ["LocalAuthority", "RemoteAuthority"]
dhttpy/certificate.py ADDED
@@ -0,0 +1,8 @@
1
+ """Certificate metadata value classes for Python bindings."""
2
+
3
+ from . import _native
4
+
5
+ CertificateChainKey = _native.CertificateChainKey
6
+ DhttpSubjectKeyIdentifier = _native.DhttpSubjectKeyIdentifier
7
+
8
+ __all__ = ["CertificateChainKey", "DhttpSubjectKeyIdentifier"]
dhttpy/endpoint.py ADDED
@@ -0,0 +1,515 @@
1
+ """aiohttp-like endpoint wrapper for dhttp."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json as _json
7
+ from collections.abc import AsyncIterator, Awaitable, Callable, Iterable
8
+ from inspect import isawaitable
9
+ from typing import Any
10
+ from urllib.parse import parse_qsl, urlsplit
11
+
12
+ from . import _native, raw
13
+ from .response import (
14
+ ClientResponse,
15
+ HeaderInput,
16
+ Headers,
17
+ Response,
18
+ StreamContent,
19
+ has_body,
20
+ header_fields,
21
+ header_text,
22
+ normalize_headers,
23
+ )
24
+
25
+ BodyInput = bytes | bytearray | memoryview | str | Iterable[bytes] | AsyncIterator[bytes] | None
26
+ Handler = Callable[..., Response | bytes | str |
27
+ None | Awaitable[Response | bytes | str | None]]
28
+
29
+
30
+ def _path_with_query(url: str) -> str:
31
+ parts = urlsplit(url)
32
+ path = parts.path or "/"
33
+ if parts.query:
34
+ path = f"{path}?{parts.query}"
35
+ return path
36
+
37
+
38
+ def _authority(url: str) -> str:
39
+ parts = urlsplit(url)
40
+ if not parts.netloc:
41
+ raise ValueError("url must include an authority")
42
+ return parts.netloc
43
+
44
+
45
+ def _request_header_fields(method: str, url: str, headers: HeaderInput) -> list[raw.HeaderField]:
46
+ parts = urlsplit(url)
47
+ if not parts.scheme or not parts.netloc:
48
+ raise ValueError("url must include scheme and authority")
49
+ fields = [
50
+ raw.HeaderField(b":method", method.upper().encode("latin-1")),
51
+ raw.HeaderField(b":scheme", parts.scheme.encode("latin-1")),
52
+ raw.HeaderField(b":authority", parts.netloc.encode("latin-1")),
53
+ raw.HeaderField(b":path", _path_with_query(url).encode("latin-1")),
54
+ ]
55
+ fields.extend(raw.HeaderField(name, value) for name, value in header_fields(headers))
56
+ return fields
57
+
58
+
59
+ def _request_body_and_headers(
60
+ headers: HeaderInput,
61
+ data: BodyInput,
62
+ json: Any,
63
+ ) -> tuple[HeaderInput, BodyInput]:
64
+ if data is not None and json is not None:
65
+ raise ValueError("only one of data or json may be provided")
66
+
67
+ if json is None:
68
+ return headers, data
69
+
70
+ pairs = normalize_headers(headers)
71
+ if not any(name.lower() == "content-type" for name, _ in pairs):
72
+ pairs.append(("content-type", "application/json"))
73
+ return pairs, _json.dumps(json).encode("utf-8")
74
+
75
+
76
+ def _endpoint_options(
77
+ options: Any = None,
78
+ *,
79
+ identity: Any = None,
80
+ dns_schemes: Iterable[str] | None = None,
81
+ bind_patterns: Iterable[str] | None = None,
82
+ ):
83
+ has_keywords = identity is not None or dns_schemes is not None or bind_patterns is not None
84
+ if options is not None:
85
+ if has_keywords:
86
+ raise ValueError(
87
+ "pass either options or keyword configuration, not both")
88
+ return options
89
+ if not has_keywords:
90
+ return None
91
+
92
+ options = _native.EndpointOptions()
93
+ if identity is not None:
94
+ options.set_identity(identity)
95
+ for scheme in dns_schemes or ():
96
+ options.add_dns_scheme(scheme)
97
+ for pattern in bind_patterns or ():
98
+ options.add_bind_pattern(pattern)
99
+ return options
100
+
101
+
102
+ def _response_header_fields(response: Response) -> list[raw.HeaderField]:
103
+ fields = [raw.HeaderField(b":status", str(response.status).encode("latin-1"))]
104
+ fields.extend(
105
+ raw.HeaderField(name, value) for name, value in header_fields(response.headers.items())
106
+ )
107
+ return fields
108
+
109
+
110
+ def _field_parts(field: raw.HeaderField | tuple[bytes, bytes]) -> tuple[bytes, bytes]:
111
+ if isinstance(field, raw.HeaderField):
112
+ return field.name, field.value
113
+ return field
114
+
115
+
116
+ def _parse_response_header(fields: list[tuple[bytes, bytes]] | None) -> tuple[int, Headers]:
117
+ if fields is None:
118
+ raise RuntimeError("response header frame is missing")
119
+ status: int | None = None
120
+ headers: list[tuple[str, str]] = []
121
+ for field in fields:
122
+ name_bytes, value_bytes = _field_parts(field)
123
+ name = header_text(name_bytes).lower()
124
+ value = header_text(value_bytes)
125
+ if name == ":status":
126
+ status = int(value)
127
+ elif not name.startswith(":"):
128
+ headers.append((name, value))
129
+ if status is None:
130
+ raise RuntimeError("response status pseudo-header is missing")
131
+ return status, Headers(headers)
132
+
133
+
134
+ def _parse_request_header(fields: list[tuple[bytes, bytes]] | None) -> tuple[str, str, Headers]:
135
+ if fields is None:
136
+ raise RuntimeError("request header frame is missing")
137
+ pseudo: dict[str, str] = {}
138
+ headers: list[tuple[str, str]] = []
139
+ for field in fields:
140
+ name_bytes, value_bytes = _field_parts(field)
141
+ name = header_text(name_bytes).lower()
142
+ value = header_text(value_bytes)
143
+ if name.startswith(":"):
144
+ pseudo[name] = value
145
+ else:
146
+ headers.append((name, value))
147
+ method = pseudo.get(":method")
148
+ scheme = pseudo.get(":scheme")
149
+ authority = pseudo.get(":authority")
150
+ path = pseudo.get(":path")
151
+ if method is None or scheme is None or authority is None or path is None:
152
+ raise RuntimeError("request pseudo-headers are missing")
153
+ return method, f"{scheme}://{authority}{path}", Headers(headers)
154
+
155
+
156
+ async def _body_chunks(body: BodyInput):
157
+ if body is None:
158
+ return
159
+ if isinstance(body, str):
160
+ yield body.encode()
161
+ return
162
+ if isinstance(body, bytes | bytearray | memoryview):
163
+ yield bytes(body)
164
+ return
165
+ if hasattr(body, "__aiter__"):
166
+ async for chunk in body: # type: ignore[union-attr]
167
+ if chunk:
168
+ yield bytes(chunk)
169
+ return
170
+ for chunk in body: # type: ignore[union-attr]
171
+ if chunk:
172
+ yield bytes(chunk)
173
+
174
+
175
+ async def _write_body(write_stream: Any, body: BodyInput) -> None:
176
+ async for chunk in _body_chunks(body):
177
+ await write_stream.write_data(chunk)
178
+ await write_stream.close()
179
+
180
+
181
+ class QueryParams:
182
+ """Ordered case-sensitive query parameter view preserving duplicate keys."""
183
+
184
+ def __init__(self, pairs: Iterable[tuple[str, str]] = ()):
185
+ self._pairs = [(str(name), str(value)) for name, value in pairs]
186
+
187
+ @classmethod
188
+ def from_query_string(cls, query_string: str) -> "QueryParams":
189
+ return cls(parse_qsl(query_string, keep_blank_values=True))
190
+
191
+ def __iter__(self):
192
+ return iter(self._pairs)
193
+
194
+ def __len__(self) -> int:
195
+ return len(self._pairs)
196
+
197
+ def __contains__(self, name: object) -> bool:
198
+ if not isinstance(name, str):
199
+ return False
200
+ return any(param == name for param, _ in self._pairs)
201
+
202
+ def items(self) -> list[tuple[str, str]]:
203
+ return list(self._pairs)
204
+
205
+ def get(self, name: str, default: str | None = None) -> str | None:
206
+ for param, value in self._pairs:
207
+ if param == name:
208
+ return value
209
+ return default
210
+
211
+ def getall(self, name: str) -> list[str]:
212
+ return [value for param, value in self._pairs if param == name]
213
+
214
+ def __getitem__(self, name: str) -> str:
215
+ for param, value in self._pairs:
216
+ if param == name:
217
+ return value
218
+ raise KeyError(name)
219
+
220
+
221
+ class ServerRequest:
222
+ def __init__(
223
+ self,
224
+ read_stream: Any,
225
+ method: str,
226
+ url: str,
227
+ headers: Headers,
228
+ *,
229
+ authority: Any = None,
230
+ ):
231
+ self._read_stream = read_stream
232
+ self._body: bytes | None = None
233
+ self._released = False
234
+ self._authority = authority
235
+ self.content = StreamContent(read_stream)
236
+ self.method = method.upper()
237
+ self.url = url
238
+ self.headers = headers
239
+ parts = urlsplit(url)
240
+ self.scheme = parts.scheme
241
+ self.host = parts.netloc
242
+ self.path = parts.path or "/"
243
+ self.query_string = parts.query
244
+ self.query = QueryParams.from_query_string(parts.query)
245
+
246
+ def authority(self):
247
+ return self._authority
248
+
249
+ async def read(self) -> bytes:
250
+ if self._body is not None:
251
+ return self._body
252
+ if self._released:
253
+ return b""
254
+ body = await self.content.read()
255
+ self._released = True
256
+ self._body = body
257
+ return self._body
258
+
259
+ async def text(self, encoding: str = "utf-8") -> str:
260
+ return (await self.read()).decode(encoding)
261
+
262
+ async def json(self) -> Any:
263
+ return _json.loads(await self.text())
264
+
265
+ async def release(self) -> None:
266
+ if self._released:
267
+ return
268
+ self._released = True
269
+ try:
270
+ await self._read_stream.stop(0)
271
+ except RuntimeError:
272
+ pass
273
+
274
+
275
+ class _RequestContextManager:
276
+ def __init__(self, awaitable: Awaitable[ClientResponse]):
277
+ self._awaitable = awaitable
278
+ self._response: ClientResponse | None = None
279
+
280
+ def __await__(self):
281
+ return self._awaitable.__await__()
282
+
283
+ async def __aenter__(self) -> ClientResponse:
284
+ self._response = await self._awaitable
285
+ return self._response
286
+
287
+ async def __aexit__(self, exc_type, exc, traceback) -> None:
288
+ if self._response is not None:
289
+ await self._response.release()
290
+
291
+
292
+ class Endpoint:
293
+ def __init__(self, inner: Any):
294
+ self._inner = inner
295
+
296
+ @classmethod
297
+ async def create(
298
+ cls,
299
+ options: Any = None,
300
+ *,
301
+ identity: Any = None,
302
+ dns_schemes: Iterable[str] | None = None,
303
+ bind_patterns: Iterable[str] | None = None,
304
+ ) -> "Endpoint":
305
+ return cls(
306
+ await _native.Endpoint.create(
307
+ _endpoint_options(
308
+ options,
309
+ identity=identity,
310
+ dns_schemes=dns_schemes,
311
+ bind_patterns=bind_patterns,
312
+ )
313
+ )
314
+ )
315
+
316
+ @classmethod
317
+ async def load(cls, name: str) -> "Endpoint":
318
+ return cls(await _native.Endpoint.load(name))
319
+
320
+ @classmethod
321
+ async def load_from(cls, path: str) -> "Endpoint":
322
+ return cls(await _native.Endpoint.load_from(path))
323
+
324
+ def identity(self):
325
+ return self._inner.identity()
326
+
327
+ def bind_patterns(self) -> list[str]:
328
+ return self._inner.bind_patterns()
329
+
330
+ def request(
331
+ self,
332
+ method: str,
333
+ url: str,
334
+ *,
335
+ headers: HeaderInput = None,
336
+ data: BodyInput = None,
337
+ json: Any = None,
338
+ ) -> _RequestContextManager:
339
+ return _RequestContextManager(
340
+ self._request(method, url, headers=headers,
341
+ data=data, json=json)
342
+ )
343
+
344
+ def get(self, url: str, **kwargs: Any) -> _RequestContextManager:
345
+ return self.request("GET", url, **kwargs)
346
+
347
+ def post(self, url: str, **kwargs: Any) -> _RequestContextManager:
348
+ return self.request("POST", url, **kwargs)
349
+
350
+ def put(self, url: str, **kwargs: Any) -> _RequestContextManager:
351
+ return self.request("PUT", url, **kwargs)
352
+
353
+ def delete(self, url: str, **kwargs: Any) -> _RequestContextManager:
354
+ return self.request("DELETE", url, **kwargs)
355
+
356
+ def patch(self, url: str, **kwargs: Any) -> _RequestContextManager:
357
+ return self.request("PATCH", url, **kwargs)
358
+
359
+ def head(self, url: str, **kwargs: Any) -> _RequestContextManager:
360
+ return self.request("HEAD", url, **kwargs)
361
+
362
+ def options(self, url: str, **kwargs: Any) -> _RequestContextManager:
363
+ return self.request("OPTIONS", url, **kwargs)
364
+
365
+ def trace(self, url: str, **kwargs: Any) -> _RequestContextManager:
366
+ return self.request("TRACE", url, **kwargs)
367
+
368
+ async def connect(self, authority: str) -> raw.Connection:
369
+ return raw.Connection(await self._inner.connect(authority))
370
+
371
+ async def _request(
372
+ self,
373
+ method: str,
374
+ url: str,
375
+ *,
376
+ headers: HeaderInput,
377
+ data: BodyInput,
378
+ json: Any,
379
+ ) -> ClientResponse:
380
+ headers, body = _request_body_and_headers(headers, data, json)
381
+ connection = await self.connect(_authority(url))
382
+ request = await connection.open_request()
383
+ read_stream = request.reader
384
+ write_stream = request.writer
385
+ authority = await request.remote_authority()
386
+ upload_task: asyncio.Task[None] | None = None
387
+ try:
388
+ await write_stream.write_header(_request_header_fields(method, url, headers))
389
+ upload_task = asyncio.create_task(_write_body(write_stream, body))
390
+ await asyncio.sleep(0)
391
+ status, response_headers = _parse_response_header(await read_stream.read_header())
392
+ return ClientResponse(
393
+ read_stream,
394
+ status,
395
+ response_headers.items(),
396
+ method=method.upper(),
397
+ url=url,
398
+ authority=authority,
399
+ upload_task=upload_task,
400
+ )
401
+ except Exception:
402
+ if upload_task is not None:
403
+ upload_task.cancel()
404
+ try:
405
+ await read_stream.stop(0)
406
+ except Exception:
407
+ pass
408
+ try:
409
+ await write_stream.reset(0)
410
+ except Exception:
411
+ pass
412
+ raise
413
+
414
+ def listen(self, handler: Callable[[raw.UnresolvedRequest], Any]):
415
+ async def raw_handler(native_request: Any) -> None:
416
+ result = handler(raw.UnresolvedRequest(native_request))
417
+ if isawaitable(result):
418
+ result = await result
419
+ if result is not None:
420
+ raise TypeError(
421
+ "Endpoint.listen(function) receives raw UnresolvedRequest; "
422
+ "use dhttpy.Service for ServerRequest -> Response handlers"
423
+ )
424
+
425
+ return self._inner.listen_raw(raw_handler)
426
+
427
+
428
+ class Service:
429
+ def __init__(self):
430
+ self._routes: list[tuple[str | None, str, Handler]] = []
431
+ self._fallback: Handler = lambda _request: Response(status=404)
432
+
433
+ async def __call__(self, raw_request: raw.UnresolvedRequest) -> None:
434
+ reader = raw_request.reader
435
+ writer = raw_request.writer
436
+ request: ServerRequest | None = None
437
+ try:
438
+ method, url, headers = _parse_request_header(await reader.read_header())
439
+ authority = await raw_request.remote_authority()
440
+ request = ServerRequest(reader, method, url, headers, authority=authority)
441
+ handler = self._match(method, request.path)
442
+ result = handler(request)
443
+ if isawaitable(result):
444
+ result = await result
445
+ response = self._response_from_result(result)
446
+ await writer.write_header(_response_header_fields(response))
447
+ body = response.body if has_body(method, response.status) else None
448
+ await _write_body(writer, body)
449
+ except Exception:
450
+ try:
451
+ await writer.reset(0)
452
+ except Exception:
453
+ pass
454
+ raise
455
+ finally:
456
+ if request is not None:
457
+ await request.release()
458
+ else:
459
+ try:
460
+ await reader.stop(0)
461
+ except Exception:
462
+ pass
463
+
464
+ def _match(self, method: str, path: str) -> Handler:
465
+ method = method.upper()
466
+ for route_method, route_path, handler in self._routes:
467
+ if route_path == path and (route_method is None or route_method == method):
468
+ return handler
469
+ return self._fallback
470
+
471
+ def _response_from_result(self, result: Response | bytes | str | None) -> Response:
472
+ if isinstance(result, Response):
473
+ return result
474
+ if result is None or isinstance(result, bytes | str):
475
+ return Response(result)
476
+ raise TypeError(f"handler returned unsupported response type {type(result).__name__}")
477
+
478
+ def route(self, path: str, handler: Handler) -> "Service":
479
+ self._routes.append((None, path, handler))
480
+ return self
481
+
482
+ def on(self, method: str, path: str, handler: Handler) -> "Service":
483
+ self._routes.append((method.upper(), path, handler))
484
+ return self
485
+
486
+ def fallback(self, handler: Handler) -> "Service":
487
+ self._fallback = handler
488
+ return self
489
+
490
+ def options(self, path: str, handler: Handler) -> "Service":
491
+ return self.on("OPTIONS", path, handler)
492
+
493
+ def get(self, path: str, handler: Handler) -> "Service":
494
+ return self.on("GET", path, handler)
495
+
496
+ def post(self, path: str, handler: Handler) -> "Service":
497
+ return self.on("POST", path, handler)
498
+
499
+ def put(self, path: str, handler: Handler) -> "Service":
500
+ return self.on("PUT", path, handler)
501
+
502
+ def delete(self, path: str, handler: Handler) -> "Service":
503
+ return self.on("DELETE", path, handler)
504
+
505
+ def head(self, path: str, handler: Handler) -> "Service":
506
+ return self.on("HEAD", path, handler)
507
+
508
+ def trace(self, path: str, handler: Handler) -> "Service":
509
+ return self.on("TRACE", path, handler)
510
+
511
+ def connect(self, path: str, handler: Handler) -> "Service":
512
+ return self.on("CONNECT", path, handler)
513
+
514
+ def patch(self, path: str, handler: Handler) -> "Service":
515
+ return self.on("PATCH", path, handler)
dhttpy/raw.py ADDED
@@ -0,0 +1,120 @@
1
+ """Raw DHTTP message primitives for Python bindings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ from . import _native
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class HeaderField:
14
+ name: bytes
15
+ value: bytes
16
+
17
+ def __init__(self, name: Any, value: Any):
18
+ object.__setattr__(self, "name", _bytes(name))
19
+ object.__setattr__(self, "value", _bytes(value))
20
+
21
+
22
+ def _bytes(value: Any) -> bytes:
23
+ if isinstance(value, bytes):
24
+ return value
25
+ if isinstance(value, bytearray | memoryview):
26
+ return bytes(value)
27
+ return bytes(value)
28
+
29
+
30
+ def _field_from_native(field: Any) -> HeaderField:
31
+ if isinstance(field, HeaderField):
32
+ return field
33
+ if hasattr(field, "name") and hasattr(field, "value"):
34
+ return HeaderField(field.name, field.value)
35
+ name, value = field
36
+ return HeaderField(name, value)
37
+
38
+
39
+ def _field_pair(field: HeaderField | tuple[Any, Any]) -> tuple[bytes, bytes]:
40
+ if isinstance(field, HeaderField):
41
+ return (field.name, field.value)
42
+ name, value = field
43
+ return (_bytes(name), _bytes(value))
44
+
45
+
46
+ class MessageReader:
47
+ def __init__(self, inner: Any):
48
+ self._inner = inner
49
+
50
+ async def read_header(self) -> list[HeaderField] | None:
51
+ headers = await self._inner.read_header()
52
+ if headers is None:
53
+ return None
54
+ return [_field_from_native(field) for field in headers]
55
+
56
+ async def read_data(self) -> bytes | None:
57
+ chunk = await self._inner.read_data()
58
+ if chunk is None:
59
+ return None
60
+ return _bytes(chunk)
61
+
62
+ async def stop(self, code: int) -> None:
63
+ await self._inner.stop(code)
64
+
65
+
66
+ class MessageWriter:
67
+ def __init__(self, inner: Any):
68
+ self._inner = inner
69
+
70
+ async def write_header(self, headers: Iterable[HeaderField | tuple[Any, Any]]) -> None:
71
+ await self._inner.write_header([_field_pair(field) for field in headers])
72
+
73
+ async def write_data(self, data: Any) -> None:
74
+ await self._inner.write_data(_bytes(data))
75
+
76
+ async def flush(self) -> None:
77
+ await self._inner.flush()
78
+
79
+ async def close(self) -> None:
80
+ await self._inner.close()
81
+
82
+ async def reset(self, code: int) -> None:
83
+ await self._inner.reset(code)
84
+
85
+
86
+ class UnresolvedRequest:
87
+ def __init__(self, inner: Any):
88
+ self._inner = inner
89
+ self.stream_id = inner.stream_id
90
+ self.reader = MessageReader(inner.reader)
91
+ self.writer = MessageWriter(inner.writer)
92
+
93
+ async def local_authority(self):
94
+ result = self._inner.local_authority()
95
+ if hasattr(result, "__await__"):
96
+ return await result
97
+ return result
98
+
99
+ async def remote_authority(self):
100
+ result = self._inner.remote_authority()
101
+ if hasattr(result, "__await__"):
102
+ return await result
103
+ return result
104
+
105
+
106
+ class Connection:
107
+ def __init__(self, inner: Any):
108
+ self._inner = inner
109
+
110
+ async def open_request(self) -> UnresolvedRequest:
111
+ return UnresolvedRequest(await self._inner.open_request())
112
+
113
+ async def local_authority(self):
114
+ return await self._inner.local_authority()
115
+
116
+ async def remote_authority(self):
117
+ return await self._inner.remote_authority()
118
+
119
+
120
+ __all__ = ["Connection", "HeaderField", "MessageReader", "MessageWriter", "UnresolvedRequest"]