diff --git a/getstream/base.py b/getstream/base.py index 8392159e..6a9ff75d 100644 --- a/getstream/base.py +++ b/getstream/base.py @@ -1,5 +1,6 @@ import json import time +import uuid from typing import Any, Dict, Optional, Type, get_origin from getstream.models import APIError @@ -16,7 +17,11 @@ span_request, current_operation, metric_attributes, + with_span, + get_current_call_cid, + get_current_channel_cid, ) +import ijson def build_path(path: str, path_params: dict) -> str: @@ -28,6 +33,7 @@ def build_path(path: str, path_params: dict) -> str: class ResponseParserMixin: + @with_span("parse_response") def _parse_response( self, response: httpx.Response, data_type: Type[T] ) -> StreamResponse[T]: @@ -89,23 +95,28 @@ def _normalize_endpoint_from_path(self, path: str) -> str: return ".".join(norm_parts) if norm_parts else "root" def _prepare_request(self, method: str, path: str, query_params, kwargs): + headers = kwargs.get("headers", {}) path_params = kwargs.get("path_params") if kwargs else None url_path = ( build_path(path, path_params) if path_params else build_path(path, None) ) url_full = f"{self.base_url}{url_path}" endpoint = self._endpoint_name(path) + client_request_id = str(uuid.uuid4()) + headers["x-client-request-id"] = client_request_id + kwargs["headers"] = headers span_attrs = common_attributes( api_key=self.api_key, endpoint=endpoint, method=method, url=url_full, + client_request_id=client_request_id, ) # Enrich with contextual IDs when available (set by decorators) - call_cid = getattr(self, "_call_cid", None) + call_cid = get_current_call_cid() if call_cid: span_attrs["stream.call_cid"] = call_cid - channel_cid = getattr(self, "_channel_cid", None) + channel_cid = get_current_channel_cid() if channel_cid: span_attrs["stream.channel_cid"] = channel_cid return url_path, url_full, endpoint, span_attrs @@ -145,7 +156,14 @@ def _endpoint_name(self, path: str) -> str: return op or current_operation(self._normalize_endpoint_from_path(path)) def _request_sync( - self, method: str, path: str, *, query_params=None, args=(), kwargs=None + self, + method: str, + path: str, + *, + query_params=None, + args=(), + kwargs=None, + data_type: Optional[Type[T]] = None, ): kwargs = kwargs or {} url_path, url_full, endpoint, attrs = self._prepare_request( @@ -161,22 +179,26 @@ def _request_sync( response = getattr(self.client, method.lower())( url_path, params=query_params, *args, **call_kwargs ) + duration = parse_duration_from_body(response.content) + if duration: + span.set_attribute("http.server.duration", duration) try: span and span.set_attribute( "http.response.status_code", response.status_code ) except Exception: pass - duration_ms = (time.perf_counter() - start) * 1000.0 - # Metrics should be low-cardinality: exclude url/call_cid/channel_cid - metric_attrs = metric_attributes( - api_key=self.api_key, - endpoint=endpoint, - method=method, - status_code=getattr(response, "status_code", None), - ) - record_metrics(duration_ms, attributes=metric_attrs) - return response + + duration_ms = (time.perf_counter() - start) * 1000.0 + # Metrics should be low-cardinality: exclude url/call_cid/channel_cid + metric_attrs = metric_attributes( + api_key=self.api_key, + endpoint=endpoint, + method=method, + status_code=getattr(response, "status_code", None), + ) + record_metrics(duration_ms, attributes=metric_attrs) + return self._parse_response(response, data_type or Dict[str, Any]) def patch( self, @@ -187,14 +209,14 @@ def patch( *args, **kwargs, ) -> StreamResponse[T]: - response = self._request_sync( + return self._request_sync( "PATCH", path, query_params=query_params, args=args, kwargs=kwargs | {"path_params": path_params}, + data_type=data_type, ) - return self._parse_response(response, data_type or Dict[str, Any]) def get( self, @@ -205,14 +227,14 @@ def get( *args, **kwargs, ) -> StreamResponse[T]: - response = self._request_sync( + return self._request_sync( "GET", path, query_params=query_params, args=args, kwargs=kwargs | {"path_params": path_params}, + data_type=data_type, ) - return self._parse_response(response, data_type or Dict[str, Any]) def post( self, @@ -223,14 +245,14 @@ def post( *args, **kwargs, ) -> StreamResponse[T]: - response = self._request_sync( + return self._request_sync( "POST", path, query_params=query_params, args=args, kwargs=kwargs | {"path_params": path_params}, + data_type=data_type, ) - return self._parse_response(response, data_type or Dict[str, Any]) def put( self, @@ -241,14 +263,14 @@ def put( *args, **kwargs, ) -> StreamResponse[T]: - response = self._request_sync( + return self._request_sync( "PUT", path, query_params=query_params, args=args, kwargs=kwargs | {"path_params": path_params}, + data_type=data_type, ) - return self._parse_response(response, data_type or Dict[str, Any]) def delete( self, @@ -259,14 +281,14 @@ def delete( *args, **kwargs, ) -> StreamResponse[T]: - response = self._request_sync( + return self._request_sync( "DELETE", path, query_params=query_params, args=args, kwargs=kwargs | {"path_params": path_params}, + data_type=data_type, ) - return self._parse_response(response, data_type or Dict[str, Any]) def close(self): """ @@ -313,9 +335,17 @@ def _endpoint_name(self, path: str) -> str: return op or current_operation(self._normalize_endpoint_from_path(path)) async def _request_async( - self, method: str, path: str, *, query_params=None, args=(), kwargs=None + self, + method: str, + path: str, + *, + query_params=None, + args=(), + kwargs=None, + data_type: Optional[Type[T]] = None, ): kwargs = kwargs or {} + query_params = query_params or {} url_path, url_full, endpoint, attrs = self._prepare_request( method, path, query_params, kwargs ) @@ -328,22 +358,26 @@ async def _request_async( response = await getattr(self.client, method.lower())( url_path, params=query_params, *args, **call_kwargs ) + duration = parse_duration_from_body(response.content) + if duration: + span.set_attribute("http.server.duration", duration) try: span and span.set_attribute( "http.response.status_code", response.status_code ) except Exception: pass - duration_ms = (time.perf_counter() - start) * 1000.0 - # Metrics should be low-cardinality: exclude url/call_cid/channel_cid - metric_attrs = metric_attributes( - api_key=self.api_key, - endpoint=endpoint, - method=method, - status_code=getattr(response, "status_code", None), - ) - record_metrics(duration_ms, attributes=metric_attrs) - return response + + duration_ms = (time.perf_counter() - start) * 1000.0 + # Metrics should be low-cardinality: exclude url/call_cid/channel_cid + metric_attrs = metric_attributes( + api_key=self.api_key, + endpoint=endpoint, + method=method, + status_code=getattr(response, "status_code", None), + ) + record_metrics(duration_ms, attributes=metric_attrs) + return self._parse_response(response, data_type or Dict[str, Any]) async def patch( self, @@ -354,14 +388,14 @@ async def patch( *args, **kwargs, ) -> StreamResponse[T]: - response = await self._request_async( + return await self._request_async( "PATCH", path, query_params=query_params, args=args, kwargs=kwargs | {"path_params": path_params}, + data_type=data_type, ) - return self._parse_response(response, data_type or Dict[str, Any]) async def get( self, @@ -372,14 +406,14 @@ async def get( *args, **kwargs, ) -> StreamResponse[T]: - response = await self._request_async( + return await self._request_async( "GET", path, query_params=query_params, args=args, kwargs=kwargs | {"path_params": path_params}, + data_type=data_type, ) - return self._parse_response(response, data_type or Dict[str, Any]) async def post( self, @@ -390,14 +424,14 @@ async def post( *args, **kwargs, ) -> StreamResponse[T]: - response = await self._request_async( + return await self._request_async( "POST", path, query_params=query_params, args=args, kwargs=kwargs | {"path_params": path_params}, + data_type=data_type, ) - return self._parse_response(response, data_type or Dict[str, Any]) async def put( self, @@ -408,14 +442,14 @@ async def put( *args, **kwargs, ) -> StreamResponse[T]: - response = await self._request_async( + return await self._request_async( "PUT", path, query_params=query_params, args=args, kwargs=kwargs | {"path_params": path_params}, + data_type=data_type, ) - return self._parse_response(response, data_type or Dict[str, Any]) async def delete( self, @@ -426,14 +460,14 @@ async def delete( *args, **kwargs, ) -> StreamResponse[T]: - response = await self._request_async( + return await self._request_async( "DELETE", path, query_params=query_params, args=args, kwargs=kwargs | {"path_params": path_params}, + data_type=data_type, ) - return self._parse_response(response, data_type or Dict[str, Any]) class StreamAPIException(Exception): @@ -478,3 +512,10 @@ def __str__(self) -> str: return f'Stream error code {self.api_error.code}: {self.api_error.message}"' else: return f"Stream error HTTP code: {self.status_code}" + + +def parse_duration_from_body(body: bytes) -> Optional[str]: + for prefix, event, value in ijson.parse(body): + if prefix == "duration" and event == "string": + return value + return None diff --git a/getstream/common/telemetry.py b/getstream/common/telemetry.py index 33a8dcbe..4e5cac47 100644 --- a/getstream/common/telemetry.py +++ b/getstream/common/telemetry.py @@ -2,9 +2,9 @@ import json import os -import time from contextlib import contextmanager from typing import Any, Dict, Optional, Callable, Awaitable, TYPE_CHECKING +from contextvars import ContextVar import inspect if TYPE_CHECKING: @@ -56,7 +56,10 @@ def _inner(*_args, **_kwargs): if _HAS_OTEL: - _TRACER = trace.get_tracer("getstream") + # Tracer must be retrieved lazily to respect late provider configuration + def _get_tracer(): + return trace.get_tracer("getstream") + _METER = metrics.get_meter("getstream") REQ_HIST = _METER.create_histogram( @@ -69,7 +72,10 @@ def _inner(*_args, **_kwargs): description="SDK client requests", ) else: # pragma: no cover - no-op instruments - _TRACER = None + + def _get_tracer(): # pragma: no cover - no-op + return None + REQ_HIST = None REQ_COUNT = None @@ -102,6 +108,7 @@ def common_attributes( method: str, url: Optional[str] = None, status_code: Optional[int] = None, + client_request_id: Optional[str] = None, ) -> Dict[str, Any]: attrs: Dict[str, Any] = { "stream.endpoint": endpoint, @@ -113,6 +120,8 @@ def common_attributes( attrs["http.response.status_code"] = int(status_code) if api_key: attrs["stream.api_key"] = api_key + if client_request_id: + attrs["stream.client_request_id"] = client_request_id return attrs @@ -160,19 +169,26 @@ def span_request( when enabled. Records duration on the span as attribute for debugging. """ include_bodies = INCLUDE_BODIES if include_bodies is None else include_bodies - if not _HAS_OTEL or _TRACER is None: # pragma: no cover - start = time.perf_counter() - try: - yield None - finally: - _ = time.perf_counter() - start + if not _HAS_OTEL: # pragma: no cover return - - start = time.perf_counter() - with _TRACER.start_as_current_span(name, kind=SpanKind.CLIENT) as span: # type: ignore[arg-type] - if attributes: + tracer = _get_tracer() + if tracer is None: # pragma: no cover + return + with tracer.start_as_current_span(name, kind=SpanKind.CLIENT) as span: # type: ignore[arg-type] + base_attrs: Dict[str, Any] = dict(attributes or {}) + # auto-propagate contextual IDs to request spans + try: + cid = get_current_call_cid() + if cid: + base_attrs["stream.call_cid"] = cid + ch = get_current_channel_cid() + if ch: + base_attrs["stream.channel_cid"] = ch + except Exception: + pass + if base_attrs: try: - span.set_attributes(attributes) # type: ignore[attr-defined] + span.set_attributes(base_attrs) # type: ignore[attr-defined] except Exception: pass if include_bodies and request_body is not None: @@ -191,12 +207,6 @@ def span_request( except Exception: pass raise - finally: - duration_ms = (time.perf_counter() - start) * 1000.0 - try: - span.set_attribute("getstream.request.duration_ms", duration_ms) # type: ignore[attr-defined] - except Exception: - pass def current_operation(default: Optional[str] = None) -> Optional[str]: @@ -204,26 +214,33 @@ def current_operation(default: Optional[str] = None) -> Optional[str]: return default -# Decorators for auto-attaching baggage around method calls +# Lightweight, context-local storage for call/channel CIDs +_CTX_CALL_CID: ContextVar[Optional[str]] = ContextVar("_stream_call_cid", default=None) +_CTX_CHANNEL_CID: ContextVar[Optional[str]] = ContextVar( + "_stream_channel_cid", default=None +) + + +def get_current_call_cid() -> Optional[str]: + return _CTX_CALL_CID.get() + + +def get_current_channel_cid() -> Optional[str]: + return _CTX_CHANNEL_CID.get() + + +# Decorators for auto-attaching contextual IDs around method calls def attach_call_cid(func: Callable[..., Any]) -> Callable[..., Any]: def wrapper(self: BaseCall, *args, **kwargs): cid = f"{self.call_type}:{self.id}" - client = getattr(self, "client", None) - prev = getattr(client, "_call_cid", None) if client is not None else None - if client is not None: - setattr(client, "_call_cid", cid) + token = _CTX_CALL_CID.set(cid) try: return func(self, *args, **kwargs) finally: - if client is not None: - if prev is not None: - setattr(client, "_call_cid", prev) - else: - try: - delattr(client, "_call_cid") - except Exception: - pass + _CTX_CALL_CID.reset(token) + # also expose for introspection if needed + setattr(wrapper, "_call_cid_attacher", True) return wrapper @@ -232,44 +249,26 @@ def attach_call_cid_async( ) -> Callable[..., Awaitable[Any]]: async def wrapper(self: BaseCall, *args, **kwargs): cid = f"{self.call_type}:{self.id}" - client = getattr(self, "client", None) - prev = getattr(client, "_call_cid", None) if client is not None else None - if client is not None: - setattr(client, "_call_cid", cid) + token = _CTX_CALL_CID.set(cid) try: return await func(self, *args, **kwargs) finally: - if client is not None: - if prev is not None: - setattr(client, "_call_cid", prev) - else: - try: - delattr(client, "_call_cid") - except Exception: - pass + _CTX_CALL_CID.reset(token) + setattr(wrapper, "_call_cid_attacher", True) return wrapper def attach_channel_cid(func: Callable[..., Any]) -> Callable[..., Any]: def wrapper(self: Channel, *args, **kwargs): cid = f"{self.channel_type}:{self.channel_id}" - client = getattr(self, "client", None) - prev = getattr(client, "_channel_cid", None) if client is not None else None - if client is not None: - setattr(client, "_channel_cid", cid) + token = _CTX_CHANNEL_CID.set(cid) try: return func(self, *args, **kwargs) finally: - if client is not None: - if prev is not None: - setattr(client, "_channel_cid", prev) - else: - try: - delattr(client, "_channel_cid") - except Exception: - pass + _CTX_CHANNEL_CID.reset(token) + setattr(wrapper, "_channel_cid_attacher", True) return wrapper @@ -278,22 +277,13 @@ def attach_channel_cid_async( ) -> Callable[..., Awaitable[Any]]: async def wrapper(self: Channel, *args, **kwargs): cid = f"{self.channel_type}:{self.channel_id}" - client = getattr(self, "client", None) - prev = getattr(client, "_channel_cid", None) if client is not None else None - if client is not None: - setattr(client, "_channel_cid", cid) + token = _CTX_CHANNEL_CID.set(cid) try: return await func(self, *args, **kwargs) finally: - if client is not None: - if prev is not None: - setattr(client, "_channel_cid", prev) - else: - try: - delattr(client, "_channel_cid") - except Exception: - pass + _CTX_CHANNEL_CID.reset(token) + setattr(wrapper, "_channel_cid_attacher", True) return wrapper @@ -328,21 +318,36 @@ def start_as_current_span( attributes: Optional[Dict[str, Any]] = None, ): """Lightweight span context manager that no-ops if OTel isn't available.""" - if not _HAS_OTEL or _TRACER is None: # pragma: no cover + if not _HAS_OTEL: # pragma: no cover yield _NullSpan() return use_kind = kind if kind is not None else SpanKind.INTERNAL - with _TRACER.start_as_current_span(name, kind=use_kind) as span: # type: ignore[arg-type] - if attributes: + tracer = _get_tracer() + if tracer is None: # pragma: no cover + yield _NullSpan() + return + with tracer.start_as_current_span(name, kind=use_kind) as span: # type: ignore[arg-type] + base_attrs: Dict[str, Any] = dict(attributes or {}) + # auto-propagate contextual IDs + try: + cid = get_current_call_cid() + if cid: + base_attrs["stream.call_cid"] = cid + ch = get_current_channel_cid() + if ch: + base_attrs["stream.channel_cid"] = ch + except Exception: + pass + if base_attrs: try: - span.set_attributes(attributes) # type: ignore[attr-defined] + span.set_attributes(base_attrs) # type: ignore[attr-defined] except Exception: pass yield span def get_current_span(): - if not _HAS_OTEL or _TRACER is None: # pragma: no cover + if not _HAS_OTEL: # pragma: no cover return _NullSpan() return trace.get_current_span() @@ -353,7 +358,7 @@ def set_span_in_context(span: "Span", context: Optional["Context"] = None): Returns an attach token suitable for later detaching via `detach_context`. If OpenTelemetry is unavailable, returns None. """ - if not _HAS_OTEL or _TRACER is None or otel_context is None: # pragma: no cover + if not _HAS_OTEL or otel_context is None: # pragma: no cover return None ctx = trace.set_span_in_context(span, context or otel_context.get_current()) token = otel_context.attach(ctx) diff --git a/getstream/video/rtc/connection_manager.py b/getstream/video/rtc/connection_manager.py index dd0a6b0d..203e3aa5 100644 --- a/getstream/video/rtc/connection_manager.py +++ b/getstream/video/rtc/connection_manager.py @@ -23,6 +23,7 @@ ConnectionOptions, connect_websocket, join_call, + watch_call, ) from getstream.video.rtc.track_util import ( fix_sdp_msid_semantic, @@ -35,7 +36,6 @@ from getstream.video.rtc.tracks import SubscriptionConfig, SubscriptionManager from getstream.video.rtc.reconnection import ReconnectionManager from getstream.video.rtc.peer_connection import PeerConnectionManager -from getstream.video.rtc.location_discovery import HTTPHintLocationDiscovery from getstream.video.rtc.models import JoinCallResponse logger = logging.getLogger(__name__) @@ -91,6 +91,7 @@ def __init__( self.twirp_signaling_client = None self.twirp_context: Optional[Context] = None + self._coordinator_task: Optional[asyncio.Task] = None @property def connection_state(self) -> ConnectionState: @@ -204,6 +205,31 @@ async def _on_subscriber_offer(self, event: events_pb2.SubscriberOffer): finally: self.subscriber_negotiation_lock.release() + async def _connect_coordinator_ws(self): + """ + Connects to the coordinator websocket and subscribes to events. + """ + + with telemetry.start_as_current_span( + "coordinator-setup", + ): + with telemetry.start_as_current_span( + "coordinator-ws-connect", + ): + self._coordinator_ws_client = StreamAPIWS( + call=self.call, + user_details={"id": self.user_id}, + ) + self._coordinator_ws_client.on_wildcard("*", _log_event) + await self._coordinator_ws_client.connect() + + with telemetry.start_as_current_span( + "watch-call", + ): + await watch_call( + self.call, self.user_id, self._coordinator_ws_client._client_id + ) + async def _connect_internal( self, region: Optional[str] = None, @@ -226,31 +252,20 @@ async def _connect_internal( self.connection_state = ConnectionState.JOINING # Step 1: Determine region - with telemetry.start_as_current_span( - "location-discovery", - ) as span: - if not region: - try: - region = HTTPHintLocationDiscovery(logger=logger).discover() - except Exception as e: - logger.warning(f"Failed to discover location: {e}") - location = "FRA" - logger.debug(f"Using location: {region}") - location = region - span.set_attribute("location", location) - - # Step 2: Create coordinator websocket - with telemetry.start_as_current_span( - "coordinator-ws-connect", - ): - self._coordinator_ws_client = StreamAPIWS( - call=self.call, - user_details={"id": self.user_id}, - ) - self._coordinator_ws_client.on_wildcard("*", _log_event) - await self._coordinator_ws_client.connect() - - # Step 3: Join call via coordinator + # with telemetry.start_as_current_span( + # "location-discovery", + # ) as span: + # if not region: + # try: + # region = HTTPHintLocationDiscovery(logger=logger).discover() + # except Exception as e: + # logger.warning(f"Failed to discover location: {e}") + # location = "FRA" + # logger.debug(f"Using location: {region}") + # location = region + # span.set_attribute("location", location) + + # Step 2: Join call via coordinator with telemetry.start_as_current_span( "coordinator-join-call", ) as span: @@ -258,34 +273,38 @@ async def _connect_internal( join_response = await join_call( self.call, self.user_id, - location, + "auto", self.create, self.local_sfu, - self._coordinator_ws_client._client_id, **self.kwargs, ) ws_url = join_response.data.credentials.server.ws_endpoint token = join_response.data.credentials.token self.join_response = join_response logger.debug(f"coordinator join response: {join_response.data}") - span.set_attribute("join_response", join_response.data) + span.set_attribute( + "credentials", join_response.data.credentials.to_json() + ) # Use provided session_id or current one current_session_id = session_id or self.session_id await self._peer_manager.setup_subscriber() - # Step 4: Connect to WebSocket + # Step 3: Connect to WebSocket try: - self._ws_client, sfu_event = await connect_websocket( - token=token, - ws_url=ws_url, - session_id=current_session_id, - options=self._connection_options, - ) + with telemetry.start_as_current_span( + "sfu-signaling-ws-connect", + ) as span: + self._ws_client, sfu_event = await connect_websocket( + token=token, + ws_url=ws_url, + session_id=current_session_id, + options=self._connection_options, + ) - self._ws_client.on_wildcard("*", _log_event) - self._ws_client.on_event("ice_trickle", self._on_ice_trickle) + self._ws_client.on_wildcard("*", _log_event) + self._ws_client.on_event("ice_trickle", self._on_ice_trickle) # Connect track subscription events to subscription manager self._ws_client.on_event( @@ -337,6 +356,21 @@ async def connect(self): like "server is full" and network issues. """ logger.info("Connecting to SFU") + # Fire-and-forget the coordinator WS connection so we don't block here + if self._coordinator_task is None or self._coordinator_task.done(): + self._coordinator_task = asyncio.create_task( + self._connect_coordinator_ws(), name="coordinator-ws-connect" + ) + + def _on_coordinator_task_done(task: asyncio.Task): + try: + task.result() + except asyncio.CancelledError: + pass + except Exception: + logger.exception("Coordinator WS task failed") + + self._coordinator_task.add_done_callback(_on_coordinator_task_done) await self._connect_internal() async def wait(self): @@ -364,6 +398,15 @@ async def leave(self): if self._ws_client: self._ws_client.close() self._ws_client = None + if self._coordinator_task and not self._coordinator_task.done(): + self._coordinator_task.cancel() + try: + await self._coordinator_task + except asyncio.CancelledError: + pass + finally: + self._coordinator_task = None + if self._coordinator_ws_client: await self._coordinator_ws_client.disconnect() self._coordinator_ws_client = None diff --git a/getstream/video/rtc/connection_utils.py b/getstream/video/rtc/connection_utils.py index d0edac51..cea16a4b 100644 --- a/getstream/video/rtc/connection_utils.py +++ b/getstream/video/rtc/connection_utils.py @@ -97,15 +97,43 @@ class ConnectionOptions: previous_session_id: Optional[str] = None +def user_client(call: Call, user_id: str): + token = call.client.stream.create_token(user_id=user_id) + client = call.client.stream.__class__( + api_key=call.client.stream.api_key, + api_secret=call.client.stream.api_secret, + base_url=call.client.stream.base_url, + ) + # set up authentication + client.token = token + client.headers["authorization"] = token + client.client.headers["authorization"] = token + return client + + +async def watch_call(call: Call, user_id: str, connection_id: str): + client = user_client(call, user_id) + + # Make the POST request to join the call + return await client.post( + "/api/v2/video/call/{type}/{id}", + JoinCallResponse, + path_params={ + "type": call.call_type, + "id": call.id, + }, + query_params=build_query_param(connection_id=connection_id), + ) + + async def join_call( - call, + call: Call, user_id: str, location: str, create: bool, local_sfu: bool, - connection_id: str, **kwargs, -): +) -> StreamResponse[JoinCallResponse]: """Join call via coordinator API.""" try: join_response = await join_call_coordinator_request( @@ -113,7 +141,6 @@ async def join_call( user_id, location=location, create=create, - connection_id=connection_id, **kwargs, ) if local_sfu: @@ -140,7 +167,6 @@ async def join_call_coordinator_request( notify: Optional[bool] = None, video: Optional[bool] = None, location: Optional[str] = None, - connection_id: Optional[str] = None, ) -> StreamResponse[JoinCallResponse]: """Make a request to join a call via the coordinator. @@ -157,20 +183,7 @@ async def join_call_coordinator_request( Returns: A response containing the call information and credentials """ - # Create a token for this user - token = call.client.stream.create_token(user_id=user_id) - - # create a new client with this token - client = call.client.stream.__class__( - api_key=call.client.stream.api_key, - api_secret=call.client.stream.api_secret, - base_url=call.client.stream.base_url, - ) - - # set up authentication - client.token = token - client.headers["authorization"] = token - client.client.headers["authorization"] = token + client = user_client(call, user_id) # Prepare path parameters for the request path_params = { @@ -180,21 +193,19 @@ async def join_call_coordinator_request( # Build the request body json_body = build_body_dict( - location=location or "FRA", # Default to Frankfurt if not specified + location=location, create=create, notify=notify, ring=ring, video=video, data=data, ) - query_params = build_query_param(connection_id=connection_id) # Make the POST request to join the call return await client.post( "/api/v2/video/call/{type}/{id}/join", JoinCallResponse, path_params=path_params, - query_params=query_params, json=json_body, ) diff --git a/getstream/video/rtc/models.py b/getstream/video/rtc/models.py index 98885f30..a5d9c7b1 100644 --- a/getstream/video/rtc/models.py +++ b/getstream/video/rtc/models.py @@ -49,3 +49,4 @@ class JoinCallResponse(DataClassJsonMixin): members: List[MemberResponse] = dc_field(metadata=dc_config(field_name="members")) credentials: Credentials = dc_field(metadata=dc_config(field_name="credentials")) stats_options: dict = dc_field(metadata=dc_config(field_name="stats_options")) + duration: str = dc_field(metadata=dc_config(field_name="duration")) diff --git a/pyproject.toml b/pyproject.toml index 814bc81e..e4eb7127 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "pydantic-settings>=2.9.1", "pydantic>=2.10.6", "pyee>=13.0.0", + "ijson>=3.4.0", ] [project.optional-dependencies] diff --git a/tests/test_video_examples.py b/tests/test_video_examples.py index 2edfa430..8527f8f2 100644 --- a/tests/test_video_examples.py +++ b/tests/test_video_examples.py @@ -534,76 +534,6 @@ async def test_srt(async_client: AsyncStream): assert call.create_srt_credentials(user_id).address != "" -def test_otel_tracing_and_metrics_base_client(): - """Verify BaseClient emits OTel spans and metrics with attributes.""" - from opentelemetry import trace, metrics - from opentelemetry.sdk.trace import TracerProvider - from opentelemetry.sdk.trace.export import SimpleSpanProcessor - from opentelemetry.sdk.trace.export.in_memory_span_exporter import ( - InMemorySpanExporter, - ) - from opentelemetry.sdk.metrics import MeterProvider - from opentelemetry.sdk.metrics.export import InMemoryMetricReader - - # Configure in-memory exporters; avoid overriding if already set - span_exporter = InMemorySpanExporter() - provider = trace.get_tracer_provider() - if hasattr(provider, "add_span_processor"): - provider.add_span_processor(SimpleSpanProcessor(span_exporter)) - else: - tp = TracerProvider() - tp.add_span_processor(SimpleSpanProcessor(span_exporter)) - trace.set_tracer_provider(tp) - - metric_reader = InMemoryMetricReader() - mp = MeterProvider(metric_readers=[metric_reader]) - metrics.set_meter_provider(mp) - - # Dummy rest client subclass using BaseClient - from getstream.base import BaseClient - import httpx - - from typing import Dict as _Dict, Any as _Any - - class DummyClient(BaseClient): - def ping(self): - return self.get("/ping", data_type=_Dict[str, _Any]) - - # Mock transport that returns minimal JSON - def handler(request: httpx.Request) -> httpx.Response: - return httpx.Response(200, json={"ok": True}, request=request) - - transport = httpx.MockTransport(handler) - - client = DummyClient( - api_key="test_key_abcdefg", base_url="http://test", token="tok", timeout=1.0 - ) - # Replace underlying httpx client to avoid real network - client.client = httpx.Client(base_url=client.base_url, transport=transport) - - # Perform request - resp = client.ping() - assert resp.status_code() == 200 - assert resp.data["ok"] is True - - # Validate spans - spans = span_exporter.get_finished_spans() - assert len(spans) >= 1 - s = spans[-1] - # Endpoint should be a string identifying the operation - endpoint_attr = s.attributes.get("stream.endpoint") - assert isinstance(endpoint_attr, str) - assert s.attributes.get("http.request.method") == "GET" - assert s.attributes.get("http.response.status_code") == 200 - api_key_attr = s.attributes.get("stream.api_key") - assert isinstance(api_key_attr, str) - assert api_key_attr == "test_key_abcdefg" or ( - api_key_attr.startswith("test_k") and api_key_attr.endswith("***") - ) - - # Metrics are validated in integration/manual tests; spans are the focus here. - - def test_otel_baggage_call_cid_video(monkeypatch): """Verify Call auto-attaches call_cid baggage and spans inherit it.""" from opentelemetry import trace diff --git a/uv.lock b/uv.lock index 807ca8b7..29e3be99 100644 --- a/uv.lock +++ b/uv.lock @@ -633,6 +633,7 @@ source = { editable = "." } dependencies = [ { name = "dataclasses-json" }, { name = "httpx" }, + { name = "ijson" }, { name = "marshmallow" }, { name = "pydantic" }, { name = "pydantic-settings" }, @@ -689,6 +690,7 @@ requires-dist = [ { name = "aiortc", marker = "extra == 'webrtc'", specifier = ">=1.13.0" }, { name = "dataclasses-json", specifier = ">=0.6.0,<0.7" }, { name = "httpx", specifier = ">=0.28.1" }, + { name = "ijson", specifier = ">=3.4.0" }, { name = "marshmallow", specifier = ">=3.21.0,<4" }, { name = "numpy", marker = "extra == 'webrtc'", specifier = ">=2.2.6" }, { name = "numpy", marker = "extra == 'webrtc'", specifier = ">=2.2.6,<2.3" }, @@ -947,6 +949,81 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9c/1f/19ebc343cc71a7ffa78f17018535adc5cbdd87afb31d7c34874680148b32/ifaddr-0.2.0-py3-none-any.whl", hash = "sha256:085e0305cfe6f16ab12d72e2024030f5d52674afad6911bb1eee207177b8a748", size = 12314 }, ] +[[package]] +name = "ijson" +version = "3.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/4f/1cfeada63f5fce87536651268ddf5cca79b8b4bbb457aee4e45777964a0a/ijson-3.4.0.tar.gz", hash = "sha256:5f74dcbad9d592c428d3ca3957f7115a42689ee7ee941458860900236ae9bb13", size = 65782 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/6b/a247ba44004154aaa71f9e6bd9f05ba412f490cc4043618efb29314f035e/ijson-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e27e50f6dcdee648f704abc5d31b976cd2f90b4642ed447cf03296d138433d09", size = 87609 }, + { url = "https://files.pythonhosted.org/packages/3c/1d/8d2009d74373b7dec2a49b1167e396debb896501396c70a674bb9ccc41ff/ijson-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2a753be681ac930740a4af9c93cfb4edc49a167faed48061ea650dc5b0f406f1", size = 59243 }, + { url = "https://files.pythonhosted.org/packages/a7/b2/a85a21ebaba81f64a326c303a94625fb94b84890c52d9efdd8acb38b6312/ijson-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a07c47aed534e0ec198e6a2d4360b259d32ac654af59c015afc517ad7973b7fb", size = 59309 }, + { url = "https://files.pythonhosted.org/packages/b1/35/273dfa1f27c38eeaba105496ecb54532199f76c0120177b28315daf5aec3/ijson-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c55f48181e11c597cd7146fb31edc8058391201ead69f8f40d2ecbb0b3e4fc6", size = 131213 }, + { url = "https://files.pythonhosted.org/packages/4d/37/9d3bb0e200a103ca9f8e9315c4d96ecaca43a3c1957c1ac069ea9dc9c6ba/ijson-3.4.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd5669f96f79d8a2dd5ae81cbd06770a4d42c435fd4a75c74ef28d9913b697d", size = 125456 }, + { url = "https://files.pythonhosted.org/packages/00/54/8f015c4df30200fd14435dec9c67bf675dff0fee44a16c084a8ec0f82922/ijson-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e3ddd46d16b8542c63b1b8af7006c758d4e21cc1b86122c15f8530fae773461", size = 130192 }, + { url = "https://files.pythonhosted.org/packages/88/01/46a0540ad3461332edcc689a8874fa13f0a4c00f60f02d155b70e36f5e0b/ijson-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1504cec7fe04be2bb0cc33b50c9dd3f83f98c0540ad4991d4017373b7853cfe6", size = 132217 }, + { url = "https://files.pythonhosted.org/packages/d7/da/8f8df42f3fd7ef279e20eae294738eed62d41ed5b6a4baca5121abc7cf0f/ijson-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:2f2ff456adeb216603e25d7915f10584c1b958b6eafa60038d76d08fc8a5fb06", size = 127118 }, + { url = "https://files.pythonhosted.org/packages/82/0a/a410d9d3b082cc2ec9738d54935a589974cbe54c0f358e4d17465594d660/ijson-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:0ab00d75d61613a125fbbb524551658b1ad6919a52271ca16563ca5bc2737bb1", size = 129808 }, + { url = "https://files.pythonhosted.org/packages/2e/c6/a3e2a446b8bd2cf91cb4ca7439f128d2b379b5a79794d0ea25e379b0f4f3/ijson-3.4.0-cp310-cp310-win32.whl", hash = "sha256:ada421fd59fe2bfa4cfa64ba39aeba3f0753696cdcd4d50396a85f38b1d12b01", size = 51160 }, + { url = "https://files.pythonhosted.org/packages/18/7c/e6620603df42d2ef8a92076eaa5cd2b905366e86e113adf49e7b79970bd3/ijson-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:8c75e82cec05d00ed3a4af5f4edf08f59d536ed1a86ac7e84044870872d82a33", size = 53710 }, + { url = "https://files.pythonhosted.org/packages/1a/0d/3e2998f4d7b7d2db2d511e4f0cf9127b6e2140c325c3cb77be46ae46ff1d/ijson-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9e369bf5a173ca51846c243002ad8025d32032532523b06510881ecc8723ee54", size = 87643 }, + { url = "https://files.pythonhosted.org/packages/e9/7b/afef2b08af2fee5ead65fcd972fadc3e31f9ae2b517fe2c378d50a9bf79b/ijson-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:26e7da0a3cd2a56a1fde1b34231867693f21c528b683856f6691e95f9f39caec", size = 59260 }, + { url = "https://files.pythonhosted.org/packages/da/4a/39f583a2a13096f5063028bb767622f09cafc9ec254c193deee6c80af59f/ijson-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1c28c7f604729be22aa453e604e9617b665fa0c24cd25f9f47a970e8130c571a", size = 59311 }, + { url = "https://files.pythonhosted.org/packages/3c/58/5b80efd54b093e479c98d14b31d7794267281f6a8729f2c94fbfab661029/ijson-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0bed8bcb84d3468940f97869da323ba09ae3e6b950df11dea9b62e2b231ca1e3", size = 136125 }, + { url = "https://files.pythonhosted.org/packages/e5/f5/f37659b1647ecc3992216277cd8a45e2194e84e8818178f77c99e1d18463/ijson-3.4.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:296bc824f4088f2af814aaf973b0435bc887ce3d9f517b1577cc4e7d1afb1cb7", size = 130699 }, + { url = "https://files.pythonhosted.org/packages/ee/2f/4c580ac4bb5eda059b672ad0a05e4bafdae5182a6ec6ab43546763dafa91/ijson-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8145f8f40617b6a8aa24e28559d0adc8b889e56a203725226a8a60fa3501073f", size = 134963 }, + { url = "https://files.pythonhosted.org/packages/6d/9e/64ec39718609faab6ed6e1ceb44f9c35d71210ad9c87fff477c03503e8f8/ijson-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b674a97bd503ea21bc85103e06b6493b1b2a12da3372950f53e1c664566a33a4", size = 137405 }, + { url = "https://files.pythonhosted.org/packages/71/b2/f0bf0e4a0962845597996de6de59c0078bc03a1f899e03908220039f4cf6/ijson-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8bc731cf1c3282b021d3407a601a5a327613da9ad3c4cecb1123232623ae1826", size = 131861 }, + { url = "https://files.pythonhosted.org/packages/17/83/4a2e3611e2b4842b413ec84d2e54adea55ab52e4408ea0f1b1b927e19536/ijson-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:42ace5e940e0cf58c9de72f688d6829ddd815096d07927ee7e77df2648006365", size = 134297 }, + { url = "https://files.pythonhosted.org/packages/38/75/2d332911ac765b44cd7da0cb2b06143521ad5e31dfcc8d8587e6e6168bc8/ijson-3.4.0-cp311-cp311-win32.whl", hash = "sha256:5be39a0df4cd3f02b304382ea8885391900ac62e95888af47525a287c50005e9", size = 51161 }, + { url = "https://files.pythonhosted.org/packages/7d/ba/4ad571f9f7fcf5906b26e757b130c1713c5f0198a1e59568f05d53a0816c/ijson-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:0b1be1781792291e70d2e177acf564ec672a7907ba74f313583bdf39fe81f9b7", size = 53710 }, + { url = "https://files.pythonhosted.org/packages/f8/ec/317ee5b2d13e50448833ead3aa906659a32b376191f6abc2a7c6112d2b27/ijson-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:956b148f88259a80a9027ffbe2d91705fae0c004fbfba3e5a24028fbe72311a9", size = 87212 }, + { url = "https://files.pythonhosted.org/packages/f8/43/b06c96ced30cacecc5d518f89b0fd1c98c294a30ff88848b70ed7b7f72a1/ijson-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:06b89960f5c721106394c7fba5760b3f67c515b8eb7d80f612388f5eca2f4621", size = 59175 }, + { url = "https://files.pythonhosted.org/packages/e9/df/b4aeafb7ecde463130840ee9be36130823ec94a00525049bf700883378b8/ijson-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9a0bb591cf250dd7e9dfab69d634745a7f3272d31cfe879f9156e0a081fd97ee", size = 59011 }, + { url = "https://files.pythonhosted.org/packages/e3/7c/a80b8e361641609507f62022089626d4b8067f0826f51e1c09e4ba86eba8/ijson-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:72e92de999977f4c6b660ffcf2b8d59604ccd531edcbfde05b642baf283e0de8", size = 146094 }, + { url = "https://files.pythonhosted.org/packages/01/44/fa416347b9a802e3646c6ff377fc3278bd7d6106e17beb339514b6a3184e/ijson-3.4.0-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9e9602157a5b869d44b6896e64f502c712a312fcde044c2e586fccb85d3e316e", size = 137903 }, + { url = "https://files.pythonhosted.org/packages/24/c6/41a9ad4d42df50ff6e70fdce79b034f09b914802737ebbdc141153d8d791/ijson-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1e83660edb931a425b7ff662eb49db1f10d30ca6d4d350e5630edbed098bc01", size = 148339 }, + { url = "https://files.pythonhosted.org/packages/5f/6f/7d01efda415b8502dce67e067ed9e8a124f53e763002c02207e542e1a2f1/ijson-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:49bf8eac1c7b7913073865a859c215488461f7591b4fa6a33c14b51cb73659d0", size = 149383 }, + { url = "https://files.pythonhosted.org/packages/95/6c/0d67024b9ecb57916c5e5ab0350251c9fe2f86dc9c8ca2b605c194bdad6a/ijson-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:160b09273cb42019f1811469508b0a057d19f26434d44752bde6f281da6d3f32", size = 141580 }, + { url = "https://files.pythonhosted.org/packages/06/43/e10edcc1c6a3b619294de835e7678bfb3a1b8a75955f3689fd66a1e9e7b4/ijson-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2019ff4e6f354aa00c76c8591bd450899111c61f2354ad55cc127e2ce2492c44", size = 150280 }, + { url = "https://files.pythonhosted.org/packages/07/84/1cbeee8e8190a1ebe6926569a92cf1fa80ddb380c129beb6f86559e1bb24/ijson-3.4.0-cp312-cp312-win32.whl", hash = "sha256:931c007bf6bb8330705429989b2deed6838c22b63358a330bf362b6e458ba0bf", size = 51512 }, + { url = "https://files.pythonhosted.org/packages/66/13/530802bc391c95be6fe9f96e9aa427d94067e7c0b7da7a9092344dc44c4b/ijson-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:71523f2b64cb856a820223e94d23e88369f193017ecc789bb4de198cc9d349eb", size = 54081 }, + { url = "https://files.pythonhosted.org/packages/77/b3/b1d2eb2745e5204ec7a25365a6deb7868576214feb5e109bce368fb692c9/ijson-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e8d96f88d75196a61c9d9443de2b72c2d4a7ba9456ff117b57ae3bba23a54256", size = 87216 }, + { url = "https://files.pythonhosted.org/packages/b1/cd/cd6d340087617f8cc9bedbb21d974542fe2f160ed0126b8288d3499a469b/ijson-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c45906ce2c1d3b62f15645476fc3a6ca279549127f01662a39ca5ed334a00cf9", size = 59170 }, + { url = "https://files.pythonhosted.org/packages/3e/4d/32d3a9903b488d3306e3c8288f6ee4217d2eea82728261db03a1045eb5d1/ijson-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:4ab4bc2119b35c4363ea49f29563612237cae9413d2fbe54b223be098b97bc9e", size = 59013 }, + { url = "https://files.pythonhosted.org/packages/d5/c8/db15465ab4b0b477cee5964c8bfc94bf8c45af8e27a23e1ad78d1926e587/ijson-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:97b0a9b5a15e61dfb1f14921ea4e0dba39f3a650df6d8f444ddbc2b19b479ff1", size = 146564 }, + { url = "https://files.pythonhosted.org/packages/c4/d8/0755545bc122473a9a434ab90e0f378780e603d75495b1ca3872de757873/ijson-3.4.0-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e3047bb994dabedf11de11076ed1147a307924b6e5e2df6784fb2599c4ad8c60", size = 137917 }, + { url = "https://files.pythonhosted.org/packages/d0/c6/aeb89c8939ebe3f534af26c8c88000c5e870dbb6ae33644c21a4531f87d2/ijson-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:68c83161b052e9f5dc8191acbc862bb1e63f8a35344cb5cd0db1afd3afd487a6", size = 148897 }, + { url = "https://files.pythonhosted.org/packages/be/0e/7ef6e9b372106f2682a4a32b3c65bf86bb471a1670e4dac242faee4a7d3f/ijson-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1eebd9b6c20eb1dffde0ae1f0fbb4aeacec2eb7b89adb5c7c0449fc9fd742760", size = 149711 }, + { url = "https://files.pythonhosted.org/packages/d1/5d/9841c3ed75bcdabf19b3202de5f862a9c9c86ce5c7c9d95fa32347fdbf5f/ijson-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:13fb6d5c35192c541421f3ee81239d91fc15a8d8f26c869250f941f4b346a86c", size = 141691 }, + { url = "https://files.pythonhosted.org/packages/d5/d2/ce74e17218dba292e9be10a44ed0c75439f7958cdd263adb0b5b92d012d5/ijson-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:28b7196ff7b37c4897c547a28fa4876919696739fc91c1f347651c9736877c69", size = 150738 }, + { url = "https://files.pythonhosted.org/packages/4e/43/dcc480f94453b1075c9911d4755b823f3ace275761bb37b40139f22109ca/ijson-3.4.0-cp313-cp313-win32.whl", hash = "sha256:3c2691d2da42629522140f77b99587d6f5010440d58d36616f33bc7bdc830cc3", size = 51512 }, + { url = "https://files.pythonhosted.org/packages/35/dd/d8c5f15efd85ba51e6e11451ebe23d779361a9ec0d192064c2a8c3cdfcb8/ijson-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:c4554718c275a044c47eb3874f78f2c939f300215d9031e785a6711cc51b83fc", size = 54074 }, + { url = "https://files.pythonhosted.org/packages/79/73/24ad8cd106203419c4d22bed627e02e281d66b83e91bc206a371893d0486/ijson-3.4.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:915a65e3f3c0eee2ea937bc62aaedb6c14cc1e8f0bb9f3f4fb5a9e2bbfa4b480", size = 91694 }, + { url = "https://files.pythonhosted.org/packages/17/2d/f7f680984bcb7324a46a4c2df3bd73cf70faef0acfeb85a3f811abdfd590/ijson-3.4.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:afbe9748707684b6c5adc295c4fdcf27765b300aec4d484e14a13dca4e5c0afa", size = 61390 }, + { url = "https://files.pythonhosted.org/packages/09/a1/f3ca7bab86f95bdb82494739e71d271410dfefce4590785d511669127145/ijson-3.4.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d823f8f321b4d8d5fa020d0a84f089fec5d52b7c0762430476d9f8bf95bbc1a9", size = 61140 }, + { url = "https://files.pythonhosted.org/packages/51/79/dd340df3d4fc7771c95df29997956b92ed0570fe7b616d1792fea9ad93f2/ijson-3.4.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b8a0a2c54f3becf76881188beefd98b484b1d3bd005769a740d5b433b089fa23", size = 214739 }, + { url = "https://files.pythonhosted.org/packages/59/f0/85380b7f51d1f5fb7065d76a7b623e02feca920cc678d329b2eccc0011e0/ijson-3.4.0-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ced19a83ab09afa16257a0b15bc1aa888dbc555cb754be09d375c7f8d41051f2", size = 198338 }, + { url = "https://files.pythonhosted.org/packages/a5/cd/313264cf2ec42e0f01d198c49deb7b6fadeb793b3685e20e738eb6b3fa13/ijson-3.4.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8100f9885eff1f38d35cef80ef759a1bbf5fc946349afa681bd7d0e681b7f1a0", size = 207515 }, + { url = "https://files.pythonhosted.org/packages/12/94/bf14457aa87ea32641f2db577c9188ef4e4ae373478afef422b31fc7f309/ijson-3.4.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d7bcc3f7f21b0f703031ecd15209b1284ea51b2a329d66074b5261de3916c1eb", size = 210081 }, + { url = "https://files.pythonhosted.org/packages/7d/b4/eaee39e290e40e52d665db9bd1492cfdce86bd1e47948e0440db209c6023/ijson-3.4.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:2dcb190227b09dd171bdcbfe4720fddd574933c66314818dfb3960c8a6246a77", size = 199253 }, + { url = "https://files.pythonhosted.org/packages/c5/9c/e09c7b9ac720a703ab115b221b819f149ed54c974edfff623c1e925e57da/ijson-3.4.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:eda4cfb1d49c6073a901735aaa62e39cb7ab47f3ad7bb184862562f776f1fa8a", size = 203816 }, + { url = "https://files.pythonhosted.org/packages/7c/14/acd304f412e32d16a2c12182b9d78206bb0ae35354d35664f45db05c1b3b/ijson-3.4.0-cp313-cp313t-win32.whl", hash = "sha256:0772638efa1f3b72b51736833404f1cbd2f5beeb9c1a3d392e7d385b9160cba7", size = 53760 }, + { url = "https://files.pythonhosted.org/packages/2f/24/93dd0a467191590a5ed1fc2b35842bca9d09900d001e00b0b497c0208ef6/ijson-3.4.0-cp313-cp313t-win_amd64.whl", hash = "sha256:3d8a0d67f36e4fb97c61a724456ef0791504b16ce6f74917a31c2e92309bbeb9", size = 56948 }, + { url = "https://files.pythonhosted.org/packages/a7/22/da919f16ca9254f8a9ea0ba482d2c1d012ce6e4c712dcafd8adb16b16c63/ijson-3.4.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:54e989c35dba9cf163d532c14bcf0c260897d5f465643f0cd1fba9c908bed7ef", size = 56480 }, + { url = "https://files.pythonhosted.org/packages/6d/54/c2afd289e034d11c4909f4ea90c9dae55053bed358064f310c3dd5033657/ijson-3.4.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:494eeb8e87afef22fbb969a4cb81ac2c535f30406f334fb6136e9117b0bb5380", size = 55956 }, + { url = "https://files.pythonhosted.org/packages/43/d6/18799b0fca9ecb8a47e22527eedcea3267e95d4567b564ef21d0299e2d12/ijson-3.4.0-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81603de95de1688958af65cd2294881a4790edae7de540b70c65c8253c5dc44a", size = 69394 }, + { url = "https://files.pythonhosted.org/packages/c2/d6/c58032c69e9e977bf6d954f22cad0cd52092db89c454ea98926744523665/ijson-3.4.0-pp310-pypy310_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8524be12c1773e1be466034cc49c1ecbe3d5b47bb86217bd2a57f73f970a6c19", size = 70378 }, + { url = "https://files.pythonhosted.org/packages/da/03/07c6840454d5d228bb5b4509c9a7ac5b9c0b8258e2b317a53f97372be1eb/ijson-3.4.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17994696ec895d05e0cfa21b11c68c920c82634b4a3d8b8a1455d6fe9fdee8f7", size = 67770 }, + { url = "https://files.pythonhosted.org/packages/32/c7/da58a9840380308df574dfdb0276c9d802b12f6125f999e92bcef36db552/ijson-3.4.0-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:0b67727aaee55d43b2e82b6a866c3cbcb2b66a5e9894212190cbd8773d0d9857", size = 53858 }, + { url = "https://files.pythonhosted.org/packages/a3/9b/0bc0594d357600c03c3b5a3a34043d764fc3ad3f0757d2f3aae5b28f6c1c/ijson-3.4.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:cdc8c5ca0eec789ed99db29c68012dda05027af0860bb360afd28d825238d69d", size = 56483 }, + { url = "https://files.pythonhosted.org/packages/00/1f/506cf2574673da1adcc8a794ebb85bf857cabe6294523978637e646814de/ijson-3.4.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:8e6b44b6ec45d5b1a0ee9d97e0e65ab7f62258727004cbbe202bf5f198bc21f7", size = 55957 }, + { url = "https://files.pythonhosted.org/packages/dc/3d/a7cd8d8a6de0f3084fe4d457a8f76176e11b013867d1cad16c67d25e8bec/ijson-3.4.0-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b51e239e4cb537929796e840d349fc731fdc0d58b1a0683ce5465ad725321e0f", size = 69394 }, + { url = "https://files.pythonhosted.org/packages/32/51/aa30abc02aabfc41c95887acf5f1f88da569642d7197fbe5aa105545226d/ijson-3.4.0-pp311-pypy311_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ed05d43ec02be8ddb1ab59579761f6656b25d241a77fd74f4f0f7ec09074318a", size = 70377 }, + { url = "https://files.pythonhosted.org/packages/c7/37/7773659b8d8d98b34234e1237352f6b446a3c12941619686c7d4a8a5c69c/ijson-3.4.0-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cfeca1aaa59d93fd0a3718cbe5f7ef0effff85cf837e0bceb71831a47f39cc14", size = 67767 }, + { url = "https://files.pythonhosted.org/packages/cd/1f/dd52a84ed140e31a5d226cd47d98d21aa559aead35ef7bae479eab4c494c/ijson-3.4.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:7ca72ca12e9a1dd4252c97d952be34282907f263f7e28fcdff3a01b83981e837", size = 53864 }, +] + [[package]] name = "importlib-metadata" version = "8.7.0"