backspin 0.5.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- backspin/__init__.py +38 -0
- backspin/cli.py +379 -0
- backspin/cost.py +85 -0
- backspin/diff.py +121 -0
- backspin/export.py +71 -0
- backspin/fakes.py +151 -0
- backspin/integrations/__init__.py +0 -0
- backspin/integrations/anthropic.py +279 -0
- backspin/integrations/openai.py +298 -0
- backspin/proxy.py +367 -0
- backspin/py.typed +0 -0
- backspin/pytest_plugin.py +83 -0
- backspin/recorder.py +346 -0
- backspin/redaction.py +49 -0
- backspin/replay.py +327 -0
- backspin/runfile.py +182 -0
- backspin/server.py +91 -0
- backspin/share.py +58 -0
- backspin/testing.py +121 -0
- backspin/tui.py +130 -0
- backspin/ui/app.js +327 -0
- backspin/ui/i18n.js +75 -0
- backspin/ui/index.html +68 -0
- backspin/ui/style.css +214 -0
- backspin-0.5.1.dist-info/METADATA +308 -0
- backspin-0.5.1.dist-info/RECORD +29 -0
- backspin-0.5.1.dist-info/WHEEL +4 -0
- backspin-0.5.1.dist-info/entry_points.txt +5 -0
- backspin-0.5.1.dist-info/licenses/LICENSE +21 -0
backspin/fakes.py
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"""Duck-typed fakes for the slice of the OpenAI SDK that backspin records.
|
|
2
|
+
|
|
3
|
+
Used by :mod:`backspin.testing` (scripted clients for demos and your own
|
|
4
|
+
tests) and by :mod:`backspin.replay` (replaying recordings without the SDK).
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from types import SimpleNamespace
|
|
9
|
+
from typing import Any, Dict, Iterator, List, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _ns(obj: Any) -> Any:
|
|
13
|
+
if isinstance(obj, dict):
|
|
14
|
+
return SimpleNamespace(**{k: _ns(v) for k, v in obj.items()})
|
|
15
|
+
if isinstance(obj, list):
|
|
16
|
+
return [_ns(x) for x in obj]
|
|
17
|
+
return obj
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def message_data(content: Optional[str], *, role: str = "assistant",
|
|
21
|
+
tool_calls: Optional[List[Dict[str, Any]]] = None) -> Dict[str, Any]:
|
|
22
|
+
"""Build a completion-shaped dict from just an assistant message."""
|
|
23
|
+
msg: Dict[str, Any] = {"role": role, "content": content}
|
|
24
|
+
if tool_calls:
|
|
25
|
+
msg["tool_calls"] = tool_calls
|
|
26
|
+
return {
|
|
27
|
+
"object": "chat.completion",
|
|
28
|
+
"model": "fake-model",
|
|
29
|
+
"choices": [{"index": 0, "message": msg, "finish_reason": "stop"}],
|
|
30
|
+
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class FakeResponse:
|
|
35
|
+
"""Stands in for ``openai``'s ChatCompletion."""
|
|
36
|
+
|
|
37
|
+
def __init__(self, data: Dict[str, Any]):
|
|
38
|
+
self._data = data
|
|
39
|
+
self.id = data.get("id", "chatcmpl-fake")
|
|
40
|
+
self.object = data.get("object", "chat.completion")
|
|
41
|
+
self.model = data.get("model", "fake-model")
|
|
42
|
+
self.choices = _ns(data.get("choices", []))
|
|
43
|
+
usage = data.get("usage")
|
|
44
|
+
self.usage = _ns(usage) if usage else None
|
|
45
|
+
|
|
46
|
+
def model_dump(self, **_kw: Any) -> Dict[str, Any]:
|
|
47
|
+
return self._data
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class FakeChunk:
|
|
51
|
+
"""Stands in for one ``openai`` ChatCompletionChunk in streaming mode."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, *, model: str, delta: Optional[Dict[str, Any]] = None,
|
|
54
|
+
finish_reason: Optional[str] = None, usage: Optional[Dict] = None):
|
|
55
|
+
self.model = model
|
|
56
|
+
self.object = "chat.completion.chunk"
|
|
57
|
+
delta = dict(delta or {})
|
|
58
|
+
delta.setdefault("content", None)
|
|
59
|
+
self.choices = [
|
|
60
|
+
SimpleNamespace(index=0, delta=_ns(delta), finish_reason=finish_reason)
|
|
61
|
+
]
|
|
62
|
+
self.usage = usage
|
|
63
|
+
|
|
64
|
+
def model_dump(self, **_kw: Any) -> Dict[str, Any]:
|
|
65
|
+
return {
|
|
66
|
+
"model": self.model,
|
|
67
|
+
"object": self.object,
|
|
68
|
+
"choices": [
|
|
69
|
+
{
|
|
70
|
+
"index": 0,
|
|
71
|
+
"delta": self.choices[0].delta.__dict__,
|
|
72
|
+
"finish_reason": self.choices[0].finish_reason,
|
|
73
|
+
}
|
|
74
|
+
],
|
|
75
|
+
"usage": self.usage,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def stream_chunks(data: Dict[str, Any], pieces: int = 3) -> Iterator[FakeChunk]:
|
|
80
|
+
"""Turn a completion-shaped dict into an iterable of chunk-like objects."""
|
|
81
|
+
model = data.get("model", "fake-model")
|
|
82
|
+
choice = (data.get("choices") or [{}])[0]
|
|
83
|
+
msg = choice.get("message", {})
|
|
84
|
+
content = msg.get("content") or ""
|
|
85
|
+
tool_calls = msg.get("tool_calls")
|
|
86
|
+
if tool_calls:
|
|
87
|
+
yield FakeChunk(model=model, delta={"role": "assistant", "tool_calls": tool_calls})
|
|
88
|
+
if content:
|
|
89
|
+
step = max(1, len(content) // pieces)
|
|
90
|
+
for i in range(0, len(content), step):
|
|
91
|
+
yield FakeChunk(model=model, delta={"content": content[i : i + step]})
|
|
92
|
+
yield FakeChunk(model=model, finish_reason="stop", usage=data.get("usage"))
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---- Anthropic-shaped fakes -------------------------------------------------
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class FakeAnthropicMessage:
|
|
99
|
+
"""Stands in for anthropic's Message object."""
|
|
100
|
+
|
|
101
|
+
def __init__(self, data: Dict[str, Any]):
|
|
102
|
+
self._data = data
|
|
103
|
+
self.id = data.get("id", "msg_fake")
|
|
104
|
+
self.type = data.get("type", "message")
|
|
105
|
+
self.role = data.get("role", "assistant")
|
|
106
|
+
self.model = data.get("model", "fake-claude")
|
|
107
|
+
self.content = _ns(data.get("content", []))
|
|
108
|
+
self.stop_reason = data.get("stop_reason")
|
|
109
|
+
usage = data.get("usage")
|
|
110
|
+
self.usage = _ns(usage) if usage else None
|
|
111
|
+
|
|
112
|
+
def model_dump(self, **_kw: Any) -> Dict[str, Any]:
|
|
113
|
+
return self._data
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class FakeAnthropicEvent:
|
|
117
|
+
"""Stands in for one anthropic stream event."""
|
|
118
|
+
|
|
119
|
+
def __init__(self, data: Dict[str, Any]):
|
|
120
|
+
self._data = data
|
|
121
|
+
|
|
122
|
+
def model_dump(self, **_kw: Any) -> Dict[str, Any]:
|
|
123
|
+
return self._data
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def anthropic_stream_events(data: Dict[str, Any]) -> List[Dict[str, Any]]:
|
|
127
|
+
"""Synthesize an anthropic SSE event sequence for a message payload."""
|
|
128
|
+
model = data.get("model", "fake-claude")
|
|
129
|
+
usage = data.get("usage") or {}
|
|
130
|
+
events: List[Dict[str, Any]] = [
|
|
131
|
+
{"type": "message_start", "message": {
|
|
132
|
+
"id": data.get("id", "msg_fake"), "type": "message",
|
|
133
|
+
"role": "assistant", "model": model,
|
|
134
|
+
"usage": {"input_tokens": usage.get("input_tokens", 8)},
|
|
135
|
+
}}
|
|
136
|
+
]
|
|
137
|
+
for index, block in enumerate(data.get("content") or []):
|
|
138
|
+
if block.get("type") == "text":
|
|
139
|
+
events.append({"type": "content_block_start", "index": index,
|
|
140
|
+
"content_block": {"type": "text", "text": ""}})
|
|
141
|
+
events.append({"type": "content_block_delta", "index": index,
|
|
142
|
+
"delta": {"type": "text_delta", "text": block.get("text", "")}})
|
|
143
|
+
else:
|
|
144
|
+
events.append({"type": "content_block_start", "index": index,
|
|
145
|
+
"content_block": dict(block)})
|
|
146
|
+
events.append({"type": "content_block_stop", "index": index})
|
|
147
|
+
events.append({"type": "message_delta",
|
|
148
|
+
"delta": {"stop_reason": data.get("stop_reason", "end_turn")},
|
|
149
|
+
"usage": {"output_tokens": usage.get("output_tokens", 4)}})
|
|
150
|
+
events.append({"type": "message_stop"})
|
|
151
|
+
return events
|
|
File without changes
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Capture Anthropic-SDK-shaped messages.create calls into a Recorder.
|
|
2
|
+
|
|
3
|
+
Duck-typed like the OpenAI integration: works with the real
|
|
4
|
+
``anthropic.Anthropic`` / ``anthropic.AsyncAnthropic`` clients and anything
|
|
5
|
+
shaped the same (see :mod:`backspin.testing`).
|
|
6
|
+
|
|
7
|
+
Anthropic events are recorded as ``kind="llm"`` with ``provider="anthropic"``
|
|
8
|
+
and the native request/response payloads preserved; ``usage`` is normalized
|
|
9
|
+
to ``prompt_tokens``/``completion_tokens`` so cost estimation and diffs work
|
|
10
|
+
across providers.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import functools
|
|
15
|
+
import inspect
|
|
16
|
+
import time
|
|
17
|
+
from typing import Any, Callable, Dict, Optional
|
|
18
|
+
|
|
19
|
+
from ..runfile import jsonable
|
|
20
|
+
|
|
21
|
+
_KEEP = {
|
|
22
|
+
"model", "messages", "system", "max_tokens", "tools", "tool_choice",
|
|
23
|
+
"temperature", "top_p", "top_k", "stream", "metadata", "stop_sequences",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _clean_kwargs(kwargs: Dict[str, Any]) -> Dict[str, Any]:
|
|
28
|
+
return {k: jsonable(v) for k, v in kwargs.items() if k in _KEEP}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _ms(t0: float) -> float:
|
|
32
|
+
return (time.perf_counter() - t0) * 1000
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _usage_of(usage: Any) -> Optional[Dict[str, Any]]:
|
|
36
|
+
"""Normalize anthropic usage {input_tokens, output_tokens} for cost."""
|
|
37
|
+
if usage is None:
|
|
38
|
+
return None
|
|
39
|
+
get = usage.get if isinstance(usage, dict) else lambda k: getattr(usage, k, None)
|
|
40
|
+
prompt = get("input_tokens")
|
|
41
|
+
completion = get("output_tokens")
|
|
42
|
+
if prompt is None and completion is None:
|
|
43
|
+
return None
|
|
44
|
+
return {
|
|
45
|
+
"prompt_tokens": prompt or 0,
|
|
46
|
+
"completion_tokens": completion or 0,
|
|
47
|
+
"input_tokens": prompt or 0,
|
|
48
|
+
"output_tokens": completion or 0,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _response_payload(resp: Any) -> Dict[str, Any]:
|
|
53
|
+
if hasattr(resp, "model_dump"):
|
|
54
|
+
try:
|
|
55
|
+
return resp.model_dump(exclude_none=True)
|
|
56
|
+
except TypeError:
|
|
57
|
+
return resp.model_dump()
|
|
58
|
+
return {"repr": repr(resp)}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class _Acc:
|
|
62
|
+
"""Accumulated view of a streamed anthropic message."""
|
|
63
|
+
|
|
64
|
+
def __init__(self, fallback_model: Optional[str]) -> None:
|
|
65
|
+
self.model = fallback_model
|
|
66
|
+
self.blocks: Dict[int, Dict[str, Any]] = {}
|
|
67
|
+
self.input_tokens: Optional[int] = None
|
|
68
|
+
self.output_tokens: Optional[int] = None
|
|
69
|
+
self.stop_reason: Optional[str] = None
|
|
70
|
+
|
|
71
|
+
def absorb(self, event: Any) -> None:
|
|
72
|
+
data = event.model_dump() if hasattr(event, "model_dump") else event
|
|
73
|
+
if not isinstance(data, dict):
|
|
74
|
+
return
|
|
75
|
+
etype = data.get("type")
|
|
76
|
+
if etype == "message_start":
|
|
77
|
+
message = data.get("message") or {}
|
|
78
|
+
if message.get("model"):
|
|
79
|
+
self.model = message["model"]
|
|
80
|
+
usage = _usage_of(message.get("usage"))
|
|
81
|
+
if usage:
|
|
82
|
+
self.input_tokens = usage["prompt_tokens"]
|
|
83
|
+
elif etype == "content_block_start":
|
|
84
|
+
block = data.get("content_block") or {}
|
|
85
|
+
self.blocks[data.get("index", 0)] = dict(block)
|
|
86
|
+
elif etype == "content_block_delta":
|
|
87
|
+
block = self.blocks.setdefault(data.get("index", 0), {"type": "text", "text": ""})
|
|
88
|
+
delta = data.get("delta") or {}
|
|
89
|
+
if delta.get("type") == "text_delta":
|
|
90
|
+
block["type"] = "text"
|
|
91
|
+
block["text"] = block.get("text", "") + (delta.get("text") or "")
|
|
92
|
+
elif delta.get("type") == "input_json_delta":
|
|
93
|
+
block["type"] = "tool_use"
|
|
94
|
+
prior = block.get("partial_json", "")
|
|
95
|
+
block["partial_json"] = prior + (delta.get("partial_json") or "")
|
|
96
|
+
elif etype == "message_delta":
|
|
97
|
+
stop = (data.get("delta") or {}).get("stop_reason")
|
|
98
|
+
if stop:
|
|
99
|
+
self.stop_reason = stop
|
|
100
|
+
usage = _usage_of(data.get("usage"))
|
|
101
|
+
if usage:
|
|
102
|
+
self.output_tokens = usage["completion_tokens"]
|
|
103
|
+
|
|
104
|
+
def payload(self) -> Dict[str, Any]:
|
|
105
|
+
content = []
|
|
106
|
+
for idx in sorted(self.blocks):
|
|
107
|
+
block = dict(self.blocks[idx])
|
|
108
|
+
if block.get("type") == "tool_use" and block.get("partial_json"):
|
|
109
|
+
import json as _json
|
|
110
|
+
|
|
111
|
+
try:
|
|
112
|
+
block["input"] = _json.loads(block.pop("partial_json"))
|
|
113
|
+
except ValueError:
|
|
114
|
+
block["input"] = block.pop("partial_json")
|
|
115
|
+
content.append(block)
|
|
116
|
+
usage: Dict[str, Any] = {}
|
|
117
|
+
if self.input_tokens is not None:
|
|
118
|
+
usage["input_tokens"] = self.input_tokens
|
|
119
|
+
if self.output_tokens is not None:
|
|
120
|
+
usage["output_tokens"] = self.output_tokens
|
|
121
|
+
return {
|
|
122
|
+
"object": "anthropic.message",
|
|
123
|
+
"reconstructed_from_stream": True,
|
|
124
|
+
"model": self.model,
|
|
125
|
+
"role": "assistant",
|
|
126
|
+
"content": content,
|
|
127
|
+
"stop_reason": self.stop_reason,
|
|
128
|
+
"usage": usage or None,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def capture_anthropic(recorder: Any, client: Any) -> Any:
|
|
133
|
+
"""Patch ``client.messages.create`` so every call is recorded."""
|
|
134
|
+
messages = client.messages
|
|
135
|
+
original = messages.create
|
|
136
|
+
is_async = inspect.iscoroutinefunction(original) or type(
|
|
137
|
+
messages
|
|
138
|
+
).__name__.lower().startswith("async")
|
|
139
|
+
if is_async:
|
|
140
|
+
messages.create = _async_wrapper(recorder, original)
|
|
141
|
+
else:
|
|
142
|
+
messages.create = _sync_wrapper(recorder, original)
|
|
143
|
+
return client
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _record(recorder: Any, kwargs: Dict[str, Any], *, response: Any = None,
|
|
147
|
+
usage: Any = None, error: Any = None, duration_ms: float,
|
|
148
|
+
model: Any = None) -> None:
|
|
149
|
+
recorder.record_llm(
|
|
150
|
+
request=_clean_kwargs(kwargs),
|
|
151
|
+
response=response,
|
|
152
|
+
usage=usage,
|
|
153
|
+
error=error,
|
|
154
|
+
duration_ms=duration_ms,
|
|
155
|
+
model=model or kwargs.get("model"),
|
|
156
|
+
provider="anthropic",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _sync_wrapper(recorder: Any, original: Callable) -> Callable:
|
|
161
|
+
@functools.wraps(original)
|
|
162
|
+
def create(*args: Any, **kwargs: Any):
|
|
163
|
+
t0 = time.perf_counter()
|
|
164
|
+
try:
|
|
165
|
+
resp = original(*args, **kwargs)
|
|
166
|
+
except Exception as exc:
|
|
167
|
+
_record(recorder, kwargs, error=exc, duration_ms=_ms(t0))
|
|
168
|
+
raise
|
|
169
|
+
if kwargs.get("stream"):
|
|
170
|
+
return _StreamRecorder(recorder, kwargs, resp, t0)
|
|
171
|
+
usage = _usage_of(getattr(resp, "usage", None))
|
|
172
|
+
_record(recorder, kwargs, response=_response_payload(resp), usage=usage,
|
|
173
|
+
duration_ms=_ms(t0), model=getattr(resp, "model", None))
|
|
174
|
+
return resp
|
|
175
|
+
|
|
176
|
+
return create
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _async_wrapper(recorder: Any, original: Callable) -> Callable:
|
|
180
|
+
@functools.wraps(original)
|
|
181
|
+
async def create(*args: Any, **kwargs: Any):
|
|
182
|
+
t0 = time.perf_counter()
|
|
183
|
+
try:
|
|
184
|
+
resp = await original(*args, **kwargs)
|
|
185
|
+
except Exception as exc:
|
|
186
|
+
_record(recorder, kwargs, error=exc, duration_ms=_ms(t0))
|
|
187
|
+
raise
|
|
188
|
+
if kwargs.get("stream"):
|
|
189
|
+
return _AsyncStreamRecorder(recorder, kwargs, resp, t0)
|
|
190
|
+
usage = _usage_of(getattr(resp, "usage", None))
|
|
191
|
+
_record(recorder, kwargs, response=_response_payload(resp), usage=usage,
|
|
192
|
+
duration_ms=_ms(t0), model=getattr(resp, "model", None))
|
|
193
|
+
return resp
|
|
194
|
+
|
|
195
|
+
return create
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class _StreamRecorder:
|
|
199
|
+
"""Transparent iterator over anthropic stream events, recorded at end."""
|
|
200
|
+
|
|
201
|
+
def __init__(self, recorder: Any, kwargs: Dict[str, Any], stream: Any, t0: float):
|
|
202
|
+
self._recorder = recorder
|
|
203
|
+
self._kwargs = kwargs
|
|
204
|
+
self._stream = stream
|
|
205
|
+
self._t0 = t0
|
|
206
|
+
self._acc = _Acc(kwargs.get("model"))
|
|
207
|
+
self._done = False
|
|
208
|
+
|
|
209
|
+
def __iter__(self) -> "_StreamRecorder":
|
|
210
|
+
return self
|
|
211
|
+
|
|
212
|
+
def __next__(self) -> Any:
|
|
213
|
+
try:
|
|
214
|
+
event = next(self._stream)
|
|
215
|
+
except StopIteration:
|
|
216
|
+
self._finalize()
|
|
217
|
+
raise
|
|
218
|
+
except Exception as exc:
|
|
219
|
+
self._finalize(error=exc)
|
|
220
|
+
raise
|
|
221
|
+
self._acc.absorb(event)
|
|
222
|
+
return event
|
|
223
|
+
|
|
224
|
+
def close(self) -> None:
|
|
225
|
+
closer = getattr(self._stream, "close", None)
|
|
226
|
+
if closer is not None:
|
|
227
|
+
closer()
|
|
228
|
+
self._finalize()
|
|
229
|
+
|
|
230
|
+
def __enter__(self) -> "_StreamRecorder":
|
|
231
|
+
enter = getattr(self._stream, "__enter__", None)
|
|
232
|
+
if enter is not None:
|
|
233
|
+
enter()
|
|
234
|
+
return self
|
|
235
|
+
|
|
236
|
+
def __exit__(self, *exc_info: Any):
|
|
237
|
+
self._finalize()
|
|
238
|
+
exit_ = getattr(self._stream, "__exit__", None)
|
|
239
|
+
if exit_ is not None:
|
|
240
|
+
return exit_(*exc_info)
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
def _finalize(self, error: Optional[BaseException] = None) -> None:
|
|
244
|
+
if self._done:
|
|
245
|
+
return
|
|
246
|
+
self._done = True
|
|
247
|
+
acc = self._acc
|
|
248
|
+
usage = None
|
|
249
|
+
if acc.input_tokens is not None or acc.output_tokens is not None:
|
|
250
|
+
usage = {"prompt_tokens": acc.input_tokens or 0,
|
|
251
|
+
"completion_tokens": acc.output_tokens or 0}
|
|
252
|
+
_record(
|
|
253
|
+
self._recorder, self._kwargs,
|
|
254
|
+
response=acc.payload(), usage=usage, error=error,
|
|
255
|
+
duration_ms=_ms(self._t0), model=acc.model,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
class _AsyncStreamRecorder(_StreamRecorder):
|
|
260
|
+
def __aiter__(self) -> "_AsyncStreamRecorder":
|
|
261
|
+
return self
|
|
262
|
+
|
|
263
|
+
async def __anext__(self) -> Any:
|
|
264
|
+
try:
|
|
265
|
+
event = await self._stream.__anext__()
|
|
266
|
+
except StopAsyncIteration:
|
|
267
|
+
self._finalize()
|
|
268
|
+
raise
|
|
269
|
+
except Exception as exc:
|
|
270
|
+
self._finalize(error=exc)
|
|
271
|
+
raise
|
|
272
|
+
self._acc.absorb(event)
|
|
273
|
+
return event
|
|
274
|
+
|
|
275
|
+
async def aclose(self) -> None:
|
|
276
|
+
closer = getattr(self._stream, "aclose", None)
|
|
277
|
+
if closer is not None:
|
|
278
|
+
await closer()
|
|
279
|
+
self._finalize()
|