meta-sam-parser 0.0.2__tar.gz → 0.0.4__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meta-sam-parser
3
- Version: 0.0.2
3
+ Version: 0.0.4
4
4
  Summary: Native Python parsing primitives for the SAM 3 segmentation protocol
5
5
  Project-URL: Homepage, https://github.com/meta-models/meta-sam
6
6
  Project-URL: Repository, https://github.com/meta-models/meta-sam
@@ -106,24 +106,35 @@ python -m pip install .
106
106
 
107
107
  Import supported APIs only from the package root. Deep imports are unsupported;
108
108
  underscore-prefixed modules are implementation details and may change without notice.
109
- Format factories are zero-argument and create isolated parser state:
109
+ Format factories are zero-argument and create isolated parser state. The example
110
+ feeds the parser one frame of SAM 3.1 output exactly as the API emits it - one line
111
+ per frame, `<Nf>` then comma-separated `id<|box;...|><|mask;...|>` records:
110
112
 
111
113
  ```python
112
114
  from meta_sam_parser import CompletedOutcome, video_segmentation_format
113
115
 
116
+ output_text = (
117
+ "<0f>0<|box;x1=211;y1=228;x2=270;y2=254;w=320;h=334|>"
118
+ "<|mask;x=0;y=0;data=27,60,~!!!!M!0c[0o91w?q1!pIH4pPRVp2B3'7`e.ioeAf6-k/#Xd8%dX9x(|>"
119
+ ",1<|box;x1=155;y1=228;x2=202;y2=254;w=320;h=334|>"
120
+ "<|mask;x=0;y=0;data=27,48,~!!!!J!0c[q=Pj_zs=*4C4(/./x#:/`S_GnD`=o3?X{emCgO$y@|>\n"
121
+ )
122
+
114
123
  format_ = video_segmentation_format()
115
124
  parser = format_.create_parser()
116
125
 
117
- for snapshot in parser.push(
118
- "[frame=7] object=bus box=(10,20,110,80)\n"
119
- "[frame=7] object=bus mask=one_bit;size=5x5;data=!!!!!(QO(0lu8?\n"
120
- ):
126
+ # Chunks may split anywhere, including inside a mask payload.
127
+ split = output_text.index(",1<|box")
128
+ for snapshot in parser.push(output_text[:split]):
121
129
  print(snapshot.revision, len(snapshot.records))
130
+ for snapshot in parser.push(output_text[split:]):
131
+ print(snapshot.revision, len(snapshot.records)) # 1 4
122
132
 
123
133
  finished = parser.finish(CompletedOutcome())
124
134
  for snapshot in finished.events:
125
135
  print(snapshot.revision, len(snapshot.records))
126
136
  result = finished.result
