vercel-queue 0.7.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.
Files changed (43) hide show
  1. vercel/queue/__init__.py +8 -0
  2. vercel/queue/__main__.py +10 -0
  3. vercel/queue/_internal/__init__.py +1 -0
  4. vercel/queue/_internal/api_async.py +219 -0
  5. vercel/queue/_internal/api_common.py +104 -0
  6. vercel/queue/_internal/api_sync.py +137 -0
  7. vercel/queue/_internal/asgi.py +145 -0
  8. vercel/queue/_internal/asynctools.py +40 -0
  9. vercel/queue/_internal/cli.py +313 -0
  10. vercel/queue/_internal/client.py +1109 -0
  11. vercel/queue/_internal/client_sync.py +432 -0
  12. vercel/queue/_internal/config.py +160 -0
  13. vercel/queue/_internal/constants.py +50 -0
  14. vercel/queue/_internal/devserver.py +205 -0
  15. vercel/queue/_internal/embedded.py +1900 -0
  16. vercel/queue/_internal/errors.py +262 -0
  17. vercel/queue/_internal/http.py +634 -0
  18. vercel/queue/_internal/lease.py +1007 -0
  19. vercel/queue/_internal/log.py +143 -0
  20. vercel/queue/_internal/messages.py +122 -0
  21. vercel/queue/_internal/multipart.py +255 -0
  22. vercel/queue/_internal/names.py +101 -0
  23. vercel/queue/_internal/polling.py +200 -0
  24. vercel/queue/_internal/push.py +246 -0
  25. vercel/queue/_internal/response.py +111 -0
  26. vercel/queue/_internal/retry.py +86 -0
  27. vercel/queue/_internal/streams.py +313 -0
  28. vercel/queue/_internal/subscribers.py +1025 -0
  29. vercel/queue/_internal/transports.py +363 -0
  30. vercel/queue/_internal/types.py +300 -0
  31. vercel/queue/_internal/typeutils.py +203 -0
  32. vercel/queue/devserver.py +24 -0
  33. vercel/queue/embedded.py +50 -0
  34. vercel/queue/py.typed +1 -0
  35. vercel/queue/sync.py +8 -0
  36. vercel/queue/testing/__init__.py +14 -0
  37. vercel/queue/testing/pytest.py +42 -0
  38. vercel/queue/testing/state.py +32 -0
  39. vercel/queue/version.py +3 -0
  40. vercel_queue-0.7.0.dist-info/METADATA +681 -0
  41. vercel_queue-0.7.0.dist-info/RECORD +43 -0
  42. vercel_queue-0.7.0.dist-info/WHEEL +4 -0
  43. vercel_queue-0.7.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,634 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Any, Generic, Protocol, TypeAlias, TypeVar, runtime_checkable
