contexara 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
contexara/__init__.py ADDED
@@ -0,0 +1,46 @@
1
+ from .async_client import AsyncContexara
2
+ from .client import Contexara, MemoryAPI
3
+ from .context import format_context_block
4
+ from .errors import (
5
+ AuthenticationError,
6
+ ConflictError,
7
+ ConnectionError_,
8
+ ContexaraAPIError,
9
+ ContexaraError,
10
+ ContexaraTimeoutError,
11
+ PermissionDeniedError,
12
+ QuotaExceededError,
13
+ RateLimitError,
14
+ ResponseValidationError,
15
+ StreamError,
16
+ ValidationError,
17
+ )
18
+ from .models import AddResult, Entity, Memory, RetrieveResult, UploadEvent
19
+ from .resilience import ResiliencePolicy
20
+ from .testing import MockContexaraClient
21
+
22
+ __all__ = [
23
+ "Contexara",
24
+ "AsyncContexara",
25
+ "MemoryAPI",
26
+ "Memory",
27
+ "Entity",
28
+ "RetrieveResult",
29
+ "AddResult",
30
+ "UploadEvent",
31
+ "ResiliencePolicy",
32
+ "MockContexaraClient",
33
+ "format_context_block",
34
+ "ContexaraError",
35
+ "AuthenticationError",
36
+ "PermissionDeniedError",
37
+ "QuotaExceededError",
38
+ "RateLimitError",
39
+ "ValidationError",
40
+ "ConflictError",
41
+ "ConnectionError_",
42
+ "ContexaraTimeoutError",
43
+ "ResponseValidationError",
44
+ "StreamError",
45
+ "ContexaraAPIError",
46
+ ]
contexara/_capture.py ADDED
@@ -0,0 +1,370 @@
1
+ """_capture.py — builds the real CaptureEvent envelope /ingest expects,
2
+ and tracks session_id/turn_number the same way a real hook does (see
3
+ clients/installer/hook/platforms/cursor/hook.mjs's nextTurnNumber() and
4
+ resolveSessionId() — session_id is the caller's own stable conversation
5
+ identity, turn_number is a local, incrementing-per-session counter, not
6
+ server-assigned).
7
+
8
+ Two usage levels, per the build plan's section 7:
9
+ - Simple: memory.add(text) — the client auto-generates one default
10
+ session_id (constant for the life of the Contexara instance,
11
+ mirroring Cursor's "cursor-default" fallback) and allocates turn
12
+ numbers atomically from a local counter.
13
+ - Prepared/durable: memory.prepare_add(...) returns a fully-built,
14
+ serializable PreparedCaptureEvent an application can persist BEFORE
15
+ sending, and replay unchanged after a restart via memory.send(event)
16
+ — event_id/created_at/turn identity are frozen at prepare-time and
17
+ MUST NOT be regenerated on replay (see the contract's replay rule).
18
+
19
+ REAL, CONFIRMED GAP (contract review, 2026-09-28): reusing an event_id
20
+ with genuinely different content is NOT reliably caught server-side —
21
+ platform/api/app/worker.py's is_event_already_ingested() dedup check
22
+ returns before any content comparison ever runs. This module enforces
23
+ the replay rule by construction (a PreparedCaptureEvent's fields are
24
+ immutable once built; send() never mutates them) rather than relying on
25
+ the server to catch a violation.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import threading
30
+ import uuid
31
+ from dataclasses import dataclass, field
32
+ from datetime import datetime, timezone
33
+
34
+ # Bounds the session registry for the life of one Contexara instance.
35
+ # Every distinct session_id ever allocated (automatic or explicit)
36
+ # counts against this once, permanently — see SessionTracker's own
37
+ # docstring for why entries are never evicted to make room. Sized
38
+ # generously (each entry is a small string key + a 2-tuple) so a real
39
+ # long-lived process handling far more than 100,000 distinct sessions
40
+ # over its lifetime is the only case that hits this limit.
41
+ MAX_BOOKKEEPING_SESSIONS = 100_000
42
+
43
+
44
+ def _iso_now() -> str:
45
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
46
+
47
+
48
+ def derive_turn_id(turn_number: int) -> str:
49
+ """MUST match platform/api/app/schemas.py:CaptureEvent.derive_turn_id
50
+ exactly — the server independently re-derives this and rejects a
51
+ mismatch (409/worker failure)."""
52
+ return f"turn_{turn_number:06d}"
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class PreparedCaptureEvent:
57
+ """A fully-built, serializable CaptureEvent, ready to send or persist.
58
+ Every field is frozen at construction time — send() must never
59
+ mutate any of these before transmitting, since a retry after an
60
+ uncertain outcome must resend the exact original payload unchanged
61
+ (the contract's replay rule)."""
62
+
63
+ event_id: str
64
+ turn_id: str
65
+ session_id: str
66
+ turn_number: int
67
+ provider: str
68
+ capture_version: str
69
+ created_at: str
70
+ user_content: str | None
71
+ assistant_content: str | None
72
+ project_id: str | None = None
73
+ workspace_id: str | None = None
74
+
75
+ def to_wire(self) -> dict:
76
+ """The real CaptureEvent JSON body /ingest expects."""
77
+ return {
78
+ "event_id": self.event_id,
79
+ "event_type": "conversation_turn",
80
+ "event_version": "1.0",
81
+ "capture_version": self.capture_version,
82
+ "schema_version": "1.0",
83
+ "turn_id": self.turn_id,
84
+ "tenant_id": "unset",
85
+ "user_id": "unset",
86
+ "session_id": self.session_id,
87
+ "turn_number": self.turn_number,
88
+ "project_id": self.project_id,
89
+ "workspace_id": self.workspace_id,
90
+ "provider": self.provider,
91
+ "created_at": self.created_at,
92
+ "status": "complete",
93
+ "incomplete_reason": None,
94
+ "conversation": {
95
+ "user": {"timestamp": None, "content": self.user_content},
96
+ "assistant": {"timestamp": None, "content": self.assistant_content},
97
+ },
98
+ "metadata": {
99
+ "capture_client_version": self.capture_version,
100
+ "os": None,
101
+ "hook_event_names": [],
102
+ },
103
+ }
104
+
105
+ def to_dict(self) -> dict:
106
+ """Serializable representation for an application to persist
107
+ BEFORE sending, so it can replay this exact event after a
108
+ restart — a plain dict of this dataclass's own fields, not the
109
+ wire shape (to_wire() derives the wire shape from this, not the
110
+ other way around, so persisting to_dict()'s output and later
111
+ reconstructing via from_dict() round-trips exactly)."""
112
+ return {
113
+ "event_id": self.event_id,
114
+ "turn_id": self.turn_id,
115
+ "session_id": self.session_id,
116
+ "turn_number": self.turn_number,
117
+ "provider": self.provider,
118
+ "capture_version": self.capture_version,
119
+ "created_at": self.created_at,
120
+ "user_content": self.user_content,
121
+ "assistant_content": self.assistant_content,
122
+ "project_id": self.project_id,
123
+ "workspace_id": self.workspace_id,
124
+ }
125
+
126
+ @classmethod
127
+ def from_dict(cls, data: dict) -> "PreparedCaptureEvent":
128
+ return cls(**data)
129
+
130
+
131
+ class SessionTracker:
132
+ """Owns default-session generation and per-session turn-number
133
+ allocation for ONE Contexara client instance. Not process-global —
134
+ two separate Contexara() instances get independent default sessions,
135
+ matching "one client instance = roughly one logical conversation
136
+ unless told otherwise", not "one process".
137
+
138
+ Thread-safe: allocate_turn() is the one method under concurrent-call
139
+ risk (two threads calling memory.add() on the same client at once),
140
+ guarded by a lock so two calls never observe/reuse the same
141
+ turn_number for the same session_id.
142
+
143
+ REAL BUG FIXED 2026-09-28 (Codex review, round 3, THIRD pass): the
144
+ previous two fixes both tried to patch the eviction/forgetting
145
+ design instead of replacing it, and both were reproduced broken by
146
+ Codex before this rewrite:
147
+
148
+ - Pass 1 (TTL + idle eviction): a bounded "recently evicted" set
149
+ rolled its oldest entry off under sustained churn, letting an
150
+ evicted session_id look brand-new again once enough OTHER
151
+ sessions had also been evicted.
152
+ - Pass 2 (unbounded evicted/explicit sets): fixed that, but (a)
153
+ broke explicit → explicit continuation (the check treated ANY
154
+ prior explicit allocation as a mode conflict, not just a prior
155
+ AUTOMATIC one — a session doing turn 1, 2, 3 all via
156
+ explicit_turn_number was incorrectly rejected on turn 2), and
157
+ (b) two separate unbounded sets meant total bookkeeping grew
158
+ without limit for the tracker's lifetime — not a configured
159
+ bound, regardless of how "non-adversarial" normal traffic is.
160
+ Reproduced directly by Codex: max_sessions=2, 100 distinct
161
+ explicitly-allocated session_ids → 100 retained forever.
162
+
163
+ This pass replaces BOTH bounded-forgetting and unbounded-growth
164
+ with the single design Codex specified: one bounded registry,
165
+ keyed by session_id, storing (mode, turn_number | None). Entries
166
+ are NEVER evicted for the life of this tracker instance — there is
167
+ no TTL, no idle sweep, no eviction of any kind. The only thing
168
+ max_sessions governs is whether *registering a brand-new*
169
+ session_id is allowed; every session_id already in the registry,
170
+ of either mode, continues working for as long as the process runs.
171
+ This is a real, fixed, configured bound (not "bounded in practice
172
+ by non-adversarial traffic" — an argument Codex correctly rejected,
173
+ since session_ids are caller-supplied and ordinary traffic alone
174
+ can exhaust an unbounded set). A caller who legitimately needs more
175
+ than max_sessions distinct sessions across one client's lifetime is
176
+ the resuming-in-another-process case this class's own
177
+ explicit_turn_number parameter already exists for — that path is
178
+ unaffected by capacity, since resuming callers manage their own
179
+ state and never need a REGISTRY entry to keep working correctly
180
+ (see allocate_turn's own docstring)."""
181
+
182
+ def __init__(self, *, max_sessions: int | None = None) -> None:
183
+ self._lock = threading.Lock()
184
+ self._default_session_id: str | None = None
185
+ # session_id -> (mode, turn_number). mode is "automatic" or
186
+ # "explicit". For "explicit" entries, turn_number is None — an
187
+ # explicit caller manages their own numbers, this registry only
188
+ # needs to remember WHICH mode the session is locked to, not
189
+ # any particular number. Never evicted, never shrinks, for the
190
+ # life of this tracker instance — see this class's own
191
+ # docstring for why that is the actual fix, not a regression.
192
+ self._sessions: dict[str, tuple[str, int | None]] = {}
193
+ self._max_sessions = max_sessions if max_sessions is not None else MAX_BOOKKEEPING_SESSIONS
194
+
195
+ def default_session_id(self) -> str:
196
+ """Lazily generates one default session_id, stable for the life
197
+ of this tracker instance — mirrors Cursor's own
198
+ resolveSessionId() fallback ("cursor-default"): a single,
199
+ consistent conversation identity used when the caller has no
200
+ more specific one to offer, not a fresh id manufactured per
201
+ call (which would make every add() its own isolated one-turn
202
+ session, defeating the point of a session concept at all)."""
203
+ with self._lock:
204
+ if self._default_session_id is None:
205
+ self._default_session_id = f"sdk-{uuid.uuid4().hex[:12]}"
206
+ return self._default_session_id
207
+
208
+ def allocate_turn(self, session_id: str, *, explicit_turn_number: int | None = None) -> int:
209
+ """Returns the next turn_number for session_id, starting at 1,
210
+ incrementing per call — the same real allocation rule
211
+ nextTurnNumber() implements for hook clients. Atomic under
212
+ concurrent calls via the instance lock.
213
+
214
+ explicit_turn_number: caller-managed allocation, for a
215
+ distributed application that tracks its own durable
216
+ session/turn state externally (e.g. after a process restart,
217
+ or across multiple worker processes sharing one logical
218
+ session) — bypasses this tracker's local counter entirely.
219
+ A session_id is permanently locked to whichever mode
220
+ (automatic or explicit) its FIRST allocate_turn() call used,
221
+ for the life of this tracker instance:
222
+
223
+ - automatic → automatic: increments and returns the local
224
+ counter, as always.
225
+ - explicit → explicit: REAL BUG FIXED 2026-09-28 (Codex review,
226
+ round 3, third pass, reproduced directly): an earlier version
227
+ of this check rejected ANY session_id that had ever seen an
228
+ explicit call, including a second, third, etc. explicit call
229
+ for the SAME session_id — "explicit turn 1" then "explicit
230
+ turn 2" for one conversation incorrectly raised. Explicit
231
+ mode does not update this registry's own counter (the caller
232
+ owns their own numbers), so repeated explicit calls for an
233
+ explicit-mode session are always allowed; only a MODE SWITCH
234
+ (automatic session receiving an explicit call, or vice versa)
235
+ is rejected.
236
+ - either mode switching to the other: raises ValidationError —
237
+ silently mixing the two would desynchronize this tracker's
238
+ local counter from whatever the caller (or the server) is
239
+ separately tracking.
240
+
241
+ Raises ValidationError if registering session_id would exceed
242
+ max_sessions. This registry never evicts an entry once
243
+ created — see this class's own docstring for why "bounded, but
244
+ forgets old entries" was the actual defect being fixed across
245
+ the last two rounds, not a feature. A caller who legitimately
246
+ needs more than max_sessions distinct session_ids across one
247
+ client instance's lifetime should pass explicit_turn_number
248
+ (which never touches the registry once a session_id is already
249
+ registered in explicit mode) or construct a new client
250
+ instance."""
251
+ from .errors import ValidationError
252
+
253
+ with self._lock:
254
+ existing = self._sessions.get(session_id)
255
+
256
+ if explicit_turn_number is not None:
257
+ if existing is not None and existing[0] != "explicit":
258
+ raise ValidationError(
259
+ f"session_id {session_id!r} already has an active local turn counter "
260
+ f"from automatic allocation — passing an explicit_turn_number for it now "
261
+ f"would desynchronize the two. Use one allocation strategy per session "
262
+ f"consistently, not both."
263
+ )
264
+ if existing is None:
265
+ self._reserve_capacity(session_id)
266
+ self._sessions[session_id] = ("explicit", None)
267
+ return explicit_turn_number
268
+
269
+ if existing is not None and existing[0] == "explicit":
270
+ raise ValidationError(
271
+ f"session_id {session_id!r} was previously allocated an explicit_turn_number "
272
+ f"— switching to automatic allocation for it now would desynchronize the two. "
273
+ f"Use one allocation strategy per session consistently, not both."
274
+ )
275
+
276
+ if existing is not None:
277
+ next_turn = (existing[1] or 0) + 1
278
+ self._sessions[session_id] = ("automatic", next_turn)
279
+ return next_turn
280
+
281
+ self._reserve_capacity(session_id)
282
+ self._sessions[session_id] = ("automatic", 1)
283
+ return 1
284
+
285
+ def _reserve_capacity(self, session_id: str) -> None:
286
+ """Raises if registering a brand-new session_id would exceed
287
+ max_sessions. Only called when session_id is NOT already in
288
+ the registry — an already-registered session_id (either mode)
289
+ never needs capacity, since it does not grow the registry."""
290
+ from .errors import ValidationError
291
+
292
+ if len(self._sessions) >= self._max_sessions:
293
+ raise ValidationError(
294
+ f"session_id {session_id!r} cannot be registered: this client is already "
295
+ f"tracking {self._max_sessions} other session_ids for its lifetime, and this "
296
+ f"registry never forgets a session_id once created (see SessionTracker's own "
297
+ f"docstring for why unbounded-with-forgetting was rejected as unsafe). Either "
298
+ f"reuse an already-registered session_id, pass an explicit_turn_number for a "
299
+ f"session_id whose turns you track yourself, or construct a new client instance."
300
+ )
301
+
302
+
303
+ # Real, documented limits — platform/api/app/schemas.py:18,22
304
+ # (MAX_MESSAGE_CONTENT_CHARS, MAX_IDENTIFIER_CHARS). REAL GAP CLOSED
305
+ # 2026-09-28 (Codex review): prepare_add() previously sent blank
306
+ # content and over-limit content straight to the network, where the
307
+ # server would reject it with a 422 — a real, avoidable round trip for
308
+ # an error the SDK can and should catch immediately.
309
+ MAX_MESSAGE_CONTENT_CHARS = 100_000
310
+ MAX_IDENTIFIER_CHARS = 512
311
+
312
+
313
+ def build_prepared_event(
314
+ tracker: SessionTracker,
315
+ *,
316
+ text: str,
317
+ assistant_message: str | None,
318
+ session_id: str | None,
319
+ project_id: str | None,
320
+ workspace_id: str | None,
321
+ provider: str,
322
+ capture_version: str,
323
+ turn_number: int | None = None,
324
+ ) -> PreparedCaptureEvent:
325
+ from .errors import ValidationError
326
+
327
+ if not text or not text.strip():
328
+ raise ValidationError("text must not be blank")
329
+ if len(text) > MAX_MESSAGE_CONTENT_CHARS:
330
+ raise ValidationError(
331
+ f"text is {len(text)} chars, over the real {MAX_MESSAGE_CONTENT_CHARS}-char limit "
332
+ f"the server enforces per message."
333
+ )
334
+ if assistant_message is not None and len(assistant_message) > MAX_MESSAGE_CONTENT_CHARS:
335
+ raise ValidationError(
336
+ f"assistant_message is {len(assistant_message)} chars, over the real "
337
+ f"{MAX_MESSAGE_CONTENT_CHARS}-char limit the server enforces per message."
338
+ )
339
+ if session_id is not None and len(session_id) > MAX_IDENTIFIER_CHARS:
340
+ raise ValidationError(f"session_id is over the real {MAX_IDENTIFIER_CHARS}-char identifier limit.")
341
+ # REAL GAP CLOSED (schema reconciliation, release plan step 2):
342
+ # shared/schemas/capture_event.schema.json declares turn_number's
343
+ # real minimum as 1, and derive_turn_id's "turn_%06d" format
344
+ # produces a malformed turn_id (fails the schema's own
345
+ # ^turn_[0-9]{6}$ regex) for turn_number <= 0 — e.g. turn_number=-1
346
+ # formats as "turn_-00001", not six digits. Neither the backend
347
+ # Pydantic model NOR this SDK enforced turn_number >= 1 before this
348
+ # fix, making it reachable via this exact explicit_turn_number
349
+ # parameter with no server-side backstop. The Node SDK already
350
+ # enforced this (capture.ts's fromPersistable); this brings the
351
+ # Python SDK to parity and catches the error at prepare_add()/add()
352
+ # time instead of only via a confusing later ID-format failure.
353
+ if turn_number is not None and turn_number < 1:
354
+ raise ValidationError(f"turn_number must be >= 1, got {turn_number}.")
355
+
356
+ resolved_session_id = session_id or tracker.default_session_id()
357
+ turn_number = tracker.allocate_turn(resolved_session_id, explicit_turn_number=turn_number)
358
+ return PreparedCaptureEvent(
359
+ event_id=f"evt_{uuid.uuid4()}",
360
+ turn_id=derive_turn_id(turn_number),
361
+ session_id=resolved_session_id,
362
+ turn_number=turn_number,
363
+ provider=provider,
364
+ capture_version=capture_version,
365
+ created_at=_iso_now(),
366
+ user_content=text,
367
+ assistant_content=assistant_message,
368
+ project_id=project_id,
369
+ workspace_id=workspace_id,
370
+ )
contexara/_sse.py ADDED
@@ -0,0 +1,110 @@
1
+ """_sse.py — parses the real /upload SSE protocol described in
2
+ platform/sdk/contracts/PUBLIC_API_CONTRACT.md's upload section and
3
+ exercised by fixtures/upload_sse_stream.json.
4
+
5
+ Real frame shape: `data: {json}\n\n` (or `\r\n\r\n`), terminated by a
6
+ literal `data: [DONE]\n\n` frame. An `{"error": ...}` frame may appear
7
+ before [DONE] — [DONE] following an error frame does NOT mean success;
8
+ callers must check for an error frame having been seen, not just for
9
+ stream completion.
10
+
11
+ This is a pure, transport-agnostic byte-accumulator: feed it raw bytes
12
+ in whatever chunks arrive from the network (which may split a frame
13
+ anywhere, including mid-multibyte-UTF-8-character), and it yields
14
+ complete, parsed UploadEvent-shaped dicts as they become available.
15
+ Kept separate from client.py so the same parser backs both the sync and
16
+ async upload() implementations without duplicating this logic.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from collections.abc import Iterator
22
+
23
+ from .errors import StreamError
24
+
25
+
26
+ class SSEFrameParser:
27
+ """Stateful, incremental parser. Call feed(chunk) with each raw byte
28
+ chunk as it arrives; each call yields zero or more fully-parsed
29
+ frame dicts. Call finish() once the stream ends to detect a
30
+ connection drop with no [DONE] (a real protocol violation, distinct
31
+ from a clean error-then-done sequence)."""
32
+
33
+ def __init__(self) -> None:
34
+ self._buffer = b""
35
+ self._decoder_leftover = b""
36
+ self._saw_done = False
37
+ self._error_frame: str | None = None
38
+ self._last_metadata: dict | None = None
39
+
40
+ def feed(self, chunk: bytes) -> Iterator[dict]:
41
+ self._buffer += chunk
42
+ # Normalize CRLF to LF once, on the accumulated buffer, so the
43
+ # frame-splitting logic below only needs to handle one line
44
+ # ending — real servers may use either per the contract.
45
+ normalized = self._buffer.replace(b"\r\n", b"\n")
46
+ # A complete frame is terminated by a blank line (\n\n). Split
47
+ # on that, keep the last (possibly incomplete) piece buffered
48
+ # for the next feed() call — this is what makes a frame split
49
+ # across two network reads reassemble correctly.
50
+ parts = normalized.split(b"\n\n")
51
+ self._buffer = parts[-1]
52
+ for raw_frame in parts[:-1]:
53
+ event = self._parse_frame(raw_frame)
54
+ if event is not None:
55
+ yield event
56
+
57
+ def _parse_frame(self, raw_frame: bytes) -> dict | None:
58
+ # A real frame is one or more `data: ...` lines. REAL BUG FIXED
59
+ # 2026-09-28 (Codex review): multiple data: lines were joined
60
+ # with NO separator (b"".join), which is not what the SSE spec
61
+ # requires — per the WHATWG spec, each data: line's value gets
62
+ # a "\n" appended before the next line's value, not
63
+ # concatenated directly. This protocol's real frames today are
64
+ # always single-line, so this had no observed practical effect
65
+ # yet, but a multiline frame would have silently corrupted the
66
+ # decoded JSON (e.g. two numbers on adjacent lines merging into
67
+ # one digit string with no separator).
68
+ lines = raw_frame.split(b"\n")
69
+ data_parts = [ln[len(b"data:"):].lstrip() for ln in lines if ln.startswith(b"data:")]
70
+ if not data_parts:
71
+ return None # a comment/keepalive line with no data: prefix
72
+ payload = b"\n".join(data_parts)
73
+ text = payload.decode("utf-8")
74
+ if text == "[DONE]":
75
+ self._saw_done = True
76
+ return {"done": True}
77
+ try:
78
+ obj = json.loads(text)
79
+ except json.JSONDecodeError as exc:
80
+ raise StreamError(f"malformed SSE data frame: {text!r}: {exc}") from None
81
+ if "error" in obj:
82
+ self._error_frame = obj["error"]
83
+ if "content_hash" in obj or "attachment_id" in obj:
84
+ self._last_metadata = obj
85
+ return obj
86
+
87
+ def finish(self) -> None:
88
+ """Call once the underlying stream has ended (EOF). Raises
89
+ StreamError if the stream ended without a [DONE] frame (a
90
+ protocol violation — connection drop or server crash mid-
91
+ stream) or if an error frame was seen at any point (regardless
92
+ of whether [DONE] followed it — DONE's presence alone never
93
+ means success)."""
94
+ if self._error_frame is not None:
95
+ meta = self._last_metadata or {}
96
+ raise StreamError(
97
+ self._error_frame,
98
+ content_hash=meta.get("content_hash"),
99
+ duplicate=meta.get("duplicate"),
100
+ attachment_id=meta.get("attachment_id"),
101
+ )
102
+ if not self._saw_done:
103
+ meta = self._last_metadata or {}
104
+ raise StreamError(
105
+ "upload stream ended without a [DONE] frame (connection drop or "
106
+ "server error mid-stream)",
107
+ content_hash=meta.get("content_hash"),
108
+ duplicate=meta.get("duplicate"),
109
+ attachment_id=meta.get("attachment_id"),
110
+ )