137
+ # Two box records and two mask records, object IDs "0" and "1", frame 0.
127
138
  ```
128
139
 
129
140
  `push()` accepts arbitrarily split text chunks and returns zero or one cumulative
@@ -140,7 +151,11 @@ can own lane validation, source cleanup, and terminal outcomes.
140
151
  `parse_responses_stream(source, format)` accepts an async iterable of official
141
152
  OpenAI Python SDK event objects, mappings with the same wire fields, or a mixture
142
153
  of both. It has no runtime dependency on the OpenAI SDK. Field names stay in the
143
- SDK/wire snake-case form (`item_id`, `output_index`, and `content_index`).
154
+ SDK/wire snake-case form (`item_id`, `output_index`, and `content_index`). An
155
+ output-text lane is finalized by either `response.output_text.done` or a
156
+ `response.content_part.done` event whose part type is `output_text`. The live SAM
157
+ Model API emits only the latter. If a stream emits both finalizers, their text
158
+ must match.
144
159
 
145
160
  ### Iterator-first
146
161
 
@@ -228,10 +243,10 @@ All public values are frozen, slotted dataclasses. Observable collections are
228
243
  tuples. The package root exports:
229
244
 
230
245
  - Geometry, masks, and aliases: `FrameReference`, `SegmentationMaskBounds`,
231
- `SegmentationMask`, `SegmentationMaskIdentity`, `SegmentationMedia`,
246
+ `SegmentationMask`, `SegmentationMaskEncoding`, `SegmentationMaskIdentity`, `SegmentationMedia`,
232
247
  `DiagnosticSeverity`, and `IncompleteReason`.
233
- - Records: `SegmentationTextRecord`, `SegmentationPointRecord`,
234
- `SegmentationBoxRecord`, `SegmentationMaskRecord`, and `SegmentationRecord`.
248
+ - Records: `SegmentationTextRecord`, `SegmentationBoxRecord`,
249
+ `SegmentationMaskRecord`, and `SegmentationRecord`.
235
250
  - Views: `SegmentationDiagnostic`, `ImageSegmentationSnapshot`,
236
251
  `VideoSegmentationSnapshot`, `SegmentationSnapshot`,
237
252
  `ImageSegmentationResult`, `VideoSegmentationResult`, and
@@ -258,15 +273,31 @@ the next revision.
258
273
 
259
274
  ## Parsing behavior
260
275
 
261
- The documented package grammar is the line form in the
276
+ SAM 3.1 returns segmentation as special-token text in one `output_text` lane, one
277
+ line per frame:
278
+
279
+ ```text
280
+ <Nf>id<|box;x1=..;y1=..;x2=..;y2=..;w=<frameW>;h=<frameH>|><|mask;x=0;y=0;data=<H>,<W>,<enc>payload|>,id<|box;...|><|mask;...|>
281
+ ```
282
+
283
+ `<Nf>` is the zero-based frame index; frames without a visible object emit no
284
+ line, so indices can skip. Each comma-separated record is a bare integer object
285
+ id - stable for an object across the frames of one response and not a dense
286
+ sequence - followed by one box and one mask. The parser retains the id as a string
287
+ in `object_id`. Box corners and the `w`/`h` frame size are source pixels; the
288
+ inclusive wire `x2`/`y2` become half-open `right`/`bottom`. The mask tuple is
289
+ `height,width,payload`, where the payload's first character selects the encoding:
290
+ `~` for `lossless` (the API default) or `!` for `one_bit`. The payload is base85,
291
+ not base64: after the marker its digits are printable ASCII `!` through `{` minus
292
+ the wire delimiters `" \ , ; < > |`, so it contains `*`, `$`, brackets, and
293
+ backticks, a base64 regex will not match it, and only the first character after
294
+ `H,W,` is the marker (`!` is also digit zero). Pass it through unchanged. Each record becomes one `SegmentationBoxRecord` followed by
295
+ one `SegmentationMaskRecord` whose `bounds` is the same half-open box. Image streams require frame zero and omit the frame from
296
+ normalized records; video records retain `frame.frame_index`. Every mask is
297
+ strictly decoded before insertion; an empty lane is a valid completed response
298
+ with no records.
299
+ The grammar is specified in the
262
300
  [SAM 3 protocol](https://github.com/meta-models/meta-sam/blob/main/protocol/sam3.md).
263
- The parser separately accepts compact box-first SAM API output as production
264
- compatibility input and normalizes it to the same records. Its object token is an
265
- ASCII-decimal ID retained as a string, and IDs may be multi-digit or non-contiguous.
266
- The compact input's inclusive `x2`/`y2` coordinates become half-open `right`/`bottom`
267
- bounds. Image API
268
- records require frame zero and omit it from normalized records; video records retain
269
- frame indices. Every mask is strictly decoded before insertion.
270
301
 
271
302
  Plain text remains an ordered text record. Malformed structured-looking lines
272
303
  produce diagnostics and parsing continues. `raw_output` preserves every input
@@ -359,8 +390,11 @@ by the `release PyPI distribution` workflow
359
390
  1. Bump `version` in `pyproject.toml`, run `node scripts/sync-compatibility` from
360
391
  the repository root so the compatibility matrix records the new version, and
361
392
  merge that change to `main`.
362
- 2. Push the tag `meta-sam-parser@<version>` at that commit. The workflow refuses a
363
- tag that does not match the manifest version.
393
+ 2. Push the tag `meta-sam-parser@<version>` at that merge commit on `main`. The
394
+ workflow refuses a tag that does not match the manifest version, a tag whose
395
+ commit is not on `main`, and a tag older than the latest `main` commit touching
396
+ `python/`, `conformance/`, or `protocol/`, so a tag created before later fixes
397
+ merged cannot publish stale source.
364
398
  3. The workflow builds the reproducible wheel and sdist, runs the artifact and
365
399
  clean-consumer audits and the cross-language conformance suite, and then waits
366
400
  for approval in the `pypi` GitHub environment before uploading through PyPI
@@ -17,24 +17,35 @@ python -m pip install .
17
17
 
18
18
  Import supported APIs only from the package root. Deep imports are unsupported;
19
19
  underscore-prefixed modules are implementation details and may change without notice.
20
- Format factories are zero-argument and create isolated parser state:
20
+ Format factories are zero-argument and create isolated parser state. The example
21
+ feeds the parser one frame of SAM 3.1 output exactly as the API emits it - one line
22
+ per frame, `<Nf>` then comma-separated `id<|box;...|><|mask;...|>` records:
21
23
 
22
24
  ```python
23
25
  from meta_sam_parser import CompletedOutcome, video_segmentation_format
24
26
 
27
+ output_text = (
28
+ "<0f>0<|box;x1=211;y1=228;x2=270;y2=254;w=320;h=334|>"
29
+ "<|mask;x=0;y=0;data=27,60,~!!!!M!0c[0o91w?q1!pIH4pPRVp2B3'7`e.ioeAf6-k/#Xd8%dX9x(|>"
30
+ ",1<|box;x1=155;y1=228;x2=202;y2=254;w=320;h=334|>"
31
+ "<|mask;x=0;y=0;data=27,48,~!!!!J!0c[q=Pj_zs=*4C4(/./x#:/`S_GnD`=o3?X{emCgO$y@|>\n"
32
+ )
33
+
25
34
  format_ = video_segmentation_format()
26
35
  parser = format_.create_parser()
27
36
 
28
- for snapshot in parser.push(
29
- "[frame=7] object=bus box=(10,20,110,80)\n"
30
- "[frame=7] object=bus mask=one_bit;size=5x5;data=!!!!!(QO(0lu8?\n"
31
- ):
37
+ # Chunks may split anywhere, including inside a mask payload.
38
+ split = output_text.index(",1<|box")
39
+ for snapshot in parser.push(output_text[:split]):
32
40
  print(snapshot.revision, len(snapshot.records))
41
+ for snapshot in parser.push(output_text[split:]):
42
+ print(snapshot.revision, len(snapshot.records)) # 1 4
33
43
 
34
44
  finished = parser.finish(CompletedOutcome())
35
45
  for snapshot in finished.events:
36
46
  print(snapshot.revision, len(snapshot.records))
37
47
  result = finished.result
48
+ # Two box records and two mask records, object IDs "0" and "1", frame 0.
38
49
  ```
39
50
 
40
51
  `push()` accepts arbitrarily split text chunks and returns zero or one cumulative
@@ -51,7 +62,11 @@ can own lane validation, source cleanup, and terminal outcomes.
51
62
  `parse_responses_stream(source, format)` accepts an async iterable of official
52
63
  OpenAI Python SDK event objects, mappings with the same wire fields, or a mixture
53
64
  of both. It has no runtime dependency on the OpenAI SDK. Field names stay in the
54
- SDK/wire snake-case form (`item_id`, `output_index`, and `content_index`).
65
+ SDK/wire snake-case form (`item_id`, `output_index`, and `content_index`). An
66
+ output-text lane is finalized by either `response.output_text.done` or a
67
+ `response.content_part.done` event whose part type is `output_text`. The live SAM
68
+ Model API emits only the latter. If a stream emits both finalizers, their text
69
+ must match.
55
70
 
56
71
  ### Iterator-first
57
72
 
@@ -139,10 +154,10 @@ All public values are frozen, slotted dataclasses. Observable collections are
139
154
  tuples. The package root exports:
140
155
 
141
156
  - Geometry, masks, and aliases: `FrameReference`, `SegmentationMaskBounds`,
142
- `SegmentationMask`, `SegmentationMaskIdentity`, `SegmentationMedia`,
157
+ `SegmentationMask`, `SegmentationMaskEncoding`, `SegmentationMaskIdentity`, `SegmentationMedia`,
143
158
  `DiagnosticSeverity`, and `IncompleteReason`.
144
- - Records: `SegmentationTextRecord`, `SegmentationPointRecord`,
145
- `SegmentationBoxRecord`, `SegmentationMaskRecord`, and `SegmentationRecord`.
159
+ - Records: `SegmentationTextRecord`, `SegmentationBoxRecord`,
160
+ `SegmentationMaskRecord`, and `SegmentationRecord`.
146
161
  - Views: `SegmentationDiagnostic`, `ImageSegmentationSnapshot`,
147
162
  `VideoSegmentationSnapshot`, `SegmentationSnapshot`,
148
163
  `ImageSegmentationResult`, `VideoSegmentationResult`, and
@@ -169,15 +184,31 @@ the next revision.
169
184
 
170
185
  ## Parsing behavior
171
186
 
172
- The documented package grammar is the line form in the
187
+ SAM 3.1 returns segmentation as special-token text in one `output_text` lane, one
188
+ line per frame:
189
+
190
+ ```text
191
+ <Nf>id<|box;x1=..;y1=..;x2=..;y2=..;w=<frameW>;h=<frameH>|><|mask;x=0;y=0;data=<H>,<W>,<enc>payload|>,id<|box;...|><|mask;...|>
192
+ ```
193
+
194
+ `<Nf>` is the zero-based frame index; frames without a visible object emit no
195
+ line, so indices can skip. Each comma-separated record is a bare integer object
196
+ id - stable for an object across the frames of one response and not a dense
197
+ sequence - followed by one box and one mask. The parser retains the id as a string
198
+ in `object_id`. Box corners and the `w`/`h` frame size are source pixels; the
199
+ inclusive wire `x2`/`y2` become half-open `right`/`bottom`. The mask tuple is
200
+ `height,width,payload`, where the payload's first character selects the encoding:
201
+ `~` for `lossless` (the API default) or `!` for `one_bit`. The payload is base85,
202
+ not base64: after the marker its digits are printable ASCII `!` through `{` minus
203
+ the wire delimiters `" \ , ; < > |`, so it contains `*`, `$`, brackets, and
204
+ backticks, a base64 regex will not match it, and only the first character after
205
+ `H,W,` is the marker (`!` is also digit zero). Pass it through unchanged. Each record becomes one `SegmentationBoxRecord` followed by
206
+ one `SegmentationMaskRecord` whose `bounds` is the same half-open box. Image streams require frame zero and omit the frame from
207
+ normalized records; video records retain `frame.frame_index`. Every mask is
208
+ strictly decoded before insertion; an empty lane is a valid completed response
209
+ with no records.
210
+ The grammar is specified in the
173
211
  [SAM 3 protocol](https://github.com/meta-models/meta-sam/blob/main/protocol/sam3.md).
174
- The parser separately accepts compact box-first SAM API output as production
175
- compatibility input and normalizes it to the same records. Its object token is an
176
- ASCII-decimal ID retained as a string, and IDs may be multi-digit or non-contiguous.
177
- The compact input's inclusive `x2`/`y2` coordinates become half-open `right`/`bottom`
178
- bounds. Image API
179
- records require frame zero and omit it from normalized records; video records retain
180
- frame indices. Every mask is strictly decoded before insertion.
181
212
 
182
213
  Plain text remains an ordered text record. Malformed structured-looking lines
183
214
  produce diagnostics and parsing continues. `raw_output` preserves every input
@@ -270,8 +301,11 @@ by the `release PyPI distribution` workflow
270
301
  1. Bump `version` in `pyproject.toml`, run `node scripts/sync-compatibility` from
271
302
  the repository root so the compatibility matrix records the new version, and
272
303
  merge that change to `main`.
273
- 2. Push the tag `meta-sam-parser@<version>` at that commit. The workflow refuses a
274
- tag that does not match the manifest version.
304
+ 2. Push the tag `meta-sam-parser@<version>` at that merge commit on `main`. The
305
+ workflow refuses a tag that does not match the manifest version, a tag whose
306
+ commit is not on `main`, and a tag older than the latest `main` commit touching
307
+ `python/`, `conformance/`, or `protocol/`, so a tag created before later fixes
308
+ merged cannot publish stale source.
275
309
  3. The workflow builds the reproducible wheel and sdist, runs the artifact and
276
310
  clean-consumer audits and the cross-language conformance suite, and then waits
277
311
  for approval in the `pypi` GitHub environment before uploading through PyPI
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
6
6
 
7
7
  [project]
8
8
  name = "meta-sam-parser"
9
- version = "0.0.2"
9
+ version = "0.0.4"
10
10
  description = "Native Python parsing primitives for the SAM 3 segmentation protocol"
11
11
  readme = "README.md"
12
12
  requires-python = ">=3.10"
@@ -36,10 +36,10 @@ from ._types import (
36
36
  SegmentationDiagnostic,
37
37
  SegmentationMask,
38
38
  SegmentationMaskBounds,
39
+ SegmentationMaskEncoding,
39
40
  SegmentationMaskIdentity,
40
41
  SegmentationMaskRecord,
41
42
  SegmentationMedia,
42
- SegmentationPointRecord,
43
43
  SegmentationRecord,
44
44
  SegmentationResult,
45
45
  SegmentationSnapshot,
@@ -80,10 +80,10 @@ __all__ = [
80
80
  "SegmentationDiagnostic",
81
81
  "SegmentationMask",
82
82
  "SegmentationMaskBounds",
83
+ "SegmentationMaskEncoding",
83
84
  "SegmentationMaskIdentity",
84
85
  "SegmentationMaskRecord",
85
86
  "SegmentationMedia",
86
- "SegmentationPointRecord",
87
87
  "SegmentationRecord",
88
88
  "SegmentationResult",
89
89
  "SegmentationSnapshot",
@@ -2,7 +2,6 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- import math
6
5
  import re
7
6
  from dataclasses import dataclass
8
7
  from typing import cast
@@ -23,7 +22,6 @@ from ._types import (
23
22
  SegmentationMaskIdentity,
24
23
  SegmentationMaskRecord,
25
24
  SegmentationMedia,
26
- SegmentationPointRecord,
27
25
  SegmentationRecord,
28
26
  SegmentationResult,
29
27
  SegmentationSnapshot,
@@ -40,65 +38,28 @@ _JS_WHITESPACE = (
40
38
  _JS_WHITESPACE_CLASS = (
41
39
  r"\t\n\v\f\r \u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff"
42
40
  )
43
- _JS_WHITESPACE_PATTERN = rf"[{_JS_WHITESPACE_CLASS}]"
44
- _JS_NON_WHITESPACE_PATTERN = rf"[^{_JS_WHITESPACE_CLASS}]"
45
41
  _JS_DOT_PATTERN = r"[^\n\r\u2028\u2029]"
46
- _RECORD_PATTERN = re.compile(
47
- rf"^(?:\[frame=([0-9]+)\]{_JS_WHITESPACE_PATTERN}+)?"
48
- rf"object=([A-Za-z0-9._-]+){_JS_WHITESPACE_PATTERN}+"
49
- rf"(point|box|mask)=({_JS_DOT_PATTERN}+)$"
50
- )
51
- _COMPACT_API_HEADER_PATTERN = re.compile(rf"^<([0-9]+)f>({_JS_DOT_PATTERN}*)$")
52
- _COMPACT_API_OBJECT_PATTERN = re.compile(
42
+ _API_HEADER_PATTERN = re.compile(rf"^<([0-9]+)f>({_JS_DOT_PATTERN}*)$")
43
+ _API_RECORD_PATTERN = re.compile(
53
44
  r"^(?:,)?([0-9]+)"
54
45
  r"<\|box;x1=(-?[0-9]+);y1=(-?[0-9]+);x2=(-?[0-9]+);"
55
46
  r"y2=(-?[0-9]+);w=([0-9]+);h=([0-9]+)\|>"
56
47
  r"<\|mask;x=0;y=0;data=([0-9]+),([0-9]+),([!~][^|]+)\|>"
57
48
  )
58
- _POINT_PATTERN = re.compile(r"^\(([^,]+),([^,]+)\)$")
59
- _BOX_PATTERN = re.compile(r"^\(([^,]+),([^,]+),([^,]+),([^,]+)\)$")
60
- _MASK_PATTERN = re.compile(
61
- rf"^([^;{_JS_WHITESPACE_CLASS}]+);"
62
- rf"size=([0-9]+)x([0-9]+);data=({_JS_NON_WHITESPACE_PATTERN}+)$"
63
- )
64
- _DECIMAL_NUMBER_PATTERN = re.compile(
65
- r"^[+-]?(?:(?:[0-9]+(?:\.[0-9]*)?)|(?:\.[0-9]+))"
66
- r"(?:[eE][+-]?[0-9]+)?$"
67
- )
68
- _HEXADECIMAL_NUMBER_PATTERN = re.compile(r"^0[xX][0-9A-Fa-f]+$")
69
- _BINARY_NUMBER_PATTERN = re.compile(r"^0[bB][01]+$")
70
- _OCTAL_NUMBER_PATTERN = re.compile(r"^0[oO][0-7]+$")
71
49
 
72
50
 
73
51
  def _trim(value: str) -> str:
74
52
  return value.strip(_JS_WHITESPACE)
75
53
 
76
54
 
77
- def _javascript_number(value: str) -> float | None:
78
- normalized = _trim(value)
79
- if not normalized:
80
- return None
81
- try:
82
- if _HEXADECIMAL_NUMBER_PATTERN.fullmatch(normalized):
83
- parsed = float(int(normalized[2:], 16))
84
- elif _BINARY_NUMBER_PATTERN.fullmatch(normalized):
85
- parsed = float(int(normalized[2:], 2))
86
- elif _OCTAL_NUMBER_PATTERN.fullmatch(normalized):
87
- parsed = float(int(normalized[2:], 8))
88
- elif _DECIMAL_NUMBER_PATTERN.fullmatch(normalized):
89
- parsed = float(normalized)
90
- else:
91
- return None
92
- except (OverflowError, ValueError):
93
- return None
94
- return parsed if math.isfinite(parsed) else None
95
-
96
-
97
55
  def _integer_text(value: str) -> int | None:
98
- parsed = _javascript_number(value)
99
- if parsed is None or not parsed.is_integer() or abs(parsed) > _MAXIMUM_SAFE_INTEGER:
100
- return None
101
- return int(parsed)
56
+ """Parse a decimal integer token the way JavaScript's ``Number`` observes it.
57
+
58
+ Callers only pass regex captures of ``-?[0-9]+``; values beyond the
59
+ JavaScript safe-integer range are rejected so both parsers agree.
60
+ """
61
+ parsed = int(value)
62
+ return parsed if abs(parsed) <= _MAXIMUM_SAFE_INTEGER else None
102
63
 
103
64
 
104
65
  class _SegmentationParser:
@@ -180,50 +141,15 @@ class _SegmentationParser:
180
141
  return False
181
142
 
182
143
  if line.startswith("<"):
183
- accepted = self._accept_compact_api_line(line, raw)
144
+ accepted = self._accept_api_line(line, raw)
184
145
  if accepted is not None:
185
146
  return accepted
186
147
 
187
- match = _RECORD_PATTERN.fullmatch(line)
188
- if match is None:
189
- if line.startswith("[frame=") or line.startswith("object="):
190
- self._diagnose("malformed_record", "Malformed structured record.", raw)
191
- else:
192
- self._add_record(
193
- SegmentationTextRecord(order=len(self._records), text=raw)
194
- )
195
- return True
196
-
197
- frame_index = None if match.group(1) is None else _integer_text(match.group(1))
198
- if match.group(1) is not None and (frame_index is None or frame_index < 0):
199
- self._diagnose(
200
- "invalid_frame",
201
- "Frame references must be non-negative safe integers.",
202
- raw,
203
- )
204
- return True
205
- if self._media == "image" and frame_index is not None:
206
- self._diagnose(
207
- "unexpected_frame",
208
- "Image segmentation records cannot include a frame reference.",
209
- raw,
210
- )
211
- return True
212
-
213
- object_id = match.group(2)
214
- kind = match.group(3)
215
- value = match.group(4)
216
- frame = None if frame_index is None else FrameReference(frame_index)
217
- if kind == "point":
218
- self._accept_point(object_id, frame, value, raw)
219
- elif kind == "box":
220
- self._accept_box(object_id, frame, value, raw)
221
- else:
222
- self._accept_mask(object_id, frame, value, raw)
148
+ self._add_record(SegmentationTextRecord(order=len(self._records), text=raw))
223
149
  return True
224
150
 
225
- def _accept_compact_api_line(self, line: str, raw: str) -> bool | None:
226
- header = _COMPACT_API_HEADER_PATTERN.fullmatch(line)
151
+ def _accept_api_line(self, line: str, raw: str) -> bool | None:
152
+ header = _API_HEADER_PATTERN.fullmatch(line)
227
153
  if header is None:
228
154
  return None
229
155
  frame_index = _integer_text(header.group(1))
@@ -247,17 +173,17 @@ class _SegmentationParser:
247
173
  if not remainder:
248
174
  self._diagnose(
249
175
  "malformed_record",
250
- "Malformed compact SAM API object record.",
176
+ "Malformed SAM API object record.",
251
177
  raw,
252
178
  )
253
179
  return True
254
180
  accepted = False
255
181
  while remainder:
256
- match = _COMPACT_API_OBJECT_PATTERN.match(remainder)
182
+ match = _API_RECORD_PATTERN.match(remainder)
257
183
  if match is None:
258
184
  self._diagnose(
259
185
  "malformed_record",
260
- "Malformed compact SAM API object record.",
186
+ "Malformed SAM API object record.",
261
187
  raw,
262
188
  )
263
189
  return True
@@ -267,7 +193,7 @@ class _SegmentationParser:
267
193
  )
268
194
  if any(value is None for value in coordinate_values):
269
195
  self._diagnose(
270
- "invalid_box", "Canonical SAM box coordinates are invalid.", raw
196
+ "invalid_box", "SAM API box coordinates are invalid.", raw
271
197
  )
272
198
  return True
273
199
  (
@@ -289,7 +215,7 @@ class _SegmentationParser:
289
215
  or inclusive_bottom >= source_height
290
216
  ):
291
217
  self._diagnose(
292
- "invalid_box", "Canonical SAM box coordinates are invalid.", raw
218
+ "invalid_box", "SAM API box coordinates are invalid.", raw
293
219
  )
294
220
  return True
295
221
  bounds = SegmentationMaskBounds(
@@ -309,14 +235,16 @@ class _SegmentationParser:
309
235
  bottom=inclusive_bottom + 1,
310
236
  )
311
237
  )
312
- mask_height = match.group(8)
313
- mask_width = match.group(9)
314
238
  payload = match.group(10)
315
- encoding = "lossless" if payload.startswith("~") else "one_bit"
316
239
  self._accept_mask(
317
240
  object_id,
318
241
  frame,
319
- f"{encoding};size={mask_width}x{mask_height};data={payload}",
242
+ SegmentationMask(
243
+ encoding="lossless" if payload.startswith("~") else "one_bit",
244
+ payload=payload,
245
+ width=int(match.group(9)),
246
+ height=int(match.group(8)),
247
+ ),
320
248
  raw,
321
249
  bounds,
322
250
  )
@@ -324,109 +252,20 @@ class _SegmentationParser:
324
252
  remainder = remainder[match.end() :]
325
253
  return accepted
326
254
 
327
- def _accept_point(
328
- self,
329
- object_id: str,
330
- frame: FrameReference | None,
331
- value: str,
332
- raw: str,
333
- ) -> None:
334
- match = _POINT_PATTERN.fullmatch(_trim(value))
335
- x = None if match is None else _javascript_number(match.group(1))
336
- y = None if match is None else _javascript_number(match.group(2))
337
- if x is None or y is None:
338
- self._diagnose(
339
- "malformed_point", "Point coordinates must be finite numbers.", raw
340
- )
341
- return
342
- self._add_record(
343
- SegmentationPointRecord(
344
- order=len(self._records),
345
- object_id=object_id,
346
- frame=frame,
347
- x=x,
348
- y=y,
349
- )
350
- )
351
-
352
- def _accept_box(
353
- self,
354
- object_id: str,
355
- frame: FrameReference | None,
356
- value: str,
357
- raw: str,
358
- ) -> None:
359
- match = _BOX_PATTERN.fullmatch(_trim(value))
360
- coordinates = (
361
- None
362
- if match is None
363
- else tuple(_javascript_number(item) for item in match.group(1, 2, 3, 4))
364
- )
365
- if coordinates is None or any(item is None for item in coordinates):
366
- self._diagnose(
367
- "malformed_box", "Box coordinates must be finite numbers.", raw
368
- )
369
- return
370
- left, top, right, bottom = cast(tuple[float, float, float, float], coordinates)
371
- if right < left or bottom < top:
372
- self._diagnose("invalid_box", "Box coordinates must be ordered.", raw)
373
- return
374
- self._add_record(
375
- SegmentationBoxRecord(
376
- order=len(self._records),
377
- object_id=object_id,
378
- frame=frame,
379
- left=left,
380
- top=top,
381
- right=right,
382
- bottom=bottom,
383
- )
384
- )
385
-
386
255
  def _accept_mask(
387
256
  self,
388
257
  object_id: str,
389
258
  frame: FrameReference | None,
390
- value: str,
259
+ mask: SegmentationMask,
391
260
  raw: str,
392
- bounds: SegmentationMaskBounds | None = None,
261
+ bounds: SegmentationMaskBounds,
393
262
  ) -> None:
394
- match = _MASK_PATTERN.fullmatch(_trim(value))
395
- if match is None:
396
- self._diagnose(
397
- "malformed_mask",
398
- "Mask records require encoding, size, and one complete payload.",
399
- raw,
400
- )
401
- return
402
- encoding = match.group(1)
403
- width_number = _javascript_number(match.group(2))
404
- height_number = _javascript_number(match.group(3))
405
- width = int(width_number) if width_number is not None else 0
406
- height = int(height_number) if height_number is not None else 0
407
- payload = match.group(4)
408
- if encoding not in ("one_bit", "lossless"):
409
- self._diagnose(
410
- "unsupported_mask_encoding",
411
- f"Unsupported complete mask encoding: {encoding}.",
412
- raw,
413
- )
414
- return
415
- area = float(width) * float(height)
416
- if (
417
- width <= 0
418
- or height <= 0
419
- or not math.isfinite(area)
420
- or not area.is_integer()
421
- or abs(area) > _MAXIMUM_SAFE_INTEGER
422
- ):
263
+ area = mask.width * mask.height
264
+ if mask.width <= 0 or mask.height <= 0 or area > _MAXIMUM_SAFE_INTEGER:
423
265
  self._diagnose(
424
266
  "invalid_mask_size", "Mask dimensions must be positive.", raw
425
267
  )
426
268
  return
427
- mask = SegmentationMask(
428
- encoding=encoding, payload=payload, width=width, height=height
429
- )
430
269
  try:
431
270
  decode_mask_to_raster(mask)
432
271
  except InvalidSegmentationMaskError as error:
@@ -107,6 +107,7 @@ class ParsedResponsesStream(AsyncIterator[EventT], Generic[EventT, ResultT]):
107
107
  self._error: BaseException | None = None
108
108
  self._lane: OutputTextLane | None = None
109
109
  self._lane_done = False
110
+ self._lane_finalizers: set[str] = set()
110
111
  self._text = ""
111
112
  self._saw_delta = False
112
113
 
@@ -169,6 +170,9 @@ class ParsedResponsesStream(AsyncIterator[EventT], Generic[EventT, ResultT]):
169
170
  if self._consumption == "idle":
170
171
  self._consumption = "final"
171
172
  failure = self._start_failure(ResponsesStreamAbortedError())
173
+ if current is self._active_read:
174
+ # A source finalizer cannot join the read that is running it.
175
+ return
172
176
  try:
173
177
  await asyncio.shield(failure)
174
178
  except asyncio.CancelledError:
@@ -274,30 +278,19 @@ class ParsedResponsesStream(AsyncIterator[EventT], Generic[EventT, ResultT]):
274
278
  self._push_parser(delta, emit=emit)
275
279
  return None
276
280
  if event_type == "response.output_text.done":
277
- lane = self._accept_lane(event)
278
- if self._lane_done:
279
- raise ResponsesStreamLaneError(
280
- "The response finalized its output text lane more than once.",
281
- self._lane,
282
- lane,
283
- )
284
- text = _field(event, "text")
285
- if not isinstance(text, str):
286
- raise ResponsesStreamLaneError(
287
- "The completed output text is missing its text.",
288
- self._lane,
289
- lane,
290
- )
291
- if self._saw_delta and text != self._text:
292
- raise ResponsesStreamLaneError(
293
- "The finalized output text conflicts with its accumulated deltas.",
294
- self._lane,
295
- lane,
281
+ self._finalize_output_text(event, _field(event, "text"), emit=emit)
282
+ return None
283
+ if event_type == "response.content_part.done":
284
+ part = _field(event, "part")
285
+ part_type = _field(part, "type")
286
+ if part_type == "output_text":
287
+ self._finalize_output_text(event, _field(part, "text"), emit=emit)
288
+ elif part_type == "refusal":
289
+ refusal = _field(part, "refusal")
290
+ message = (
291
+ refusal if isinstance(refusal, str) else "The response was refused."
296
292
  )
297
- if not self._saw_delta:
298
- self._text = text
299
- self._push_parser(text, emit=emit)
300
- self._lane_done = True
293
+ raise ResponsesStreamRefusalError(message, event)
301
294
  return None
302
295
  if event_type in ("response.refusal.delta", "response.refusal.done"):
303
296
  refusal = _field(event, "refusal")
@@ -342,6 +335,46 @@ class ParsedResponsesStream(AsyncIterator[EventT], Generic[EventT, ResultT]):
342
335
  raise ResponsesStreamEventError(event_message, event)
343
336
  return None
344
337
 
338
+ def _finalize_output_text(
339
+ self, event: ResponsesEventLike, text: object, *, emit: bool
340
+ ) -> None:
341
+ lane = self._accept_lane(event)
342
+ event_type = _field(event, "type")
343
+ assert isinstance(event_type, str)
344
+ if self._lane_done and event_type in self._lane_finalizers:
345
+ raise ResponsesStreamLaneError(
346
+ "The response finalized its output text lane more than once.",
347
+ self._lane,
348
+ lane,
349
+ )
350
+ if not isinstance(text, str):
351
+ raise ResponsesStreamLaneError(
352
+ "The completed output text is missing its text.",
353
+ self._lane,
354
+ lane,
355
+ )
356
+ if self._lane_done:
357
+ if text != self._text:
358
+ raise ResponsesStreamLaneError(
359
+ "The response finalized its output text lane twice with "
360
+ "conflicting text.",
361
+ self._lane,
362
+ lane,
363
+ )
364
+ self._lane_finalizers.add(event_type)
365
+ return
366
+ if self._saw_delta and text != self._text:
367
+ raise ResponsesStreamLaneError(
368
+ "The finalized output text conflicts with its accumulated deltas.",
369
+ self._lane,
370
+ lane,
371
+ )
372
+ if not self._saw_delta:
373
+ self._text = text
374
+ self._push_parser(text, emit=emit)
375
+ self._lane_done = True
376
+ self._lane_finalizers.add(event_type)
377
+
345
378
  def _accept_lane(self, event: ResponsesEventLike) -> OutputTextLane:
346
379
  item_id = _field(event, "item_id")
347
380
  output_index = _safe_nonnegative_integer(_field(event, "output_index"))
@@ -437,19 +470,41 @@ class ParsedResponsesStream(AsyncIterator[EventT], Generic[EventT, ResultT]):
437
470
  if isinstance(error, ResponsesStreamError)
438
471
  else ResponsesStreamParserError(error)
439
472
  )
473
+ # Stop admitting reads before yielding, and hand the current read to
474
+ # the one cleanup task. Consumer cancellation must not cancel it again.
475
+ self._interrupt_reads(failure)
476
+ active = self._active_read
440
477
  self._failure_task = self._ensure_loop().create_task(
441
- self._fail_and_close(failure)
478
+ self._fail_and_close(
479
+ failure, active, cancel_read=active is not asyncio.current_task()
480
+ )
442
481
  )
443
482
  self._observe(self._failure_task)
444
483
  return self._failure_task
445
484
 
446
- async def _fail_and_close(self, failure: ResponsesStreamError) -> None:
447
- self._interrupt_reads(failure)
448
- active = self._active_read
449
- if active is not None and not active.done():
450
- active.cancel()
451
- with suppress(BaseException):
485
+ async def _fail_and_close(
486
+ self,
487
+ failure: ResponsesStreamError,
488
+ active: asyncio.Future[ResponsesEventLike] | None,
489
+ *,
490
+ cancel_read: bool,
491
+ ) -> None:
492
+ if active is not None:
493
+ # A read initiating its own close must unwind itself: cancellation
494
+ # could interrupt the finalizer that requested cleanup.
495
+ if cancel_read and not active.done():
496
+ active.cancel()
497
+ try:
452
498
  await active
499
+ except (asyncio.CancelledError, StopAsyncIteration):
500
+ pass
501
+ except BaseException as error:
502
+ failure = ResponsesStreamSourceError(
503
+ "return", error, prior_error=failure
504
+ )
505
+ finally:
506
+ if self._active_read is active:
507
+ self._active_read = None
453
508
  if (
454
509
  isinstance(failure, ResponsesStreamSourceError)
455
510
  and failure.operation == "return"
@@ -486,7 +541,8 @@ class ParsedResponsesStream(AsyncIterator[EventT], Generic[EventT, ResultT]):
486
541
  failure = self._start_failure(ResponsesStreamAbortedError())
487
542
  with suppress(asyncio.CancelledError):
488
543
  await asyncio.shield(failure)
489
- self._consumer_done = True
544
+ if failure.done():
545
+ self._consumer_done = True
490
546
 
491
547
  def _ensure_source_iterator(self) -> AsyncIterator[ResponsesEventLike]:
492
548
  if self._source_iterator is not None:
@@ -500,6 +556,9 @@ class ParsedResponsesStream(AsyncIterator[EventT], Generic[EventT, ResultT]):
500
556
  return self._source_iterator
501
557
 
502
558
  async def _read_source(self) -> tuple[bool, ResponsesEventLike | None]:
559
+ interrupted = self._ensure_read_interruption()
560
+ if interrupted.done():
561
+ raise interrupted.result()
503
562
  iterator = self._ensure_source_iterator()
504
563
  try:
505
564
  awaitable = iterator.__anext__()
@@ -512,15 +571,12 @@ class ParsedResponsesStream(AsyncIterator[EventT], Generic[EventT, ResultT]):
512
571
  raise ResponsesStreamSourceError("next", error) from error
513
572
  self._active_read = reading
514
573
  self._observe(reading)
515
- interrupted = self._ensure_read_interruption()
516
574
  try:
517
575
  completed, _pending = await asyncio.wait(
518
576
  (reading, interrupted), return_when=asyncio.FIRST_COMPLETED
519
577
  )
520
578
  if interrupted in completed:
521
- interrupted_error = interrupted.result()
522
- reading.cancel()
523
- raise interrupted_error
579
+ raise interrupted.result()
524
580
  try:
525
581
  event = reading.result()
526
582
  except StopAsyncIteration:
@@ -540,16 +596,17 @@ class ParsedResponsesStream(AsyncIterator[EventT], Generic[EventT, ResultT]):
540
596
  raise ResponsesStreamSourceError("next", shape_error) from shape_error
541
597
  return False, event
542
598
  except asyncio.CancelledError:
543
- reading.cancel()
544
- with suppress(BaseException):
545
- await reading
599
+ self._start_failure(ResponsesStreamAbortedError())
546
600
  raise
547
601
  finally:
548
- if self._active_read is reading:
602
+ if self._active_read is reading and self._failure_task is None:
549
603
  self._active_read = None
550
604
 
551
605
  async def _close_source(self) -> None:
552
606
  current = asyncio.current_task()
607
+ if current is self._active_read:
608
+ self._start_failure(ResponsesStreamAbortedError())
609
+ return
553
610
  if current is self._close_task:
554
611
  return
555
612
  if self._close_task is None:
@@ -48,11 +48,20 @@ class SegmentationMaskBounds:
48
48
  bottom: float
49
49
 
50
50
 
51
+ SegmentationMaskEncoding: TypeAlias = Literal["lossless", "one_bit"]
52
+ """The wire encodings of a mask payload: ``~`` selects lossless, ``!`` one_bit."""
53
+
54
+
51
55
  @dataclass(frozen=True, slots=True)
52
56
  class SegmentationMask:
53
- """One complete encoded SAM 3 segmentation mask."""
57
+ """One complete mask exactly as the SAM API emitted it.
58
+
59
+ ``payload`` is the base85 text after the encoding marker and is never
60
+ partial; ``width`` and ``height`` are the raster's own dimensions, not the
61
+ frame's.
62
+ """
54
63
 
55
- encoding: str
64
+ encoding: SegmentationMaskEncoding
56
65
  payload: str
57
66
  width: int
58
67
  height: int
@@ -76,18 +85,6 @@ class SegmentationTextRecord:
76
85
  text: str
77
86
 
78
87
 
79
- @dataclass(frozen=True, slots=True)
80
- class SegmentationPointRecord:
81
- """A point associated with one object and optional frame."""
82
-
83
- kind: Literal["point"] = field(default="point", init=False)
84
- order: int
85
- object_id: str
86
- x: float
87
- y: float
88
- frame: FrameReference | None = None
89
-
90
-
91
88
  @dataclass(frozen=True, slots=True)
92
89
  class SegmentationBoxRecord:
93
90
  """A half-open or explicitly supplied box in source-media coordinates."""
@@ -112,15 +109,12 @@ class SegmentationMaskRecord:
112
109
  identity: SegmentationMaskIdentity
113
110
  revision: int
114
111
  mask: SegmentationMask
112
+ bounds: SegmentationMaskBounds
115
113
  frame: FrameReference | None = None
116
- bounds: SegmentationMaskBounds | None = None
117
114
 
118
115
 
119
116
  SegmentationRecord: TypeAlias = (
120
- SegmentationTextRecord
121
- | SegmentationPointRecord
122
- | SegmentationBoxRecord
123
- | SegmentationMaskRecord
117
+ SegmentationTextRecord | SegmentationBoxRecord | SegmentationMaskRecord
124
118
  )
125
119
 
126
120
 
File without changes