4
+
5
+ import contextlib
6
+ import logging
7
+ import warnings
8
+ from collections.abc import (
9
+ AsyncIterable,
10
+ AsyncIterator,
11
+ Callable,
12
+ Iterable,
13
+ Iterator,
14
+ Mapping,
15
+ )
16
+ from contextlib import AbstractAsyncContextManager, AbstractContextManager
17
+ from dataclasses import dataclass
18
+ from datetime import datetime, timezone
19
+ from email.utils import parsedate_to_datetime
20
+ from threading import Lock
21
+ from urllib.parse import urljoin, urlsplit
22
+
23
+ import httpx
24
+ from anyio.lowlevel import get_async_backend
25
+
26
+ from .asynctools import iter_bytes_async
27
+ from .config import resolve_token, resolve_token_async
28
+ from .constants import HEADER_RETRY_AFTER
29
+ from .errors import CommunicationError
30
+ from .log import content_type, debug_enabled, debug_log, redact_text, safe_header_names, safe_url
31
+ from .types import Duration, Headers, RawHeaders, RequestContent, duration_to_float_seconds
32
+
33
+ _httpx_logger = logging.getLogger("httpx")
34
+
35
+
36
+ @dataclass
37
+ class _HttpxFilterState:
38
+ lock: Lock
39
+ installed: bool = False
40
+
41
+
42
+ _httpx_filter_state = _HttpxFilterState(lock=Lock())
43
+
44
+
45
+ class BaseHttpResponse(Protocol):
46
+ @property
47
+ def headers(self) -> Mapping[str, str]: ...
48
+
49
+
50
+ @runtime_checkable
51
+ class AsyncHttpMessage(BaseHttpResponse, Protocol):
52
+ """Minimal async HTTP message surface for queue push deliveries."""
53
+
54
+ @property
55
+ def headers(self) -> Mapping[str, str]: ...
56
+
57
+ def aiter_bytes(self, chunk_size: int | None = None) -> AsyncIterator[bytes]: ...
58
+
59
+
60
+ @runtime_checkable
61
+ class AsyncHttpResponse(AsyncHttpMessage, Protocol):
62
+ """Minimal async HTTP response surface used by the queue client."""
63
+
64
+ @property
65
+ def status_code(self) -> int: ...
66
+
67
+ @property
68
+ def text(self) -> str: ...
69
+
70
+ def json(self) -> Any: ...
71
+
72
+
73
+ @runtime_checkable
74
+ class HttpResponse(BaseHttpResponse, Protocol):
75
+ """Minimal sync HTTP response interface."""
76
+
77
+ def iter_bytes(self, chunk_size: int | None = None) -> Iterator[bytes]: ...
78
+
79
+
80
+ class SyncHttpResponse(HttpResponse, Protocol):
81
+ """Minimal sync HTTP response surface used by the queue client."""
82
+
83
+ @property
84
+ def status_code(self) -> int: ...
85
+
86
+ @property
87
+ def text(self) -> str: ...
88
+
89
+ def json(self) -> Any: ...
90
+
91
+
92
+ PushDeliveryBody: TypeAlias = bytes | Iterable[bytes] | AsyncIterable[bytes]
93
+ AsyncPushDeliveryBody: TypeAlias = bytes | AsyncIterable[bytes]
94
+ PushDeliveryInput: TypeAlias = PushDeliveryBody | HttpResponse
95
+ AsyncPushDeliveryInput: TypeAlias = AsyncPushDeliveryBody | AsyncHttpMessage
96
+
97
+
98
+ class SyncHttpClient(Protocol):
99
+ def request(
100
+ self,
101
+ method: str,
102
+ url: str,
103
+ *,
104
+ headers: Mapping[str, str],
105
+ content: RequestContent | None = None,
106
+ json: object | None = None,
107
+ timeout: float | None = None,
108
+ ) -> SyncHttpResponse: ...
109
+
110
+ def stream(
111
+ self,
112
+ method: str,
113
+ url: str,
114
+ *,
115
+ headers: Mapping[str, str],
116
+ timeout: float | None = None,
117
+ ) -> AbstractContextManager[SyncHttpResponse]: ...
118
+
119
+ def close(self) -> None: ...
120
+
121
+
122
+ class AsyncHttpClient(Protocol):
123
+ async def request(
124
+ self,
125
+ method: str,
126
+ url: str,
127
+ *,
128
+ headers: Mapping[str, str],
129
+ content: RequestContent | None = None,
130
+ json: object | None = None,
131
+ timeout: float | None = None, # noqa: ASYNC109
132
+ ) -> AsyncHttpResponse: ...
133
+
134
+ def stream(
135
+ self,
136
+ method: str,
137
+ url: str,
138
+ *,
139
+ headers: Mapping[str, str],
140
+ timeout: float | None = None,
141
+ ) -> AbstractAsyncContextManager[AsyncHttpResponse]: ...
142
+
143
+ async def aclose(self) -> None: ...
144
+
145
+
146
+ SyncHttpClientFactory: TypeAlias = Callable[..., SyncHttpClient]
147
+ AsyncHttpClientFactory: TypeAlias = Callable[..., AsyncHttpClient]
148
+ _ClientT = TypeVar("_ClientT", bound=SyncHttpClient | AsyncHttpClient)
149
+
150
+
151
+ @dataclass(kw_only=True)
152
+ class _HttpClientPoolEntry(Generic[_ClientT]):
153
+ client: _ClientT
154
+ in_use: int = 0
155
+
156
+
157
+ class _HttpClientPool(Generic[_ClientT]):
158
+ def __init__(self, *, max_size: int | None = 32) -> None:
159
+ if max_size is not None and max_size < 1:
160
+ raise ValueError("max_size must be positive or None")
161
+ self._max_size = max_size
162
+ self._clients: dict[object, _HttpClientPoolEntry[_ClientT]] = {}
163
+ self._lru: list[object] = []
164
+ self._lock = Lock()
165
+
166
+ def acquire(self, key: object, factory: Callable[[], _ClientT]) -> _ClientT:
167
+ with self._lock:
168
+ entry = self._clients.get(key)
169
+ if entry is None:
170
+ entry = _HttpClientPoolEntry(client=factory())
171
+ self._clients[key] = entry
172
+ self._lru.append(key)
173
+ else:
174
+ self._mark_recent(key)
175
+ entry.in_use += 1
176
+ return entry.client
177
+
178
+ def release(self, key: object) -> list[_ClientT]:
179
+ with self._lock:
180
+ entry = self._clients.get(key)
181
+ if entry is None:
182
+ raise RuntimeError("HTTP client pool entry was released after eviction")
183
+ if entry.in_use <= 0:
184
+ raise RuntimeError("HTTP client pool entry was released too many times")
185
+ entry.in_use -= 1
186
+ if entry.in_use == 0:
187
+ self._mark_recent(key)
188
+ return self._evict_if_needed_locked()
189
+
190
+ def reset(self) -> list[_ClientT]:
191
+ with self._lock:
192
+ clients = [entry.client for entry in self._clients.values()]
193
+ self._clients.clear()
194
+ self._lru.clear()
195
+ return clients
196
+
197
+ def _mark_recent(self, key: object) -> None:
198
+ try:
199
+ self._lru.remove(key)
200
+ except ValueError:
201
+ pass
202
+ self._lru.append(key)
203
+
204
+ def _evict_if_needed_locked(self) -> list[_ClientT]:
205
+ evicted: list[_ClientT] = []
206
+ if self._max_size is None:
207
+ return evicted
208
+ while len(self._clients) > self._max_size:
209
+ for key in list(self._lru):
210
+ entry = self._clients[key]
211
+ if entry.in_use > 0:
212
+ continue
213
+ self._lru.remove(key)
214
+ self._clients.pop(key, None)
215
+ evicted.append(entry.client)
216
+ break
217
+ else:
218
+ warnings.warn(
219
+ "queue HTTP client pool is over capacity because all clients are active",
220
+ ResourceWarning,
221
+ stacklevel=3,
222
+ )
223
+ break
224
+ return evicted
225
+
226
+
227
+ _sync_http_client_pool: _HttpClientPool[SyncHttpClient] = _HttpClientPool()
228
+ _async_http_client_pool: _HttpClientPool[AsyncHttpClient] = _HttpClientPool()
229
+
230
+
231
+ def _sync_factory_key(factory: SyncHttpClientFactory | None) -> object:
232
+ return factory or httpx.Client
233
+
234
+
235
+ def _async_factory_key(factory: AsyncHttpClientFactory | None) -> object:
236
+ return factory or httpx.AsyncClient
237
+
238
+
239
+ def _async_backend_key() -> object:
240
+ return get_async_backend().current_token()
241
+
242
+
243
+ def reset_http_client_pools_for_tests() -> None:
244
+ for client in _sync_http_client_pool.reset():
245
+ client.close()
246
+
247
+
248
+ async def reset_async_http_client_pool_for_tests() -> None:
249
+ for client in _async_http_client_pool.reset():
250
+ try:
251
+ await client.aclose()
252
+ except RuntimeError as exc:
253
+ if not _is_async_client_close_context_error(exc):
254
+ raise
255
+
256
+
257
+ def _is_async_client_close_context_error(exc: RuntimeError) -> bool:
258
+ message = str(exc)
259
+ return (
260
+ "Event loop is closed" in message
261
+ or "must be called from async context" in message
262
+ or "no running event loop" in message
263
+ or "handler is closed" in message
264
+ )
265
+
266
+
267
+ def headers_from_raw(headers: RawHeaders) -> Headers:
268
+ if isinstance(headers, httpx.Headers):
269
+ return headers
270
+ return httpx.Headers(headers)
271
+
272
+
273
+ class _SyncResponse:
274
+ def __init__(self, response: SyncHttpResponse) -> None:
275
+ self._response = response
276
+
277
+ @property
278
+ def status_code(self) -> int:
279
+ return self._response.status_code
280
+
281
+ @property
282
+ def headers(self) -> Mapping[str, str]:
283
+ return self._response.headers
284
+
285
+ @property
286
+ def text(self) -> str:
287
+ return self._response.text
288
+
289
+ def aiter_bytes(self, chunk_size: int | None = None) -> AsyncIterator[bytes]:
290
+ return iter_bytes_async(self._response.iter_bytes(chunk_size=chunk_size))
291
+
292
+ def json(self) -> Any:
293
+ return self._response.json()
294
+
295
+
296
+ async def response_text(response: AsyncHttpResponse) -> str:
297
+ try:
298
+ return response.text
299
+ except httpx.ResponseNotRead:
300
+ body = bytearray()
301
+ async for chunk in response.aiter_bytes():
302
+ body.extend(chunk)
303
+ return body.decode("utf-8", errors="replace")
304
+
305
+
306
+ def parse_retry_after(response: AsyncHttpResponse) -> int | None:
307
+ value = response.headers.get(HEADER_RETRY_AFTER)
308
+ if not value:
309
+ return None
310
+ try:
311
+ parsed = int(value)
312
+ except (TypeError, ValueError):
313
+ try:
314
+ retry_at = parsedate_to_datetime(value)
315
+ except (TypeError, ValueError):
316
+ return None
317
+ if retry_at.tzinfo is None:
318
+ retry_at = retry_at.replace(tzinfo=timezone.utc)
319
+ delay = (retry_at.astimezone(timezone.utc) - datetime.now(timezone.utc)).total_seconds()
320
+ return max(1, int(delay))
321
+ return max(1, parsed) if parsed >= 0 else None
322
+
323
+
324
+ def response_headers(response: BaseHttpResponse) -> Mapping[str, str]:
325
+ headers = response.headers
326
+ if isinstance(headers, httpx.Headers):
327
+ return headers
328
+ return headers_from_raw(headers)
329
+
330
+
331
+ class BaseQueueRuntime:
332
+ def __init__(self, *, timeout: Duration | None) -> None:
333
+ _install_httpx_request_log_filter()
334
+ self.timeout = None if timeout is None else duration_to_float_seconds(timeout)
335
+ if self.timeout is not None and self.timeout < 0:
336
+ raise ValueError("timeout must be non-negative")
337
+ self._base_url: str | None = None
338
+
339
+ def configure_base_url(self, base_url: str) -> None:
340
+ self._base_url = base_url.rstrip("/")
341
+
342
+ def _resolve_runtime_url(self, url: str) -> str:
343
+ parsed = urlsplit(url)
344
+ if parsed.scheme and parsed.netloc:
345
+ return url
346
+ if url.startswith("/"):
347
+ if self._base_url is None:
348
+ raise RuntimeError("queue runtime base URL is not configured")
349
+ return urljoin(self._base_url + "/", url.lstrip("/"))
350
+ raise ValueError("queue runtime HTTP URLs must be absolute or root-relative")
351
+
352
+ async def token(self, token: str | None) -> str:
353
+ raise NotImplementedError
354
+
355
+ async def _request(self, method: str, url: str, **kwargs: Any) -> AsyncHttpResponse:
356
+ raise NotImplementedError
357
+
358
+ async def request(
359
+ self,
360
+ method: str,
361
+ url: str,
362
+ *,
363
+ headers: Mapping[str, str],
364
+ content: RequestContent | None = None,
365
+ json: object | None = None,
366
+ ) -> AsyncHttpResponse:
367
+ return await self._request(
368
+ method=method,
369
+ url=self._resolve_runtime_url(url),
370
+ content=content,
371
+ json=json,
372
+ headers=headers,
373
+ )
374
+
375
+ async def get(self, url: str, *, headers: Mapping[str, str]) -> AsyncHttpResponse:
376
+ return await self.request("GET", url, headers=headers)
377
+
378
+ async def post(
379
+ self,
380
+ url: str,
381
+ *,
382
+ headers: Mapping[str, str],
383
+ content: RequestContent | None = None,
384
+ json: object | None = None,
385
+ ) -> AsyncHttpResponse:
386
+ return await self.request(
387
+ method="POST",
388
+ url=url,
389
+ content=content,
390
+ json=json,
391
+ headers=headers,
392
+ )
393
+
394
+ def stream_post(
395
+ self,
396
+ url: str,
397
+ *,
398
+ headers: Mapping[str, str],
399
+ ) -> AbstractAsyncContextManager[AsyncHttpResponse]:
400
+ return self.stream("POST", url, headers=headers)
401
+
402
+ def stream(
403
+ self,
404
+ method: str,
405
+ url: str,
406
+ *,
407
+ headers: Mapping[str, str],
408
+ ) -> AbstractAsyncContextManager[AsyncHttpResponse]:
409
+ raise NotImplementedError
410
+
411
+ async def delete(self, url: str, *, headers: Mapping[str, str]) -> AsyncHttpResponse:
412
+ return await self.request(method="DELETE", url=url, headers=headers)
413
+
414
+ async def patch(
415
+ self,
416
+ url: str,
417
+ *,
418
+ headers: Mapping[str, str],
419
+ json: object,
420
+ ) -> AsyncHttpResponse:
421
+ return await self.request(method="PATCH", url=url, json=json, headers=headers)
422
+
423
+
424
+ class SyncQueueRuntime(BaseQueueRuntime):
425
+ def __init__(
426
+ self,
427
+ *,
428
+ timeout: Duration | None,
429
+ client_factory: SyncHttpClientFactory | None = None,
430
+ ) -> None:
431
+ super().__init__(timeout=timeout)
432
+ self._client_factory = client_factory
433
+ self._client_key = _sync_factory_key(client_factory)
434
+
435
+ def _acquire_client(self) -> SyncHttpClient:
436
+ factory = self._client_factory or httpx.Client
437
+ return _sync_http_client_pool.acquire(
438
+ self._client_key,
439
+ factory,
440
+ )
441
+
442
+ def _release_client(self) -> None:
443
+ for client in _sync_http_client_pool.release(self._client_key):
444
+ client.close()
445
+
446
+ async def token(self, token: str | None) -> str:
447
+ return resolve_token(token)
448
+
449
+ async def _request(self, method: str, url: str, **kwargs: Any) -> AsyncHttpResponse:
450
+ _log_http_request(method, url, self.timeout, "request", kwargs.get("headers", {}))
451
+ kwargs["timeout"] = self.timeout
452
+ client = self._acquire_client()
453
+ try:
454
+ response = client.request(method, url, **kwargs)
455
+ except httpx.TransportError as exc:
456
+ _log_http_error(method, url, exc)
457
+ raise CommunicationError(str(exc)) from exc
458
+ finally:
459
+ self._release_client()
460
+ _log_http_response(method, url, response.status_code, response.headers)
461
+ return _SyncResponse(response)
462
+
463
+ @contextlib.asynccontextmanager
464
+ async def stream(
465
+ self,
466
+ method: str,
467
+ url: str,
468
+ *,
469
+ headers: Mapping[str, str],
470
+ ) -> AsyncIterator[AsyncHttpResponse]:
471
+ resolved_url = self._resolve_runtime_url(url)
472
+ _log_http_request(method, resolved_url, self.timeout, "stream", headers)
473
+ client = self._acquire_client()
474
+ try:
475
+ with client.stream(
476
+ method,
477
+ resolved_url,
478
+ headers=headers,
479
+ timeout=self.timeout,
480
+ ) as response:
481
+ _log_http_response(method, resolved_url, response.status_code, response.headers)
482
+ yield _SyncResponse(response)
483
+ except httpx.TransportError as exc:
484
+ _log_http_error(method, resolved_url, exc)
485
+ raise CommunicationError(str(exc)) from exc
486
+ finally:
487
+ self._release_client()
488
+
489
+
490
+ class AsyncQueueRuntime(BaseQueueRuntime):
491
+ def __init__(
492
+ self,
493
+ *,
494
+ timeout: Duration | None,
495
+ client_factory: AsyncHttpClientFactory | None = None,
496
+ ) -> None:
497
+ super().__init__(timeout=timeout)
498
+ self._client_factory = client_factory
499
+
500
+ def _client_key(self) -> tuple[object, object]:
501
+ return (_async_backend_key(), _async_factory_key(self._client_factory))
502
+
503
+ def _acquire_client(self) -> tuple[object, AsyncHttpClient]:
504
+ key = self._client_key()
505
+ factory = self._client_factory or httpx.AsyncClient
506
+ return key, _async_http_client_pool.acquire(
507
+ key,
508
+ factory,
509
+ )
510
+
511
+ async def _release_client(self, key: object) -> None:
512
+ for client in _async_http_client_pool.release(key):
513
+ try:
514
+ await client.aclose()
515
+ except RuntimeError as exc:
516
+ if not _is_async_client_close_context_error(exc):
517
+ raise
518
+
519
+ async def token(self, token: str | None) -> str:
520
+ return await resolve_token_async(token)
521
+
522
+ async def _request(self, method: str, url: str, **kwargs: Any) -> AsyncHttpResponse:
523
+ _log_http_request(method, url, self.timeout, "request", kwargs.get("headers", {}))
524
+ kwargs["timeout"] = self.timeout
525
+ key, client = self._acquire_client()
526
+ try:
527
+ response = await client.request(method, url, **kwargs)
528
+ except httpx.TransportError as exc:
529
+ _log_http_error(method, url, exc)
530
+ raise CommunicationError(str(exc)) from exc
531
+ finally:
532
+ await self._release_client(key)
533
+ _log_http_response(method, url, response.status_code, response.headers)
534
+ return response
535
+
536
+ @contextlib.asynccontextmanager
537
+ async def stream(
538
+ self,
539
+ method: str,
540
+ url: str,
541
+ *,
542
+ headers: Mapping[str, str],
543
+ ) -> AsyncIterator[AsyncHttpResponse]:
544
+ resolved_url = self._resolve_runtime_url(url)
545
+ _log_http_request(method, resolved_url, self.timeout, "stream", headers)
546
+ key, client = self._acquire_client()
547
+ try:
548
+ async with client.stream(
549
+ method,
550
+ resolved_url,
551
+ headers=headers,
552
+ timeout=self.timeout,
553
+ ) as response:
554
+ _log_http_response(method, resolved_url, response.status_code, response.headers)
555
+ yield response
556
+ except httpx.TransportError as exc:
557
+ _log_http_error(method, resolved_url, exc)
558
+ raise CommunicationError(str(exc)) from exc
559
+ finally:
560
+ await self._release_client(key)
561
+
562
+
563
+ def _log_http_request(
564
+ method: str,
565
+ url: str,
566
+ timeout: float | None,
567
+ kind: str,
568
+ headers: object,
569
+ ) -> None:
570
+ if not debug_enabled():
571
+ return
572
+ debug_log(
573
+ "http.request",
574
+ method=method,
575
+ url=safe_url(url),
576
+ timeout=timeout,
577
+ kind=kind,
578
+ headers=safe_header_names(headers) if isinstance(headers, Mapping) else [],
579
+ )
580
+
581
+
582
+ def _log_http_response(
583
+ method: str,
584
+ url: str,
585
+ status_code: int,
586
+ headers: Mapping[str, str],
587
+ ) -> None:
588
+ if not debug_enabled():
589
+ return
590
+ debug_log(
591
+ "http.response",
592
+ method=method,
593
+ url=safe_url(url),
594
+ status_code=status_code,
595
+ content_type=content_type(headers),
596
+ )
597
+
598
+
599
+ def _log_http_error(method: str, url: str, exc: BaseException) -> None:
600
+ if not debug_enabled():
601
+ return
602
+ debug_log(
603
+ "http.error",
604
+ method=method,
605
+ url=safe_url(url),
606
+ exception_class=exc.__class__.__name__,
607
+ exception_message=redact_text(str(exc)),
608
+ )
609
+
610
+
611
+ class _QueueHttpxRequestLogFilter(logging.Filter):
612
+ def filter(self, record: logging.LogRecord) -> bool:
613
+ args = record.args
614
+ if not isinstance(args, tuple):
615
+ return True
616
+ return not any(_is_queue_url(str(arg)) for arg in args)
617
+
618
+
619
+ def _is_queue_url(value: str) -> bool:
620
+ path_parts = [part for part in urlsplit(value).path.split("/") if part]
621
+ for index in range(len(path_parts) - 3):
622
+ if path_parts[index : index + 3] == ["api", "v3", "topic"]:
623
+ return True
624
+ return False
625
+
626
+
627
+ def _install_httpx_request_log_filter() -> None:
628
+ if _httpx_filter_state.installed:
629
+ return
630
+ with _httpx_filter_state.lock:
631
+ if _httpx_filter_state.installed:
632
+ return
633
+ _httpx_logger.addFilter(_QueueHttpxRequestLogFilter())
634
+ _httpx_filter_state.installed = True