Python: Fix spans not correctly nested when using streaming (#5552)

* Fix spans not correctly nested when using streaming

* fix pre commit

* Address comments
This commit is contained in:
Tao Chen
2026-04-29 01:21:28 -07:00
committed by GitHub
Unverified
parent 46ab47b9e1
commit 03e47b5232
4 changed files with 648 additions and 52 deletions
+53 -7
View File
@@ -3,6 +3,7 @@
from __future__ import annotations
import base64
import contextlib
import json
import logging
import re
@@ -2890,6 +2891,7 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
self._inner_stream_source: ResponseStream[Any, Any] | Awaitable[ResponseStream[Any, Any]] | None = None
self._wrap_inner: bool = False
self._map_update: Callable[[Any], UpdateT | Awaitable[UpdateT]] | None = None
self._pull_context_manager_factories: list[Callable[[], contextlib.AbstractContextManager[Any]]] = []
def map(
self,
@@ -3008,11 +3010,18 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
return self
async def __anext__(self) -> UpdateT:
if self._iterator is None:
stream = await self._get_stream()
self._iterator = stream.__aiter__()
try:
update: UpdateT = await self._iterator.__anext__()
with contextlib.ExitStack() as stack:
for factory in self._pull_context_manager_factories:
stack.enter_context(factory())
# Resolve the underlying stream inside the pull contexts so that any
# spans/contexts created during stream resolution (e.g. inner chat
# completion spans created on the first pull of a wrapped agent stream)
# inherit the active context (e.g. an outer agent invoke span).
if self._iterator is None:
stream = await self._get_stream()
self._iterator = stream.__aiter__()
update: UpdateT = await self._iterator.__anext__()
except StopAsyncIteration:
self._consumed = True
await self._run_cleanup_hooks()
@@ -3038,9 +3047,25 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
update = hooked
return update
async def _resolve_stream_with_pull_contexts(self) -> AsyncIterable[UpdateT]:
"""Resolve the underlying stream while activating any registered pull context managers.
Used by ``__await__`` and ``get_final_response`` so that any spans/contexts created
during stream resolution (e.g. when the source is an Awaitable that internally
creates child telemetry spans) inherit the same active context as iterator pulls.
``__anext__`` resolves the stream inside its own ExitStack and so calls ``_get_stream``
directly.
"""
if self._stream is not None:
return await self._get_stream()
with contextlib.ExitStack() as stack:
for factory in self._pull_context_manager_factories:
stack.enter_context(factory())
return await self._get_stream()
def __await__(self) -> Any:
async def _wrap() -> ResponseStream[UpdateT, FinalT]:
await self._get_stream()
await self._resolve_stream_with_pull_contexts()
return self
return _wrap().__await__()
@@ -3064,10 +3089,12 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
"""
if self._wrap_inner:
if self._inner_stream is None:
# Use _get_stream() to resolve the awaitable - this properly handles
# Use _resolve_stream_with_pull_contexts() so that any spans/contexts
# created while resolving the awaitable (e.g. inner telemetry spans)
# inherit the same active context as iterator pulls. This also handles
# the case where _stream_source and _inner_stream_source are the same
# coroutine (e.g., from from_awaitable), avoiding double-await errors.
await self._get_stream()
await self._resolve_stream_with_pull_contexts()
if self._inner_stream is None:
raise RuntimeError("Inner stream not available")
if not self._finalized and not self._consumed:
@@ -3177,6 +3204,25 @@ class ResponseStream(AsyncIterable[UpdateT], Generic[UpdateT, FinalT]):
self._cleanup_hooks.append(hook)
return self
def with_pull_context_manager(
self,
cm_factory: Callable[[], contextlib.AbstractContextManager[Any]],
) -> ResponseStream[UpdateT, FinalT]:
"""Register a context manager factory invoked around each underlying iterator pull.
The factory is called once per ``__anext__`` and the returned context manager wraps
the await of the underlying iterator. This is useful for state that needs to be
active while the inner async work runs - for example, attaching an OpenTelemetry
span to the current context so child spans created by inner code (HTTP clients,
tool execution) are correctly parented.
Because the context manager is entered and exited within the same ``__anext__``
invocation, attach/detach style operations remain symmetric in the same async
context regardless of where the stream is iterated.
"""
self._pull_context_manager_factories.append(cm_factory)
return self
async def _run_cleanup_hooks(self) -> None:
if self._cleanup_run:
return
@@ -26,6 +26,7 @@ from time import perf_counter, time_ns
from typing import TYPE_CHECKING, Any, ClassVar, Final, Generic, Literal, TypedDict, cast, overload
from dotenv import load_dotenv
from opentelemetry import context as otel_context
from opentelemetry import metrics, trace
from . import __version__ as version_info
@@ -1277,27 +1278,8 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
)
if stream:
result_stream = cast(
ResponseStream[ChatResponseUpdate, ChatResponse[Any]],
super_get_response(
messages=messages,
stream=True,
options=opts,
compaction_strategy=compaction_strategy,
tokenizer=tokenizer,
function_invocation_kwargs=function_invocation_kwargs,
client_kwargs=merged_client_kwargs,
),
)
span = _start_streaming_span(attributes, OtelAttr.REQUEST_MODEL)
# Create span directly without trace.use_span() context attachment.
# Streaming spans are closed asynchronously in cleanup hooks, which run
# in a different async context than creation — using use_span() would
# cause "Failed to detach context" errors from OpenTelemetry.
operation = attributes.get(OtelAttr.OPERATION, "operation")
span_name = attributes.get(OtelAttr.REQUEST_MODEL, "unknown")
span = get_tracer().start_span(f"{operation} {span_name}")
span.set_attributes(attributes)
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages:
_capture_messages(
span=span,
@@ -1319,6 +1301,24 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
def _record_duration() -> None:
duration_state["duration"] = perf_counter() - start_time
try:
result_stream = cast(
ResponseStream[ChatResponseUpdate, ChatResponse[Any]],
super_get_response(
messages=messages,
stream=True,
options=opts,
compaction_strategy=compaction_strategy,
tokenizer=tokenizer,
function_invocation_kwargs=function_invocation_kwargs,
client_kwargs=merged_client_kwargs,
),
)
except Exception as exception:
capture_exception(span=span, exception=exception, timestamp=time_ns())
_close_span()
raise
async def _finalize_stream() -> None:
from ._types import ChatResponse
@@ -1357,11 +1357,18 @@ class ChatTelemetryLayer(Generic[OptionsCoT]):
finally:
_close_span()
# Register a weak reference callback to close the span if stream is garbage collected
# without being consumed. This ensures spans don't leak if users don't consume streams.
wrapped_stream: ResponseStream[ChatResponseUpdate, ChatResponse[Any]] = result_stream.with_cleanup_hook(
_record_duration
).with_cleanup_hook(_finalize_stream)
# The pull context manager attaches the span around each underlying iterator pull so
# that child spans created during the pull (e.g. HTTP requests, inner tool execution)
# are parented under this chat span. Attach and detach happen in the same async
# context as the pull, avoiding cross-context cleanup issues. The weakref finalizer
# ensures the span is closed even if the stream is garbage collected without being
# consumed.
wrapped_stream: ResponseStream[ChatResponseUpdate, ChatResponse[Any]] = (
result_stream
.with_cleanup_hook(_record_duration)
.with_cleanup_hook(_finalize_stream)
.with_pull_context_manager(lambda: _activate_span(span))
)
weakref.finalize(wrapped_stream, _close_span)
return wrapped_stream
@@ -1543,23 +1550,8 @@ class AgentTelemetryLayer:
inner_accumulated_usage_token = INNER_ACCUMULATED_USAGE.set({})
if stream:
try:
run_result: object = execute()
if isinstance(run_result, ResponseStream):
result_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]] = run_result # pyright: ignore[reportUnknownVariableType]
elif isinstance(run_result, Awaitable):
result_stream = ResponseStream.from_awaitable(run_result) # type: ignore[arg-type] # pyright: ignore[reportArgumentType]
else:
raise RuntimeError("Streaming telemetry requires a ResponseStream result.")
except Exception:
INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.reset(inner_response_telemetry_captured_fields_token)
INNER_ACCUMULATED_USAGE.reset(inner_accumulated_usage_token)
raise
span = _start_streaming_span(attributes, OtelAttr.AGENT_NAME)
operation = attributes.get(OtelAttr.OPERATION, "operation")
span_name = attributes.get(OtelAttr.AGENT_NAME, "unknown")
span = get_tracer().start_span(f"{operation} {span_name}")
span.set_attributes(attributes)
if OBSERVABILITY_SETTINGS.SENSITIVE_DATA_ENABLED and messages:
_capture_messages(
span=span,
@@ -1581,6 +1573,21 @@ class AgentTelemetryLayer:
def _record_duration() -> None:
duration_state["duration"] = perf_counter() - start_time
try:
run_result: object = execute()
if isinstance(run_result, ResponseStream):
result_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]] = run_result # pyright: ignore[reportUnknownVariableType]
elif isinstance(run_result, Awaitable):
result_stream = ResponseStream.from_awaitable(run_result) # type: ignore[arg-type] # pyright: ignore[reportArgumentType]
else:
raise RuntimeError("Streaming telemetry requires a ResponseStream result.")
except Exception as exception:
capture_exception(span=span, exception=exception, timestamp=time_ns())
INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.reset(inner_response_telemetry_captured_fields_token)
INNER_ACCUMULATED_USAGE.reset(inner_accumulated_usage_token)
_close_span()
raise
async def _finalize_stream() -> None:
from ._types import AgentResponse
@@ -1620,9 +1627,18 @@ class AgentTelemetryLayer:
INNER_ACCUMULATED_USAGE.reset(inner_accumulated_usage_token)
_close_span()
wrapped_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]] = result_stream.with_cleanup_hook(
_record_duration
).with_cleanup_hook(_finalize_stream)
# The pull context manager attaches the span around each underlying iterator pull so
# that child spans created during the pull (e.g. inner chat completion spans from the
# underlying ChatTelemetryLayer) are parented under this agent invoke span. Attach and
# detach happen in the same async context as the pull, avoiding cross-context cleanup
# issues. The weakref finalizer ensures the span is closed even if the stream is
# garbage collected without being consumed.
wrapped_stream: ResponseStream[AgentResponseUpdate, AgentResponse[Any]] = (
result_stream
.with_cleanup_hook(_record_duration)
.with_cleanup_hook(_finalize_stream)
.with_pull_context_manager(lambda: _activate_span(span))
)
weakref.finalize(wrapped_stream, _close_span)
return wrapped_stream
@@ -1809,6 +1825,27 @@ def get_function_span(
)
@contextlib.contextmanager
def _activate_span(span: trace.Span) -> Generator[None]:
"""Attach ``span`` as the current span in the OpenTelemetry context.
Designed to be used as a per-pull context manager registered on a
``ResponseStream`` via ``with_pull_context_manager``: it attaches the span
before each underlying iterator pull and detaches immediately after, so
child spans created during the pull (HTTP clients, inner chat completions,
tool execution) are correctly parented under ``span``.
Because attach and detach happen within the same ``__anext__`` invocation
(and therefore the same async task / contextvars context), there is no risk
of "Failed to detach context" warnings from cross-context cleanup.
"""
token = otel_context.attach(trace.set_span_in_context(span))
try:
yield
finally:
otel_context.detach(token)
@contextlib.contextmanager
def _get_span(
attributes: dict[str, Any],
@@ -1831,6 +1868,29 @@ def _get_span(
yield current_span
def _start_streaming_span(attributes: dict[str, Any], span_name_attribute: str) -> trace.Span:
"""Start a non-current span for a streaming operation.
Unlike :func:`_get_span`, the returned span is not attached to the current
OpenTelemetry context. The caller is responsible for:
- Ending the span via cleanup hooks on the wrapped
:class:`~agent_framework._types.ResponseStream`.
- Activating the span around each iterator pull via
:func:`_activate_span` registered with ``with_pull_context_manager`` so
that child spans created during stream production inherit it as parent.
Streaming spans are closed asynchronously in cleanup hooks that run in a
different async context than creation, so attaching the span at creation
time would cause "Failed to detach context" errors from OpenTelemetry.
"""
operation = attributes.get(OtelAttr.OPERATION, "operation")
span_name = attributes.get(span_name_attribute, "unknown")
span = get_tracer().start_span(f"{operation} {span_name}")
span.set_attributes(attributes)
return span
def _get_instructions_from_options(options: Any) -> str | list[str] | None:
"""Extract instructions from options dict."""
if options is None:
@@ -3313,3 +3313,487 @@ async def test_agent_invoke_span_aggregates_usage_on_max_iterations_exhaustion(s
# The invoke_agent span must aggregate usage from the in-loop call and the final exhaustion call
assert agent_span.attributes.get(OtelAttr.INPUT_TOKENS) == 500
assert agent_span.attributes.get(OtelAttr.OUTPUT_TOKENS) == 100
# region Test span nesting (parent-child relationships)
@pytest.mark.parametrize("stream", [False, True])
async def test_chat_span_nested_under_agent_span(span_exporter: InMemorySpanExporter, stream: bool):
"""The inner chat span must be a child of the outer agent invoke span."""
class NestedChatClient(ChatTelemetryLayer, BaseChatClient[Any]):
def service_url(self):
return "https://test.example.com"
def _inner_get_response(
self, *, messages: MutableSequence[Message], stream: bool, options: dict[str, Any], **kwargs: Any
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
if stream:
async def _stream() -> AsyncIterable[ChatResponseUpdate]:
yield ChatResponseUpdate(contents=[Content.from_text("Hello")], role="assistant")
yield ChatResponseUpdate(
contents=[Content.from_text(" world")], role="assistant", finish_reason="stop"
)
def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse:
return ChatResponse(
messages=[Message(role="assistant", contents=["Hello world"])],
response_id="resp_1",
usage_details=UsageDetails(input_token_count=3, output_token_count=4),
finish_reason="stop",
)
return ResponseStream(_stream(), finalizer=_finalize)
async def _get() -> ChatResponse:
return ChatResponse(
messages=[Message(role="assistant", contents=["Hello world"])],
response_id="resp_1",
usage_details=UsageDetails(input_token_count=3, output_token_count=4),
finish_reason="stop",
)
return _get()
agent = Agent(
client=NestedChatClient(),
id="nested_agent_id",
name="nested_agent",
default_options={"model": "NestedModel"},
)
span_exporter.clear()
if stream:
result_stream = agent.run("Test message", stream=True)
async for _ in result_stream:
pass
await result_stream.get_final_response()
else:
await agent.run("Test message")
spans = span_exporter.get_finished_spans()
assert len(spans) == 2
span_by_op = {s.attributes[OtelAttr.OPERATION.value]: s for s in spans}
agent_span = span_by_op[OtelAttr.AGENT_INVOKE_OPERATION]
chat_span = span_by_op[OtelAttr.CHAT_COMPLETION_OPERATION]
# Agent span has no parent (it is the root)
assert agent_span.parent is None
# Chat span's parent must be the agent span
assert chat_span.parent is not None
assert chat_span.parent.span_id == agent_span.context.span_id
assert chat_span.parent.trace_id == agent_span.context.trace_id
# Both spans must share the same trace
assert chat_span.context.trace_id == agent_span.context.trace_id
@pytest.mark.parametrize("stream", [False, True])
async def test_function_call_spans_nested_under_agent_span(span_exporter: InMemorySpanExporter, stream: bool):
"""All inner spans (chat completions and execute_tool) must be children of the agent span."""
from agent_framework import Content
from agent_framework._tools import FunctionInvocationLayer
@tool(name="get_weather", description="Get the weather for a location")
def get_weather(location: str) -> str:
return f"The weather in {location} is sunny."
class NestedToolChatClient(FunctionInvocationLayer, ChatTelemetryLayer, BaseChatClient[Any]):
def __init__(self) -> None:
super().__init__()
self.call_count = 0
def service_url(self):
return "https://test.example.com"
def _inner_get_response(
self, *, messages: MutableSequence[Message], stream: bool, options: dict[str, Any], **kwargs: Any
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
self.call_count += 1
is_first = self.call_count == 1
if stream:
async def _stream() -> AsyncIterable[ChatResponseUpdate]:
if is_first:
yield ChatResponseUpdate(
contents=[
Content.from_function_call(
call_id="call_123",
name="get_weather",
arguments='{"location": "Seattle"}',
)
],
role="assistant",
)
else:
yield ChatResponseUpdate(
contents=[Content.from_text("The weather in Seattle is sunny!")],
role="assistant",
finish_reason="stop",
)
def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse:
return ChatResponse.from_updates(updates)
return ResponseStream(_stream(), finalizer=_finalize)
async def _get() -> ChatResponse:
if is_first:
return ChatResponse(
messages=[
Message(
role="assistant",
contents=[
Content.from_function_call(
call_id="call_123",
name="get_weather",
arguments='{"location": "Seattle"}',
)
],
)
],
)
return ChatResponse(
messages=[Message(role="assistant", contents=["The weather in Seattle is sunny!"])],
finish_reason="stop",
)
return _get()
agent = Agent(
client=NestedToolChatClient(),
id="tool_agent_id",
name="tool_agent",
default_options={"model": "ToolModel", "tools": [get_weather], "tool_choice": "auto"},
)
span_exporter.clear()
if stream:
result_stream = agent.run("What's the weather in Seattle?", stream=True)
async for _ in result_stream:
pass
await result_stream.get_final_response()
else:
await agent.run("What's the weather in Seattle?")
spans = span_exporter.get_finished_spans()
invoke_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.AGENT_INVOKE_OPERATION]
chat_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.CHAT_COMPLETION_OPERATION]
tool_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.TOOL_EXECUTION_OPERATION]
assert len(invoke_spans) == 1, f"Expected 1 invoke_agent span, got {len(invoke_spans)}"
assert len(chat_spans) == 2, f"Expected 2 chat spans, got {len(chat_spans)}"
assert len(tool_spans) == 1, f"Expected 1 execute_tool span, got {len(tool_spans)}"
agent_span = invoke_spans[0]
assert agent_span.parent is None
# All inner spans must be parented under the agent invoke span
for inner in (*chat_spans, *tool_spans):
assert inner.parent is not None, f"Span {inner.name} has no parent"
assert inner.parent.span_id == agent_span.context.span_id, (
f"Span {inner.name} parent={inner.parent.span_id} != agent={agent_span.context.span_id}"
)
assert inner.context.trace_id == agent_span.context.trace_id
@pytest.mark.parametrize("stream", [False, True])
async def test_chat_span_nested_under_explicit_outer_span(
span_exporter: InMemorySpanExporter, mock_chat_client, stream: bool
):
"""Chat telemetry spans (including streaming) must inherit a user-provided outer span as parent."""
from agent_framework.observability import get_tracer
client = mock_chat_client()
span_exporter.clear()
tracer = get_tracer()
with tracer.start_as_current_span("outer") as outer_span:
outer_ctx = outer_span.get_span_context()
if stream:
stream_obj = client.get_response(
stream=True, messages=[Message(role="user", contents=["Test"])], options={"model": "Test"}
)
async for _ in stream_obj:
pass
await stream_obj.get_final_response()
else:
await client.get_response(messages=[Message(role="user", contents=["Test"])], options={"model": "Test"})
spans = span_exporter.get_finished_spans()
chat_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.CHAT_COMPLETION_OPERATION]
assert len(chat_spans) == 1
chat_span = chat_spans[0]
assert chat_span.parent is not None
assert chat_span.parent.span_id == outer_ctx.span_id
assert chat_span.context.trace_id == outer_ctx.trace_id
@pytest.mark.parametrize("stream", [False, True])
async def test_http_span_nested_under_chat_span(span_exporter: InMemorySpanExporter, stream: bool):
"""A span created inside ``_inner_get_response`` (e.g. an HTTP client call to the LLM provider)
must be parented under the chat completion span.
This validates that the chat span context is active while the inner client implementation
runs, both for non-streaming responses and while streaming updates are being pulled.
"""
from agent_framework.observability import get_tracer
tracer = get_tracer()
class HttpEmittingClient(ChatTelemetryLayer, BaseChatClient[Any]):
def service_url(self):
return "https://test.example.com"
def _inner_get_response(
self, *, messages: MutableSequence[Message], stream: bool, options: dict[str, Any], **kwargs: Any
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
if stream:
async def _stream() -> AsyncIterable[ChatResponseUpdate]:
# Simulate an HTTP request to the model provider while producing the stream.
with tracer.start_as_current_span("HTTP POST"):
pass
yield ChatResponseUpdate(contents=[Content.from_text("hi")], role="assistant", finish_reason="stop")
def _finalize(updates: Sequence[ChatResponseUpdate]) -> ChatResponse:
return ChatResponse.from_updates(updates)
return ResponseStream(_stream(), finalizer=_finalize)
async def _get() -> ChatResponse:
# Simulate an HTTP request to the model provider during the call.
with tracer.start_as_current_span("HTTP POST"):
pass
return ChatResponse(
messages=[Message(role="assistant", contents=["done"])],
usage_details=UsageDetails(input_token_count=1, output_token_count=1),
)
return _get()
span_exporter.clear()
client = HttpEmittingClient()
if stream:
result_stream = client.get_response(
stream=True, messages=[Message(role="user", contents=["Test"])], options={"model": "Test"}
)
async for _ in result_stream:
pass
await result_stream.get_final_response()
else:
await client.get_response(messages=[Message(role="user", contents=["Test"])], options={"model": "Test"})
spans = span_exporter.get_finished_spans()
chat_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.CHAT_COMPLETION_OPERATION]
http_spans = [s for s in spans if s.name == "HTTP POST"]
assert len(chat_spans) == 1
assert len(http_spans) == 1
chat_span = chat_spans[0]
http_span = http_spans[0]
assert http_span.parent is not None
assert http_span.parent.span_id == chat_span.context.span_id
assert http_span.context.trace_id == chat_span.context.trace_id
# region Test ResponseStream.with_pull_context_manager
async def test_with_pull_context_manager_enters_and_exits_per_pull():
"""The registered factory is entered and exited symmetrically around each iterator pull."""
import contextlib
events: list[str] = []
@contextlib.contextmanager
def cm():
events.append("enter")
try:
yield
finally:
events.append("exit")
async def src() -> AsyncIterable[int]:
yield 1
yield 2
stream: ResponseStream[int, list[int]] = ResponseStream(src(), finalizer=lambda updates: list(updates))
stream.with_pull_context_manager(cm)
pulled = [u async for u in stream]
assert pulled == [1, 2]
# Enter/exit must be balanced and there must be at least one pair per yielded update.
assert events.count("enter") == events.count("exit")
assert events.count("enter") >= 2
# Verify symmetric ordering (no overlapping pairs).
for i in range(0, len(events), 2):
assert events[i] == "enter"
assert events[i + 1] == "exit"
async def test_with_pull_context_manager_exits_on_iteration_error():
"""The pull context is exited even when the underlying stream raises mid-iteration."""
import contextlib
events: list[str] = []
@contextlib.contextmanager
def cm():
events.append("enter")
try:
yield
finally:
events.append("exit")
async def src() -> AsyncIterable[int]:
yield 1
raise RuntimeError("boom")
stream: ResponseStream[int, list[int]] = ResponseStream(src(), finalizer=lambda updates: list(updates))
stream.with_pull_context_manager(cm)
with pytest.raises(RuntimeError, match="boom"):
async for _ in stream:
pass
# Enter/exit balanced even on the failing pull.
assert events.count("enter") == events.count("exit")
assert events.count("enter") >= 2
async def test_with_pull_context_manager_wraps_stream_resolution_via_await():
"""Awaiting a ``from_awaitable`` stream resolves the inner stream under the pull contexts."""
import contextlib
events: list[str] = []
@contextlib.contextmanager
def cm():
events.append("enter")
try:
yield
finally:
events.append("exit")
async def inner() -> AsyncIterable[int]:
yield 1
async def make_stream() -> ResponseStream[int, list[int]]:
# Record that we resolve while a pull context is active.
events.append("resolving")
return ResponseStream(inner(), finalizer=lambda updates: list(updates))
stream: ResponseStream[int, list[int]] = ResponseStream.from_awaitable(make_stream())
stream.with_pull_context_manager(cm)
await stream # Triggers _resolve_stream_with_pull_contexts via __await__
assert "resolving" in events
resolve_index = events.index("resolving")
assert events[resolve_index - 1] == "enter" # Pull context active during resolution
# region Test streaming telemetry error paths
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
async def test_chat_streaming_super_failure_closes_span(span_exporter: InMemorySpanExporter, enable_sensitive_data):
"""If the underlying client raises synchronously when constructing the stream, the chat
span is ended and the exception is recorded (no span leak)."""
class FailingClient(ChatTelemetryLayer, BaseChatClient[Any]):
def service_url(self):
return "https://test.example.com"
def _inner_get_response(
self, *, messages: MutableSequence[Message], stream: bool, options: dict[str, Any], **kwargs: Any
) -> Awaitable[ChatResponse] | ResponseStream[ChatResponseUpdate, ChatResponse]:
raise RuntimeError("inner failed")
span_exporter.clear()
client = FailingClient()
with pytest.raises(RuntimeError, match="inner failed"):
client.get_response(stream=True, messages=[Message(role="user", contents=["Test"])], options={"model": "Test"})
spans = span_exporter.get_finished_spans()
chat_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.CHAT_COMPLETION_OPERATION]
assert len(chat_spans) == 1
assert chat_spans[0].status.status_code == StatusCode.ERROR
@pytest.mark.parametrize("enable_sensitive_data", [True], indirect=True)
async def test_agent_streaming_execute_failure_closes_span_and_resets_contextvars(
span_exporter: InMemorySpanExporter, enable_sensitive_data
):
"""If ``execute()`` raises synchronously during streaming agent invocation, the agent span is
ended, the exception is recorded, and the telemetry contextvars are reset."""
from agent_framework.observability import (
INNER_ACCUMULATED_USAGE,
INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS,
)
class _FailingExecuteAgent:
AGENT_PROVIDER_NAME = "test_provider"
def __init__(self):
self._id = "failing_execute"
self._name = "Failing Execute"
self._description = "Agent whose stream call raises synchronously"
self._default_options: dict[str, Any] = {}
@property
def id(self):
return self._id
@property
def name(self):
return self._name
@property
def description(self):
return self._description
@property
def default_options(self):
return self._default_options
def run(self, messages=None, *, stream: bool = False, session=None, **kwargs):
if stream:
raise RuntimeError("execute failed")
raise NotImplementedError
class FailingExecuteAgent(AgentTelemetryLayer, _FailingExecuteAgent):
pass
# Sentinel values to detect that contextvars were reset to their pre-call state.
sentinel_fields: set[str] = set()
sentinel_usage: dict[str, Any] = {}
fields_token = INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.set(sentinel_fields)
usage_token = INNER_ACCUMULATED_USAGE.set(sentinel_usage)
try:
agent = FailingExecuteAgent()
span_exporter.clear()
with pytest.raises(RuntimeError, match="execute failed"):
agent.run(messages="Hello", stream=True)
# Contextvars must be back to the sentinel values registered before the call.
assert INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.get() is sentinel_fields
assert INNER_ACCUMULATED_USAGE.get() is sentinel_usage
finally:
INNER_ACCUMULATED_USAGE.reset(usage_token)
INNER_RESPONSE_TELEMETRY_CAPTURED_FIELDS.reset(fields_token)
spans = span_exporter.get_finished_spans()
agent_spans = [s for s in spans if s.attributes.get(OtelAttr.OPERATION.value) == OtelAttr.AGENT_INVOKE_OPERATION]
assert len(agent_spans) == 1
assert agent_spans[0].status.status_code == StatusCode.ERROR
+7 -1
View File
@@ -1,5 +1,5 @@
version = 1
revision = 3
revision = 2
requires-python = ">=3.10"
resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'darwin'",
@@ -2608,6 +2608,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/38/3f/9859f655d11901e7b2996c6e3d33e0caa9a1d4572c3bc61ed0faa64b2f4c/greenlet-3.3.2-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:9bc885b89709d901859cf95179ec9f6bb67a3d2bb1f0e88456461bd4b7f8fd0d", size = 277747, upload-time = "2026-02-20T20:16:21.325Z" },
{ url = "https://files.pythonhosted.org/packages/fb/07/cb284a8b5c6498dbd7cba35d31380bb123d7dceaa7907f606c8ff5993cbf/greenlet-3.3.2-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b568183cf65b94919be4438dc28416b234b678c608cafac8874dfeeb2a9bbe13", size = 579202, upload-time = "2026-02-20T20:47:28.955Z" },
{ url = "https://files.pythonhosted.org/packages/ed/45/67922992b3a152f726163b19f890a85129a992f39607a2a53155de3448b8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:527fec58dc9f90efd594b9b700662ed3fb2493c2122067ac9c740d98080a620e", size = 590620, upload-time = "2026-02-20T20:55:55.581Z" },
{ url = "https://files.pythonhosted.org/packages/03/5f/6e2a7d80c353587751ef3d44bb947f0565ec008a2e0927821c007e96d3a7/greenlet-3.3.2-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508c7f01f1791fbc8e011bd508f6794cb95397fdb198a46cb6635eb5b78d85a7", size = 602132, upload-time = "2026-02-20T21:02:43.261Z" },
{ url = "https://files.pythonhosted.org/packages/ad/55/9f1ebb5a825215fadcc0f7d5073f6e79e3007e3282b14b22d6aba7ca6cb8/greenlet-3.3.2-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ad0c8917dd42a819fe77e6bdfcb84e3379c0de956469301d9fd36427a1ca501f", size = 591729, upload-time = "2026-02-20T20:20:58.395Z" },
{ url = "https://files.pythonhosted.org/packages/24/b4/21f5455773d37f94b866eb3cf5caed88d6cea6dd2c6e1f9c34f463cba3ec/greenlet-3.3.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:97245cc10e5515dbc8c3104b2928f7f02b6813002770cfaffaf9a6e0fc2b94ef", size = 1551946, upload-time = "2026-02-20T20:49:31.102Z" },
{ url = "https://files.pythonhosted.org/packages/00/68/91f061a926abead128fe1a87f0b453ccf07368666bd59ffa46016627a930/greenlet-3.3.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8c1fdd7d1b309ff0da81d60a9688a8bd044ac4e18b250320a96fc68d31c209ca", size = 1618494, upload-time = "2026-02-20T20:21:06.541Z" },
@@ -2615,6 +2616,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" },
{ url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" },
{ url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" },
{ url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" },
{ url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" },
{ url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" },
{ url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" },
@@ -2623,6 +2625,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" },
{ url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" },
{ url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" },
{ url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" },
{ url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" },
{ url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" },
{ url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" },
@@ -2631,6 +2634,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" },
{ url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" },
{ url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" },
{ url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" },
{ url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" },
{ url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" },
{ url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" },
@@ -2639,6 +2643,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" },
{ url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" },
{ url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" },
{ url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" },
{ url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" },
{ url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" },
@@ -2647,6 +2652,7 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" },
{ url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" },
{ url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" },
{ url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" },
{ url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" },
{ url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" },
{ url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },