tensorplate-python 0.1.3__py3-none-any.whl → 0.1.5__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.
tensorplate/__init__.py CHANGED
@@ -16,7 +16,7 @@ from tensorplate.client import (
16
16
  canonicalize_serving_url,
17
17
  resolve_serving_url,
18
18
  )
19
- from tensorplate.conventions import YOLO_V8_SINGLE_OUTPUT, detections
19
+ from tensorplate.conventions import YOLO26_E2E_DETECTIONS, YOLO_V8_SINGLE_OUTPUT, detections
20
20
  from tensorplate.errors import (
21
21
  EndpointResolutionError,
22
22
  ErrorCode,
@@ -29,7 +29,7 @@ from tensorplate.errors import (
29
29
  )
30
30
  from tensorplate.postprocess import Detection, decode_detections
31
31
  from tensorplate.preprocess import LetterboxTransform, PreprocessConfig, preprocess
32
- from tensorplate.serving import HealthSnapshot, InferResult, ServingClient, Timing
32
+ from tensorplate.serving import ClientTiming, HealthSnapshot, InferResult, ServingClient, Timing
33
33
  from tensorplate.tensors import DType, Layout, TensorInput, TensorOutput
34
34
  from tensorplate.vision import VisionClient
35
35
 
@@ -40,7 +40,9 @@ except PackageNotFoundError: # pragma: no cover - only hit outside an installed
40
40
 
41
41
  __all__ = [
42
42
  "LOOPBACK_DEFAULT",
43
+ "YOLO26_E2E_DETECTIONS",
43
44
  "YOLO_V8_SINGLE_OUTPUT",
45
+ "ClientTiming",
44
46
  "DType",
45
47
  "Detection",
46
48
  "EndpointResolutionError",
tensorplate/client.py CHANGED
@@ -104,8 +104,9 @@ def http_request(
104
104
  :class:`RequestTimeoutError`.
105
105
  """
106
106
  request = urllib.request.Request(url, data=body, method=method)
107
- request.add_header("Accept", "application/json")
108
- if body is not None:
107
+ normalized_headers = {key.lower(): value for key, value in (headers or {}).items()}
108
+ request.add_header("Accept", normalized_headers.get("accept", "application/json"))
109
+ if body is not None and "content-type" not in normalized_headers:
109
110
  request.add_header("Content-Type", "application/json")
110
111
  for key, value in (headers or {}).items():
111
112
  request.add_header(key, value)
@@ -21,9 +21,15 @@ class detections:
21
21
  classes = "detections.classes"
22
22
 
23
23
 
24
- #: Supported built-in detector output contract: a single tensor shaped
24
+ #: Supported built-in YOLOv8-style detector output contract: a single tensor shaped
25
25
  #: ``[1, 4 + C, N]`` (YOLOv8-style; 4 box coords + C class scores over N
26
26
  #: anchors), or its transpose ``[1, N, 4 + C]`` when the caller declares
27
27
  #: it. Other heads (YOLOv5 objectness, exporter-side NMS, masks,
28
28
  #: keypoints) remain application-side postprocessing in v0.1.3.
29
29
  YOLO_V8_SINGLE_OUTPUT = "yolo_v8_single_output"
30
+
31
+ #: Supported built-in YOLO26 default one-to-one / end-to-end detector output
32
+ #: contract: a single NMS-free tensor shaped ``[1, K, 6]`` with
33
+ #: ``K <= 300`` and columns ``x1, y1, x2, y2, score, class_id`` in
34
+ #: letterboxed model-input pixels.
35
+ YOLO26_E2E_DETECTIONS = "yolo26_e2e_detections"
@@ -7,13 +7,14 @@ lazily.
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import math
10
11
  from collections.abc import Sequence
11
12
  from dataclasses import dataclass
12
13
  from typing import TYPE_CHECKING
13
14
 
14
- from tensorplate.conventions import YOLO_V8_SINGLE_OUTPUT
15
+ from tensorplate.conventions import YOLO26_E2E_DETECTIONS, YOLO_V8_SINGLE_OUTPUT
15
16
  from tensorplate.errors import ProtocolError
16
- from tensorplate.tensors import TensorOutput
17
+ from tensorplate.tensors import DType, TensorOutput
17
18
 
18
19
  if TYPE_CHECKING:
19
20
  import numpy as np
@@ -41,16 +42,22 @@ def decode_detections(
41
42
  transposed: bool = False,
42
43
  contract: str = YOLO_V8_SINGLE_OUTPUT,
43
44
  ) -> list[Detection]:
44
- """Decode a YOLOv8-style single-output tensor into source-pixel detections.
45
+ """Decode a supported YOLO-style output tensor into source-pixel detections.
45
46
 
46
- Expects ``[1, 4 + C, N]`` (or ``[1, N, 4 + C]`` when ``transposed``): 4
47
- box coordinates (center-x, center-y, width, height in model-input
48
- pixels) followed by C per-class scores. Applies score thresholding and
49
- class-aware NMS, then maps boxes back to source pixels via ``transform``.
47
+ ``yolo_v8_single_output`` expects ``[1, 4 + C, N]`` (or
48
+ ``[1, N, 4 + C]`` when ``transposed``): 4 center-format box coordinates
49
+ followed by C per-class scores. It applies score thresholding and
50
+ class-aware NMS.
51
+
52
+ ``yolo26_e2e_detections`` expects NMS-free ``[1, K, 6]`` with columns
53
+ x1, y1, x2, y2, score, class_id. It applies only score thresholding.
50
54
  """
55
+ if contract == YOLO26_E2E_DETECTIONS:
56
+ return _decode_yolo26_e2e(output, transform, score_threshold=score_threshold, labels=labels)
51
57
  if contract != YOLO_V8_SINGLE_OUTPUT:
52
58
  raise ValueError(
53
- f"unsupported output contract {contract!r}; only {YOLO_V8_SINGLE_OUTPUT!r} is built in"
59
+ f"unsupported output contract {contract!r}; supported contracts are "
60
+ f"{YOLO_V8_SINGLE_OUTPUT!r} and {YOLO26_E2E_DETECTIONS!r}"
54
61
  )
55
62
  import numpy as np
56
63
 
@@ -99,6 +106,56 @@ def decode_detections(
99
106
  return detections
100
107
 
101
108
 
109
+ def _decode_yolo26_e2e(
110
+ output: TensorOutput,
111
+ transform: LetterboxTransform,
112
+ *,
113
+ score_threshold: float,
114
+ labels: Sequence[str] | None,
115
+ ) -> list[Detection]:
116
+ if output.dtype not in (DType.FLOAT16, DType.FLOAT32):
117
+ raise ProtocolError(
118
+ f"detector output for {YOLO26_E2E_DETECTIONS!r} must be float16 or float32, "
119
+ f"got {output.dtype.value!r}"
120
+ )
121
+ try:
122
+ array = output.to_numpy().astype("float32")
123
+ except (RuntimeError, ValueError) as exc:
124
+ raise ProtocolError(
125
+ f"could not convert detector output for {YOLO26_E2E_DETECTIONS!r} to float32"
126
+ ) from exc
127
+ if array.ndim != 3 or array.shape[0] != 1 or array.shape[2] != 6:
128
+ raise ProtocolError(
129
+ f"detector output must be [1, K, 6] for {YOLO26_E2E_DETECTIONS!r}, "
130
+ f"got shape {tuple(array.shape)}"
131
+ )
132
+ if array.shape[1] > 300:
133
+ raise ProtocolError(
134
+ f"detector output has {int(array.shape[1])} rows for {YOLO26_E2E_DETECTIONS!r}; "
135
+ "expected K <= 300"
136
+ )
137
+
138
+ detections: list[Detection] = []
139
+ for row in array[0]:
140
+ score = float(row[4])
141
+ # Skip non-finite scores (NaN never compares >= threshold and would
142
+ # otherwise fall through to int(row[5]) and raise on a NaN class id).
143
+ if not math.isfinite(score) or score < score_threshold:
144
+ continue
145
+ class_value = float(row[5])
146
+ # A real YOLO26 head never emits a negative or non-finite class id;
147
+ # drop such rows rather than store a nonsensical Detection.class_id.
148
+ if not math.isfinite(class_value) or class_value < 0:
149
+ continue
150
+ class_id = int(class_value)
151
+ box = transform.map_box_to_source(
152
+ float(row[0]), float(row[1]), float(row[2]), float(row[3])
153
+ )
154
+ label = labels[class_id] if labels is not None and class_id < len(labels) else None
155
+ detections.append(Detection(class_id=class_id, score=score, box=box, label=label))
156
+ return detections
157
+
158
+
102
159
  def _looks_like_wrong_layout(array: np.ndarray, transposed: bool) -> bool:
103
160
  """Return true for the common unambiguous YOLO transpose mismatch."""
104
161
  rows = int(array.shape[1])
tensorplate/serving.py CHANGED
@@ -3,9 +3,13 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
+ import math
7
+ import struct
8
+ import time
6
9
  import uuid
7
10
  from collections.abc import Sequence
8
11
  from dataclasses import dataclass
12
+ from typing import Literal
9
13
 
10
14
  from tensorplate.client import (
11
15
  DEFAULT_TIMEOUT_S,
@@ -19,12 +23,16 @@ from tensorplate.errors import (
19
23
  ServingError,
20
24
  UnsupportedSchemaVersionError,
21
25
  )
22
- from tensorplate.tensors import DType, Layout, TensorInput, TensorOutput
26
+ from tensorplate.tensors import DType, Layout, TensorInput, TensorOutput, itemsize
23
27
 
24
28
  #: Serving envelope schema_version this SDK speaks.
25
29
  SCHEMA_VERSION = "0.1"
26
30
 
27
31
  _HEALTH_PATH = "/health"
32
+ _BINARY_CONTENT_TYPE = "application/vnd.tensorplate.infer.binary.v1"
33
+ _BINARY_INFER_MAGIC = b"TPINFER1"
34
+ _BINARY_RESULT_MAGIC = b"TPRESULT1"
35
+ TransportPreference = Literal["auto", "binary", "json"]
28
36
 
29
37
 
30
38
  @dataclass(frozen=True)
@@ -36,6 +44,17 @@ class Timing:
36
44
  total_latency_ns: int | None = None
37
45
 
38
46
 
47
+ @dataclass(frozen=True)
48
+ class ClientTiming:
49
+ """Optional SDK-side timing for request encode, transport, and decode."""
50
+
51
+ encode_ns: int
52
+ http_roundtrip_ns: int
53
+ decode_ns: int
54
+ request_bytes: int
55
+ response_bytes: int
56
+
57
+
39
58
  @dataclass(frozen=True)
40
59
  class InferResult:
41
60
  """Parsed v0.1 ``success`` response."""
@@ -44,6 +63,8 @@ class InferResult:
44
63
  outputs: tuple[TensorOutput, ...]
45
64
  correlation_id: str | None = None
46
65
  timing: Timing | None = None
66
+ transport: str = "json"
67
+ client_timing: ClientTiming | None = None
47
68
 
48
69
  def output(self, name: str) -> TensorOutput:
49
70
  """Return the output tensor named ``name`` or raise ``KeyError``."""
@@ -89,7 +110,10 @@ class ServingClient:
89
110
  config_path: str | None = None,
90
111
  timeout: float = DEFAULT_TIMEOUT_S,
91
112
  discover: bool = True,
113
+ preferred_transport: TransportPreference = "auto",
92
114
  ) -> None:
115
+ if preferred_transport not in ("auto", "binary", "json"):
116
+ raise ValueError("preferred_transport must be 'auto', 'binary', or 'json'")
93
117
  self._endpoint = resolve_serving_url(
94
118
  serving_url,
95
119
  profile=profile,
@@ -98,6 +122,8 @@ class ServingClient:
98
122
  discover=discover,
99
123
  )
100
124
  self._timeout = timeout
125
+ self._preferred_transport: TransportPreference = preferred_transport
126
+ self._binary_supported: bool | None = None
101
127
 
102
128
  @property
103
129
  def endpoint(self) -> ResolvedEndpoint:
@@ -129,6 +155,7 @@ class ServingClient:
129
155
  *,
130
156
  deadline_ms: int | None = None,
131
157
  correlation_id: str | None = None,
158
+ profile: bool = False,
132
159
  ) -> InferResult:
133
160
  """Run a single synchronous inference against ``endpoint``.
134
161
 
@@ -150,23 +177,118 @@ class ServingClient:
150
177
  if correlation_id is not None and not correlation_id:
151
178
  raise ValueError("correlation_id must be non-empty when provided")
152
179
  request_id = str(uuid.uuid4())
153
- request_body: dict[str, object] = {
154
- "schema_version": SCHEMA_VERSION,
155
- "request_id": request_id,
156
- "endpoint": endpoint,
157
- "inputs": [tensor.to_named_input() for tensor in inputs],
158
- }
159
- if deadline_ms is not None:
160
- request_body["deadline_ms"] = deadline_ms
161
- headers: dict[str, str] = {}
162
- if correlation_id is not None:
163
- request_body["metadata"] = {"correlation_id": correlation_id}
164
- headers["X-Correlation-Id"] = correlation_id
165
- payload = json.dumps(request_body).encode("utf-8")
180
+ if self._should_try_binary():
181
+ status_code, raw, timing = self._infer_http(
182
+ "binary",
183
+ request_id,
184
+ endpoint,
185
+ inputs,
186
+ deadline_ms=deadline_ms,
187
+ correlation_id=correlation_id,
188
+ profile=profile,
189
+ )
190
+ if status_code == 415 and _is_binary_unsupported(raw):
191
+ if self._preferred_transport == "binary":
192
+ return _parse_infer_response(
193
+ raw, status_code, self._endpoint.url, "binary", timing
194
+ )
195
+ # In 'auto' mode a 415 'unsupported' is ambiguous: the worker may
196
+ # lack the binary transport, or the request itself may be
197
+ # unsupported (unknown dtype/layout, schema_version, ...) — both
198
+ # surface as 415 + code 'unsupported'. Retry over JSON and only
199
+ # mark binary unsupported when that retry actually succeeds;
200
+ # otherwise the failure was request content, not transport
201
+ # capability, so binary must stay enabled (a transient bad
202
+ # request must not permanently downgrade the client to JSON).
203
+ status_code, raw, timing = self._infer_http(
204
+ "json",
205
+ request_id,
206
+ endpoint,
207
+ inputs,
208
+ deadline_ms=deadline_ms,
209
+ correlation_id=correlation_id,
210
+ profile=profile,
211
+ )
212
+ result = _parse_infer_response(raw, status_code, self._endpoint.url, "json", timing)
213
+ self._binary_supported = False
214
+ return result
215
+ else:
216
+ result = _parse_binary_or_json_response(
217
+ raw, status_code, self._endpoint.url, "binary", timing
218
+ )
219
+ if result.transport == "binary":
220
+ self._binary_supported = True
221
+ return result
222
+
223
+ status_code, raw, timing = self._infer_http(
224
+ "json",
225
+ request_id,
226
+ endpoint,
227
+ inputs,
228
+ deadline_ms=deadline_ms,
229
+ correlation_id=correlation_id,
230
+ profile=profile,
231
+ )
232
+ return _parse_infer_response(raw, status_code, self._endpoint.url, "json", timing)
233
+
234
+ def _should_try_binary(self) -> bool:
235
+ if self._preferred_transport == "binary":
236
+ return True
237
+ if self._preferred_transport == "json":
238
+ return False
239
+ return self._binary_supported is not False
240
+
241
+ def _infer_http(
242
+ self,
243
+ transport: Literal["binary", "json"],
244
+ request_id: str,
245
+ endpoint: str,
246
+ inputs: Sequence[TensorInput],
247
+ *,
248
+ deadline_ms: int | None,
249
+ correlation_id: str | None,
250
+ profile: bool,
251
+ ) -> tuple[int, bytes, ClientTiming | None]:
252
+ t0 = time.perf_counter_ns()
253
+ if transport == "binary":
254
+ payload = _encode_binary_infer_request(
255
+ request_id,
256
+ endpoint,
257
+ inputs,
258
+ deadline_ms=deadline_ms,
259
+ correlation_id=correlation_id,
260
+ )
261
+ headers = {
262
+ "Content-Type": _BINARY_CONTENT_TYPE,
263
+ "Accept": f"{_BINARY_CONTENT_TYPE}, application/json",
264
+ }
265
+ if correlation_id is not None:
266
+ headers["X-Correlation-Id"] = correlation_id
267
+ else:
268
+ payload, headers = _encode_json_infer_request(
269
+ request_id,
270
+ endpoint,
271
+ inputs,
272
+ deadline_ms=deadline_ms,
273
+ correlation_id=correlation_id,
274
+ )
275
+ t1 = time.perf_counter_ns()
166
276
  status_code, raw = http_request(
167
277
  "POST", self._endpoint.url, body=payload, headers=headers, timeout=self._timeout
168
278
  )
169
- return _parse_infer_response(raw, status_code, self._endpoint.url)
279
+ t2 = time.perf_counter_ns()
280
+ timing = (
281
+ ClientTiming(
282
+ encode_ns=t1 - t0,
283
+ http_roundtrip_ns=t2 - t1,
284
+ decode_ns=0,
285
+ request_bytes=len(payload),
286
+ response_bytes=len(raw),
287
+ )
288
+ if profile
289
+ else None
290
+ )
291
+ return status_code, raw, timing
170
292
 
171
293
  def health(self) -> HealthSnapshot:
172
294
  """Read ``GET /health`` and parse the worker's readiness snapshot.
@@ -182,6 +304,72 @@ class ServingClient:
182
304
  return _parse_health(payload, url)
183
305
 
184
306
 
307
+ def _encode_json_infer_request(
308
+ request_id: str,
309
+ endpoint: str,
310
+ inputs: Sequence[TensorInput],
311
+ *,
312
+ deadline_ms: int | None = None,
313
+ correlation_id: str | None = None,
314
+ ) -> tuple[bytes, dict[str, str]]:
315
+ request_body: dict[str, object] = {
316
+ "schema_version": SCHEMA_VERSION,
317
+ "request_id": request_id,
318
+ "endpoint": endpoint,
319
+ "inputs": [tensor.to_named_input() for tensor in inputs],
320
+ }
321
+ if deadline_ms is not None:
322
+ request_body["deadline_ms"] = deadline_ms
323
+ headers: dict[str, str] = {}
324
+ if correlation_id is not None:
325
+ request_body["metadata"] = {"correlation_id": correlation_id}
326
+ headers["X-Correlation-Id"] = correlation_id
327
+ return json.dumps(request_body).encode("utf-8"), headers
328
+
329
+
330
+ def _encode_binary_infer_request(
331
+ request_id: str,
332
+ endpoint: str,
333
+ inputs: Sequence[TensorInput],
334
+ *,
335
+ deadline_ms: int | None = None,
336
+ correlation_id: str | None = None,
337
+ ) -> bytes:
338
+ payload = bytearray()
339
+ encoded_inputs: list[dict[str, object]] = []
340
+ for tensor in inputs:
341
+ offset = len(payload)
342
+ payload.extend(tensor.data)
343
+ encoded_inputs.append(
344
+ {
345
+ "name": tensor.name,
346
+ "tensor": {
347
+ "dtype": tensor.dtype.value,
348
+ "layout": tensor.layout.value,
349
+ "shape": list(tensor.shape),
350
+ "byte_offset": 0,
351
+ "byte_size": len(tensor.data),
352
+ },
353
+ "payload_offset": offset,
354
+ "payload_size": len(tensor.data),
355
+ }
356
+ )
357
+ request_body: dict[str, object] = {
358
+ "schema_version": SCHEMA_VERSION,
359
+ "request_id": request_id,
360
+ "endpoint": endpoint,
361
+ "inputs": encoded_inputs,
362
+ }
363
+ if deadline_ms is not None:
364
+ request_body["deadline_ms"] = deadline_ms
365
+ if correlation_id is not None:
366
+ request_body["metadata"] = {"correlation_id": correlation_id}
367
+ metadata = json.dumps(request_body, separators=(",", ":")).encode("utf-8")
368
+ if len(metadata) > 0xFFFFFFFF:
369
+ raise ValueError("binary infer metadata exceeds uint32 length limit")
370
+ return _BINARY_INFER_MAGIC + struct.pack("<I", len(metadata)) + metadata + bytes(payload)
371
+
372
+
185
373
  def _decode_json_object(raw: bytes, url: str) -> dict[str, object]:
186
374
  try:
187
375
  parsed = json.loads(raw)
@@ -200,7 +388,47 @@ def _require_supported_schema(payload: dict[str, object]) -> None:
200
388
  )
201
389
 
202
390
 
203
- def _parse_infer_response(raw: bytes, status_code: int, url: str) -> InferResult:
391
+ def _with_decode_timing(
392
+ result: InferResult, timing: ClientTiming | None, started_ns: int
393
+ ) -> InferResult:
394
+ if timing is None:
395
+ return result
396
+ return InferResult(
397
+ request_id=result.request_id,
398
+ outputs=result.outputs,
399
+ correlation_id=result.correlation_id,
400
+ timing=result.timing,
401
+ transport=result.transport,
402
+ client_timing=ClientTiming(
403
+ encode_ns=timing.encode_ns,
404
+ http_roundtrip_ns=timing.http_roundtrip_ns,
405
+ decode_ns=time.perf_counter_ns() - started_ns,
406
+ request_bytes=timing.request_bytes,
407
+ response_bytes=timing.response_bytes,
408
+ ),
409
+ )
410
+
411
+
412
+ def _parse_binary_or_json_response(
413
+ raw: bytes,
414
+ status_code: int,
415
+ url: str,
416
+ transport: str,
417
+ client_timing: ClientTiming | None = None,
418
+ ) -> InferResult:
419
+ if raw.startswith(_BINARY_RESULT_MAGIC):
420
+ return _parse_binary_infer_response(raw, status_code, url, client_timing)
421
+ return _parse_infer_response(raw, status_code, url, transport, client_timing)
422
+
423
+
424
+ def _parse_infer_response(
425
+ raw: bytes,
426
+ status_code: int,
427
+ url: str,
428
+ transport: str = "json",
429
+ client_timing: ClientTiming | None = None,
430
+ ) -> InferResult:
431
+ decode_started = time.perf_counter_ns()
204
432
  payload = _decode_json_object(raw, url)
205
433
  _require_supported_schema(payload)
206
434
  status = payload.get("status")
@@ -218,14 +446,145 @@ def _parse_infer_response(raw: bytes, status_code: int, url: str) -> InferResult
218
446
  if not isinstance(request_id, str):
219
447
  raise ProtocolError(f"serving success response from {url!r} is missing 'request_id'")
220
448
  correlation_id = payload.get("correlation_id")
221
- return InferResult(
222
- request_id=request_id,
223
- outputs=outputs,
224
- correlation_id=correlation_id if isinstance(correlation_id, str) else None,
225
- timing=_parse_timing(payload.get("timing")),
449
+ return _with_decode_timing(
450
+ InferResult(
451
+ request_id=request_id,
452
+ outputs=outputs,
453
+ correlation_id=correlation_id if isinstance(correlation_id, str) else None,
454
+ timing=_parse_timing(payload.get("timing")),
455
+ transport=transport,
456
+ ),
457
+ client_timing,
458
+ decode_started,
226
459
  )
227
460
 
228
461
 
462
+ def _parse_binary_infer_response(
463
+ raw: bytes,
464
+ status_code: int,
465
+ url: str,
466
+ client_timing: ClientTiming | None = None,
467
+ ) -> InferResult:
468
+ del status_code
469
+ decode_started = time.perf_counter_ns()
470
+ metadata, payload = _split_binary_envelope(raw, _BINARY_RESULT_MAGIC, url)
471
+ _require_supported_schema(metadata)
472
+ if metadata.get("status") != "success":
473
+ raise ProtocolError(f"binary serving response from {url!r} did not declare success")
474
+ outputs_obj = metadata.get("outputs")
475
+ if not isinstance(outputs_obj, list) or not outputs_obj:
476
+ raise ProtocolError(f"binary serving success response from {url!r} is missing outputs")
477
+ outputs = tuple(_binary_output_from_metadata(item, payload) for item in outputs_obj)
478
+ request_id = metadata.get("request_id")
479
+ if not isinstance(request_id, str):
480
+ raise ProtocolError(f"binary serving success response from {url!r} is missing request_id")
481
+ correlation_id = metadata.get("correlation_id")
482
+ return _with_decode_timing(
483
+ InferResult(
484
+ request_id=request_id,
485
+ outputs=outputs,
486
+ correlation_id=correlation_id if isinstance(correlation_id, str) else None,
487
+ timing=_parse_timing(metadata.get("timing")),
488
+ transport="binary",
489
+ ),
490
+ client_timing,
491
+ decode_started,
492
+ )
493
+
494
+
495
+ def _split_binary_envelope(raw: bytes, magic: bytes, url: str) -> tuple[dict[str, object], bytes]:
496
+ header_size = len(magic) + 4
497
+ if len(raw) < header_size or not raw.startswith(magic):
498
+ raise ProtocolError(f"binary serving response from {url!r} has invalid magic")
499
+ (metadata_len,) = struct.unpack("<I", raw[len(magic) : header_size])
500
+ metadata_end = header_size + metadata_len
501
+ if len(raw) < metadata_end:
502
+ raise ProtocolError(f"binary serving response from {url!r} has truncated metadata")
503
+ try:
504
+ metadata = json.loads(raw[header_size:metadata_end])
505
+ except ValueError as exc:
506
+ raise ProtocolError(
507
+ f"binary serving response from {url!r} has invalid metadata JSON: {exc}"
508
+ ) from exc
509
+ if not isinstance(metadata, dict):
510
+ raise ProtocolError(f"binary serving response from {url!r} metadata is not an object")
511
+ return metadata, raw[metadata_end:]
512
+
513
+
514
+ def _binary_output_from_metadata(obj: object, payload: bytes) -> TensorOutput:
515
+ if not isinstance(obj, dict):
516
+ raise ProtocolError("binary serving output entry is not a JSON object")
517
+ name = obj.get("name")
518
+ if not isinstance(name, str) or not name:
519
+ raise ProtocolError("binary serving output is missing a non-empty 'name'")
520
+ tensor = obj.get("tensor")
521
+ if not isinstance(tensor, dict):
522
+ raise ProtocolError(f"binary serving output {name!r} is missing tensor metadata")
523
+ dtype_value = tensor.get("dtype")
524
+ try:
525
+ dtype = DType(dtype_value)
526
+ except ValueError as exc:
527
+ raise ProtocolError(
528
+ f"binary serving output {name!r} has unknown dtype {dtype_value!r}"
529
+ ) from exc
530
+ layout_value = tensor.get("layout", Layout.ROW_MAJOR.value)
531
+ try:
532
+ layout = Layout(layout_value)
533
+ except ValueError as exc:
534
+ raise ProtocolError(
535
+ f"binary serving output {name!r} has unknown layout {layout_value!r}"
536
+ ) from exc
537
+ shape_raw = tensor.get("shape")
538
+ if (
539
+ not isinstance(shape_raw, list)
540
+ or not shape_raw
541
+ or not all(
542
+ isinstance(dim, int) and not isinstance(dim, bool) and dim >= 1 for dim in shape_raw
543
+ )
544
+ ):
545
+ raise ProtocolError(f"binary serving output {name!r} has an invalid shape")
546
+ shape = tuple(int(dim) for dim in shape_raw)
547
+ expected_size = itemsize(dtype) * math.prod(shape)
548
+ offset = _required_nonnegative_int(obj, "payload_offset", name)
549
+ size = _required_nonnegative_int(obj, "payload_size", name)
550
+ if size < expected_size:
551
+ raise ProtocolError(
552
+ f"binary serving output {name!r} payload_size is {size}, expected {expected_size}"
553
+ )
554
+ end = offset + size
555
+ if len(payload) < end:
556
+ raise ProtocolError(
557
+ f"binary serving output {name!r} payload is {len(payload)} bytes, need {end}"
558
+ )
559
+ semantic_tag = obj.get("semantic_tag")
560
+ return TensorOutput(
561
+ name=name,
562
+ dtype=dtype,
563
+ shape=shape,
564
+ data=payload[offset : offset + expected_size],
565
+ layout=layout,
566
+ semantic_tag=semantic_tag if isinstance(semantic_tag, str) else None,
567
+ )
568
+
569
+
570
+ def _required_nonnegative_int(obj: dict[str, object], field: str, name: str) -> int:
571
+ value = obj.get(field)
572
+ if not isinstance(value, int) or isinstance(value, bool) or value < 0:
573
+ raise ProtocolError(f"binary serving output {name!r} has invalid {field!r}")
574
+ return value
575
+
576
+
577
+ def _is_binary_unsupported(raw: bytes) -> bool:
578
+ try:
579
+ payload = json.loads(raw)
580
+ except (ValueError, UnicodeDecodeError):
581
+ return True
582
+ if not isinstance(payload, dict) or payload.get("status") != "failure":
583
+ return False
584
+ error_obj = payload.get("error")
585
+ return isinstance(error_obj, dict) and error_obj.get("code") == ErrorCode.UNSUPPORTED.value
586
+
587
+
229
588
  def _serving_error(payload: dict[str, object]) -> ServingError:
230
589
  request_id = payload.get("request_id")
231
590
  request_id = request_id if isinstance(request_id, str) else None
tensorplate/vision.py CHANGED
@@ -12,7 +12,7 @@ from tensorplate.conventions import YOLO_V8_SINGLE_OUTPUT, detections
12
12
  from tensorplate.errors import ProtocolError
13
13
  from tensorplate.postprocess import Detection, decode_detections
14
14
  from tensorplate.preprocess import PreprocessConfig, preprocess
15
- from tensorplate.serving import InferResult, ServingClient
15
+ from tensorplate.serving import InferResult, ServingClient, TransportPreference
16
16
  from tensorplate.tensors import TensorOutput
17
17
 
18
18
  if TYPE_CHECKING:
@@ -39,6 +39,7 @@ class VisionClient:
39
39
  config_path: str | None = None,
40
40
  timeout: float = DEFAULT_TIMEOUT_S,
41
41
  discover: bool = True,
42
+ preferred_transport: TransportPreference = "auto",
42
43
  client: ServingClient | None = None,
43
44
  ) -> None:
44
45
  self._serving = client or ServingClient(
@@ -47,6 +48,7 @@ class VisionClient:
47
48
  config_path=config_path,
48
49
  timeout=timeout,
49
50
  discover=discover,
51
+ preferred_transport=preferred_transport,
50
52
  )
51
53
 
52
54
  @property
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tensorplate-python
3
- Version: 0.1.3
3
+ Version: 0.1.5
4
4
  Summary: First-party Python SDK for calling deployed TensorPlate detection and vision serving models.
5
5
  Author: TensorPlate Contributors
6
6
  License: Apache-2.0
@@ -0,0 +1,15 @@
1
+ tensorplate/__init__.py,sha256=GnFr1z9YVQirlbudOJzmYlpPWmBnFLLKL24_SyOBvsk,2047
2
+ tensorplate/client.py,sha256=hVoVAaqXUT8ZcP6HDbhk1OtqL_4uS5R9E4LgPDz4n_M,10673
3
+ tensorplate/conventions.py,sha256=6b2WBWHAGNxuPninMqNVRpe6pGz2k8FCI1SbLe-r33c,1414
4
+ tensorplate/errors.py,sha256=oZSYgG1T8TtLaw4jFwW806KzfDzke_5GB6Omlkvx8Uw,2718
5
+ tensorplate/postprocess.py,sha256=nKcPf5LeIOZIn-IlEKhQOOV3Qc6YUMAYk5uiTThco2s,7553
6
+ tensorplate/preprocess.py,sha256=ItUWijW3jKzel3ESLhnjg5ag6BoNXQhytpa_2o08fKk,6019
7
+ tensorplate/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ tensorplate/serving.py,sha256=pq3mvLsgqf2DyFcbdi-zZM0JZglJdd_O_kyd3TIzhtw,23560
9
+ tensorplate/tensors.py,sha256=KvI3zYkAfNuquBKb9xDT-Mt4W8HudMECt02aHbMKm6Y,8939
10
+ tensorplate/vision.py,sha256=d4p-Gm5X4fhF4QH0nI0w66kS74pEZZywS_teUAr9c0A,4664
11
+ tensorplate_python-0.1.5.dist-info/licenses/LICENSE,sha256=ZxVIDHiEBfOHLu3QJTeSSO8fYRNUlInHP9ESK3DZmEE,11389
12
+ tensorplate_python-0.1.5.dist-info/METADATA,sha256=4kVJXFmVg9ro0cfMWg6S6eX8k6S9HvyjMx6jHutqGmA,2905
13
+ tensorplate_python-0.1.5.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
14
+ tensorplate_python-0.1.5.dist-info/top_level.txt,sha256=FFVk-zeOOH6pSd9Hd9gzDD7WSJpbZ23RazVSCssIXnM,12
15
+ tensorplate_python-0.1.5.dist-info/RECORD,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (82.0.1)
2
+ Generator: setuptools (83.0.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -1,15 +0,0 @@
1
- tensorplate/__init__.py,sha256=cPJYqsMBR-PjWtfqN1N1yA2alUrVv32RwaSSTPQ1UUs,1961
2
- tensorplate/client.py,sha256=SwgFZYnxXO1Ff3ruBSIAdCMzbyReGixlXOvjYK6Oj5M,10506
3
- tensorplate/conventions.py,sha256=rysRZVfesQ6vRLrlTxiWbRBK4zbazvv-9njmBVKdJJo,1109
4
- tensorplate/errors.py,sha256=oZSYgG1T8TtLaw4jFwW806KzfDzke_5GB6Omlkvx8Uw,2718
5
- tensorplate/postprocess.py,sha256=GusFErKCJdUERoDUDvdrPbMkt0wtEFkXL5R3_c5O_JA,5186
6
- tensorplate/preprocess.py,sha256=ItUWijW3jKzel3ESLhnjg5ag6BoNXQhytpa_2o08fKk,6019
7
- tensorplate/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
- tensorplate/serving.py,sha256=d4OYe4JdcPFJWTwTzbi3xgbftFxgfSEHDiubc7C_eAc,10287
9
- tensorplate/tensors.py,sha256=KvI3zYkAfNuquBKb9xDT-Mt4W8HudMECt02aHbMKm6Y,8939
10
- tensorplate/vision.py,sha256=KwbVfPqtqdbIGSzEKqbkif9-KZj6U0zUZ81MrQDPPhw,4531
11
- tensorplate_python-0.1.3.dist-info/licenses/LICENSE,sha256=ZxVIDHiEBfOHLu3QJTeSSO8fYRNUlInHP9ESK3DZmEE,11389
12
- tensorplate_python-0.1.3.dist-info/METADATA,sha256=vS3F-CXLjd5BCrwYKRsUkzbzkyZZtNPp9d3Oy62JX3c,2905
13
- tensorplate_python-0.1.3.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
14
- tensorplate_python-0.1.3.dist-info/top_level.txt,sha256=FFVk-zeOOH6pSd9Hd9gzDD7WSJpbZ23RazVSCssIXnM,12
15
- tensorplate_python-0.1.3.dist-info/RECORD,,