boxes-client 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,106 @@
1
+ """Best-effort decoding of Envelope data values — the *legacy auto* path.
2
+
3
+ Boxes send heavy payloads as raw ``bytes``. The old client *guessed* the
4
+ encoding; that guessing is kept only as a **legacy fallback** so
5
+ un-migrated boxes keep working. The documented path is the box-declared
6
+ ``"encoding"`` keyword decoded by the named codecs in
7
+ :mod:`boxes_client.codec` (see ``CODECS.md``): when a field has a declared
8
+ codec, :mod:`boxes_client.result` uses :func:`codec.decode_with` directly
9
+ and this guessing never runs.
10
+
11
+ Guess chain (legacy): JSON (if it looks like JSON) -> torch (if installed)
12
+ -> numpy (if 4-byte aligned and not an image) -> raw ``bytes``. Nothing here
13
+ raises: the client always returns *something*.
14
+
15
+ .. note::
16
+ The guessing is imperfect: the numpy branch fires on *any*
17
+ 4-byte-aligned blob and can return a plausible-looking but wrong float
18
+ array. If you control the box, declare ``"encoding"`` in its response
19
+ config instead of relying on guessing.
20
+
21
+ The codec *bodies* live once in :mod:`boxes_client.codec`; this module
22
+ wraps them with the legacy "try it, or fall through" behaviour.
23
+ """
24
+
25
+ from .codec import _decode_json, _decode_numpy, _decode_torch # noqa: F401 (shared bodies / back-compat)
26
+
27
+
28
+ def _try_numpy(buf: bytes):
29
+ try:
30
+ return _decode_numpy(buf)
31
+ except Exception:
32
+ pass
33
+ try:
34
+ # legacy extra: the uint8 fallback kept for pre-declaration behaviour
35
+ import numpy as np
36
+ arr = np.frombuffer(buf, dtype=np.uint8)
37
+ if arr.nbytes == 0:
38
+ return None
39
+ return arr
40
+ except Exception:
41
+ return None
42
+
43
+
44
+ def _try_torch(buf: bytes):
45
+ """Legacy: only accepts a plain Tensor payload (the original contract);
46
+ dict-of-tensors go through the declared ``torch`` codec instead."""
47
+ try:
48
+ import torch
49
+ except ImportError:
50
+ return None
51
+ try:
52
+ obj = _decode_torch(buf)
53
+ if isinstance(obj, torch.Tensor):
54
+ return obj
55
+ except Exception:
56
+ pass
57
+ return None
58
+
59
+
60
+ def _try_json(buf: bytes):
61
+ try:
62
+ s = buf.decode("utf-8")
63
+ except Exception:
64
+ return None
65
+ if not s or s[0] not in "{[":
66
+ return None
67
+ try:
68
+ return _decode_json(buf)
69
+ except Exception:
70
+ return None
71
+
72
+
73
+ def decode_payload(buf):
74
+ """Return the decoded value of ``buf``.
75
+
76
+ Order (legacy fallback only — boxes are expected to declare
77
+ ``"encoding"`` in the response config instead):
78
+ JSON (if it looks like it) -> torch (tapnext / vggt) -> numpy
79
+ (opencv_box ``np_to_bytes``) -> raw ``bytes``.
80
+ Nothing raises: the client always returns *something*.
81
+ """
82
+ if buf is None:
83
+ return None
84
+ if not isinstance(buf, (bytes, bytearray, memoryview)):
85
+ return buf
86
+ buf = bytes(buf)
87
+
88
+ # JSON first: a JSON document is never a valid torch/numpy buffer.
89
+ # _try_json only fires when the first byte is '{' or '[' — cheap and safe.
90
+ j = _try_json(buf)
91
+ if j is not None:
92
+ return j
93
+
94
+ # torch next — tapnext / vggt use torch.save.
95
+ t = _try_torch(buf)
96
+ if t is not None:
97
+ return t
98
+
99
+ # numpy next — opencv_box uses np_to_bytes; only attempt when the size is
100
+ # at least one element and not clearly a JPEG.
101
+ if len(buf) >= 4 and len(buf) % 4 == 0 and buf[:2] not in (b"\xff\xd8", b"\x89P"):
102
+ arr = _try_numpy(buf)
103
+ if arr is not None:
104
+ return arr
105
+
106
+ return buf
@@ -0,0 +1,115 @@
1
+ """Envelope builders for the shared ``pipeline.PipelineService`` boxes.
2
+
3
+ Every box in the *envelope family* (tapnext, yolo, vggt, opencv_box, lang_segm,
4
+ ...) speaks::
5
+
6
+ service PipelineService {
7
+ rpc <Method>( Envelope ) returns ( Envelope ); # Process, DetectSequence, ...
8
+ }
9
+
10
+ ``build()`` turns a flat Python ``data`` dict + a ``config`` dict into an
11
+ ``Envelope`` message. It intentionally makes **no assumption about field names
12
+ or payload types** -- ``data={"images": ...}``, ``data={"sentences": ...}``
13
+ or any other keys are all the same shape to the client. Value types pick the
14
+ ``Value`` oneof via ``aux.wrap_value``.
15
+
16
+ Value coercion (applied to each data value, and to each list element):
17
+
18
+ bytes / bytearray / memoryview -> bytes binary payload (e.g. an image)
19
+ pathlib.Path -> read file serializes a local file to bytes
20
+ int -> float (proto ``f`` is a float)
21
+ str -> literal string a *text* value, NOT a file path
22
+ list of the above (homogeneous) -> BytesList / StringList / FloatList
23
+
24
+ So: to ship a *file* use ``bytes`` or ``pathlib.Path`` (or :func:`load`); to send a
25
+ *literal string* use ``str``.
26
+ """
27
+
28
+ import json
29
+ import pathlib
30
+ from typing import Any, Dict, List, Optional, Sequence, Union
31
+
32
+ from ._pb_loader import get as _get_pb
33
+
34
+
35
+ def load(path: Union[str, "pathlib.PurePath"]) -> bytes:
36
+ """Read a local file into ``bytes`` so it can be shipped to a (possibly
37
+ remote) box. Equivalent to ``pathlib.Path(path).read_bytes()``."""
38
+ return pathlib.Path(path).read_bytes()
39
+
40
+
41
+ def _coerce_item(v: Any) -> Any:
42
+ """Coerce one scalar into something ``aux.wrap_value`` can wrap."""
43
+ if isinstance(v, pathlib.PurePath):
44
+ return v.read_bytes()
45
+ if isinstance(v, (bytearray, memoryview)):
46
+ return bytes(v)
47
+ if type(v) is int:
48
+ return float(v)
49
+ return v
50
+
51
+
52
+ def _coerce_value(v: Any) -> Any:
53
+ """Coerce a value or a homogeneous list of values."""
54
+ if isinstance(v, (list, tuple)):
55
+ return [_coerce_item(x) for x in v]
56
+ return _coerce_item(v)
57
+
58
+
59
+ def build(data: Optional[Dict[str, Any]] = None,
60
+ config: Optional[Dict[str, Any]] = None):
61
+ """Build an ``Envelope`` request.
62
+
63
+ Parameters
64
+ ----------
65
+ data:
66
+ ``{field_name: value}`` mapped into ``Envelope.data``. Values follow the
67
+ coercion rules in the module docstring.
68
+ config:
69
+ Arbitrary dict serialized to ``Envelope.config_json`` (the box-specific
70
+ control payload; shape depends on the box).
71
+ """
72
+ pb2, _grpc, aux = _get_pb()
73
+ env = pb2.Envelope(config_json=json.dumps(config or {}))
74
+ for k, v in (data or {}).items():
75
+ env.data[k].CopyFrom(aux.wrap_value(_coerce_value(v)))
76
+ return env
77
+
78
+
79
+ def _load_images(images: Union[str, bytes, Sequence]) -> List[bytes]:
80
+ """Normalize an ``images`` argument into a list of bytes.
81
+
82
+ Used by the image-box conveniences (:mod:`boxes_client.conveniences`), so a
83
+ bare ``str`` here is treated as a **local file path** to serialize (distinct
84
+ from the generic ``run`` contract where ``str`` means a literal string).
85
+ Accepts:
86
+
87
+ * a single path (str) or preencoded bytes
88
+ * a list of paths / preencoded bytes
89
+ * :class:`pathlib.Path` objects
90
+ """
91
+ if images is None:
92
+ return []
93
+ if isinstance(images, (str, bytes, bytearray, pathlib.PurePath)):
94
+ images = [images]
95
+ out: List[bytes] = []
96
+ for item in images:
97
+ if isinstance(item, (bytes, bytearray, memoryview)):
98
+ out.append(bytes(item))
99
+ elif isinstance(item, (str, pathlib.PurePath)):
100
+ out.append(pathlib.Path(item).read_bytes())
101
+ else:
102
+ raise TypeError(
103
+ f"Unsupported image element: {type(item)!r}. "
104
+ "Pass a path (str/pathlib.Path), pre-encoded bytes, or a list of those."
105
+ )
106
+ return out
107
+
108
+
109
+ def reset_envelope(config_key: str):
110
+ """Build a config-only ``Process`` request that clears server-side state.
111
+
112
+ ``config_key`` names the box's config section (e.g. ``"tapnext"``,
113
+ ``"clip"``); the caller decides -- the core has no default and names no box.
114
+ """
115
+ return build(config={config_key: {"command": "reset"}})
boxes_client/result.py ADDED
@@ -0,0 +1,146 @@
1
+ """Result object returned from ``Box`` calls."""
2
+
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from typing import Any, Dict
6
+
7
+ from .codec import decode_with
8
+ from .decode_util import decode_payload
9
+
10
+
11
+ def _is_numeric_array(v):
12
+ try:
13
+ import numpy as np
14
+ except ImportError:
15
+ return False
16
+ return isinstance(v, np.ndarray)
17
+
18
+
19
+ def _find_encoding(config):
20
+ """Locate the box's declared ``"encoding"`` contract key.
21
+
22
+ Scans the parsed config top-level, then each one-level section (first hit
23
+ wins). Returns the declared value — a codec-name ``str`` that applies to
24
+ every ``bytes`` field, or a ``{field_name: codec_name}`` map for mixed
25
+ responses — or ``None`` (undeclared: legacy auto-decode applies).
26
+ ``encoding`` is a generic contract keyword, not a box name.
27
+ """
28
+ if not isinstance(config, dict):
29
+ return None
30
+ if "encoding" in config:
31
+ return config["encoding"]
32
+ for section in config.values():
33
+ if isinstance(section, dict) and "encoding" in section:
34
+ return section["encoding"]
35
+ return None
36
+
37
+
38
+ def _codec_for(enc, field_name):
39
+ """Resolve the declared codec name for one field (or ``None``)."""
40
+ if enc is None:
41
+ return None
42
+ if isinstance(enc, str):
43
+ return enc
44
+ if isinstance(enc, dict):
45
+ return enc.get(field_name)
46
+ # A declared but unrecognized shape (e.g. a list) -> ignore, use legacy.
47
+ return None
48
+
49
+
50
+ def _decode_any(buf, codec_name=None):
51
+ """Decode a single Envelope Value payload.
52
+
53
+ - list of byte payloads: decode each element
54
+ - list of strings/floats: pass through
55
+ - bytes: declared codec if given (never raises), else the legacy guess chain
56
+ - scalars (str/float/int): pass through
57
+ """
58
+ if isinstance(buf, (list, tuple)):
59
+ items = list(buf)
60
+ if items and all(isinstance(x, (bytes, bytearray, memoryview)) for x in items):
61
+ if codec_name is not None:
62
+ return [decode_with(x, codec_name) for x in items]
63
+ return [decode_payload(x) for x in items]
64
+ return items
65
+ if isinstance(buf, (bytes, bytearray, memoryview)):
66
+ if codec_name is not None:
67
+ return decode_with(buf, codec_name)
68
+ return decode_payload(buf)
69
+ return buf
70
+
71
+
72
+ @dataclass
73
+ class Result:
74
+ """A decoded response from a box.
75
+
76
+ ``fields`` maps every Envelope data field to its decoded value: the
77
+ box-declared ``"encoding"`` codec when the box declares one
78
+ (:mod:`boxes_client.codec`), else the legacy auto-decode
79
+ (:mod:`boxes_client.decode_util`). Use ``raw`` for the undecoded Envelope.
80
+ Field values can also be read directly, e.g. ``res.tracks``.
81
+ """
82
+
83
+ raw: Any = None
84
+ config: Any = None
85
+ fields: Dict[str, Any] = field(default_factory=dict)
86
+ #: The box's declared payload encoding, exactly as found in the response
87
+ #: config: a codec-name ``str``, a ``{field: codec}`` map, or ``None``
88
+ #: (undeclared → legacy auto-decode). Exposed so callers can see *why*
89
+ #: a field came back decoded or raw.
90
+ encoding: Any = None
91
+
92
+ @classmethod
93
+ def from_envelope(cls, envelope):
94
+ config = envelope.config_json
95
+ if config:
96
+ try:
97
+ config = json.loads(config)
98
+ except (ValueError, TypeError):
99
+ pass
100
+
101
+ # Declared payload encoding (generic contract keyword, not a box name).
102
+ # ``None`` → behaviour is identical to before the codecs landed.
103
+ enc = _find_encoding(config)
104
+
105
+ fields = {}
106
+ for name, val in envelope.data.items():
107
+ kind = val.WhichOneof("kind") if hasattr(val, "WhichOneof") else None
108
+ if kind == "b":
109
+ buf = val.b
110
+ elif kind == "bb":
111
+ buf = list(val.bb.values)
112
+ elif kind == "ss":
113
+ buf = list(val.ss.values)
114
+ elif kind == "ff":
115
+ buf = list(val.ff.values)
116
+ elif kind == "f":
117
+ buf = val.f
118
+ elif kind == "s":
119
+ buf = val.s
120
+ else:
121
+ buf = val
122
+ fields[name] = _decode_any(buf, codec_name=_codec_for(enc, name))
123
+ return cls(raw=envelope, config=config, fields=fields, encoding=enc)
124
+
125
+ def __getattr__(self, name):
126
+ if name.startswith("_") or name in ("raw", "config", "fields"):
127
+ raise AttributeError(name)
128
+ d = self.__dict__.get("fields") or {}
129
+ if name in d:
130
+ return d[name]
131
+ raise AttributeError(
132
+ f"Result has no field {name!r}; available: {sorted(d)}"
133
+ )
134
+
135
+ def as_dict(self):
136
+ return {
137
+ "config": self.config,
138
+ "fields": {
139
+ k: (v.tolist() if _is_numeric_array(v) else v)
140
+ for k, v in self.fields.items()
141
+ },
142
+ }
143
+
144
+ def __repr__(self):
145
+ cfg = self.config if isinstance(self.config, dict) else "<str>"
146
+ return f"<Result fields=[{', '.join(sorted(self.fields))}] config={cfg}>"
@@ -0,0 +1,257 @@
1
+ Metadata-Version: 2.4
2
+ Name: boxes-client
3
+ Version: 0.1.1
4
+ Summary: Thin Python client to call deployed AI 'boxes' (gRPC services) by IP:port
5
+ Author: Manuel Ferreira
6
+ License-Expression: GPL-3.0-only
7
+ Project-URL: Homepage, https://github.com/manel-ferreira-01/boxes
8
+ Project-URL: Repository, https://github.com/manel-ferreira-01/boxes
9
+ Project-URL: Issues, https://github.com/manel-ferreira-01/boxes/issues
10
+ Keywords: grpc,grpc-client,inference-serving,ai-fleet,pipeline,zstandard,codec
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: grpcio>=1.50
25
+ Requires-Dist: protobuf>=7.35.1
26
+ Requires-Dist: grpcio-reflection>=1.50
27
+ Requires-Dist: numpy>=1.21
28
+ Requires-Dist: zstandard>=0.18
29
+ Provides-Extra: dev
30
+ Requires-Dist: pytest>=7; extra == "dev"
31
+ Provides-Extra: torch
32
+ Requires-Dist: torch>=2.0; extra == "torch"
33
+ Dynamic: license-file
34
+
35
+ # boxes-client
36
+
37
+ A thin Python client for calling deployed AI **"boxes"** by `IP:port`.
38
+
39
+ A *box* is one of the gRPC services in [`/images/`](../images/) built to the
40
+ shared
41
+
42
+ ```protobuf
43
+ service PipelineService {
44
+ rpc Process( Envelope ) returns ( Envelope );
45
+ }
46
+ ```
47
+
48
+ interface. Point this client at **any one box** and send an `Envelope`:
49
+
50
+ ```python
51
+ from boxes_client import Box
52
+
53
+ b = Box("localhost:8061") # a local box
54
+ # b = Box("10.0.0.5:8061") # ...or a remote one
55
+
56
+ res = b.run(data={"images": ["frame.jpg"]},
57
+ config={"my_box": {"command": "do_thing", "parameters": {}}})
58
+ print(res.fields) # decoded payload, whatever the box returned
59
+ print(res.config) # parsed config_json
60
+ ```
61
+
62
+ Box-specific one-liners live in an *optional* convenience layer, e.g. the
63
+ tapnext tracker:
64
+
65
+ ```python
66
+ from boxes_client import trace
67
+ res = trace(b, images=["frame.jpg"], grid_size=30)
68
+ ```
69
+
70
+ No registry, no central server — the client connects directly to the box, so
71
+ local and remote boxes are the same call. Boxes stay independent, addressable,
72
+ composable units (the *client* does the calling, preserving the distributed
73
+ nature of the fleet).
74
+
75
+ ## Install
76
+
77
+ ```bash
78
+ pip install boxes-client
79
+ # optional, to decode tapnext/vggt tensor payloads to numpy:
80
+ pip install "boxes-client[torch]"
81
+ ```
82
+
83
+ (From a repo checkout, instead: `pip install -e boxes_client`.)
84
+
85
+ `zstandard` is a base dependency (the `zstd_pickle` codec needs it); `torch`
86
+ stays optional. Without the codec's library, declared payloads degrade to raw
87
+ `bytes` with a warning (still usable — e.g.
88
+ `torch.load(BytesIO(res.tracks), weights_only=False)`).
89
+
90
+ The full design of the declared-encoding contract lives in
91
+ [docs/CODECS.md](../docs/CODECS.md).
92
+
93
+ ## Core API (box-agnostic) + optional conveniences
94
+
95
+ `Box` is deliberately box-agnostic: it builds and sends an `Envelope` and reads
96
+ a `Result` back, and knows **no box, field, or model**. Per-box sugar lives in a
97
+ separate convenience layer, so adding one never touches the core.
98
+
99
+ ### 1. Generic — `Box.run(data, config, method, reset_first)`
100
+ The workhorse. No assumption about field names or payload types:
101
+
102
+ ```python
103
+ b.run(
104
+ data = {"images": [img1, img2]}, # any field names; any types
105
+ config = {"my_box": {"command": "do_thing", "parameters": {...}}},
106
+ method = "Process", # default; e.g. "similarity_check"
107
+ )
108
+ ```
109
+
110
+ `data` is a dict of `field_name -> value`. Values are coerced per the rules
111
+ below and wrapped into the shared `Value` oneof via the vendored `aux.wrap_value`.
112
+
113
+ ### 2. Convenience — `trace(box, images, grid_size=None, reset_first=True)`
114
+ An **optional** one-liner for the *tapnext* box, living in
115
+ `boxes_client.conveniences` (not the core). It reads image files (or accepts
116
+ pre-encoded bytes) and just calls `Box.run()` with the right shape:
117
+
118
+ ```python
119
+ b = Box("localhost:8061")
120
+ res = trace(b, images=["f1.jpg", "f2.jpg", "f3.jpg"], grid_size=30)
121
+ np.save("tracks.npy", res.tracks.numpy())
122
+ ```
123
+
124
+ The core `Box` knows nothing about tapnext — `trace` *is* the only tapnext
125
+ knowledge, and it's safe to delete without touching the generic client. Add a
126
+ sibling convenience (`segment`, `embed`, `detect`, …) for other boxes the same
127
+ way; never put a box name in `box.py`.
128
+
129
+ ### `Box.reset(config_key=None)`
130
+ Sends `{config_key: {"command": "reset"}}` on `Process`. `config_key` is the
131
+ box's section name (or the one from the constructor). Stateful boxes clear
132
+ state; stateless boxes typically ignore it. Pass the box name explicitly, or
133
+ construct with `Box(host, config_key="tapnext")`.
134
+
135
+ ### `Box.info()`
136
+ Asks the box (via gRPC reflection) whether it serves `pipeline.PipelineService`.
137
+ Useful to check box reachability and shape before committing to a call.
138
+
139
+ ## Value coercion (for `Box.run` / convenience `data` values)
140
+
141
+ | You pass | What's sent |
142
+ |---|---|
143
+ | `bytes` / `bytearray` / `memoryview` | `b` (single bytes) or `bb` (list) — for pre-encoded binary payloads (images, tensors, etc.) |
144
+ | `pathlib.Path` | file bytes (read locally, sent as `b`) |
145
+ | `str` | **literal string** (NOT a file path) — sent as `s` |
146
+ | `int` | coerced to `float` (proto `f` is a float) |
147
+ | list of the above (homogeneous) | corresponding list: `BytesList` / `StringList` / `FloatList` |
148
+
149
+ That's why a *file* has to be passed as `pathlib.Path` (or a `bytes` you loaded
150
+ yourself) — the client serializes it and ships it. A *literal string* is just
151
+ `str` — no confusion.
152
+
153
+ ```python
154
+ import pathlib
155
+ # file:
156
+ b.run(data={"images": [pathlib.Path("frame.jpg")]}, config={...})
157
+
158
+ # preencoded:
159
+ b.run(data={"images": [open("frame.jpg","rb").read()]}, config={...})
160
+
161
+ # literal text (e.g. a hypothetical text-only envelope box):
162
+ b.run(data={"sentences": ["hello", "world"]}, config={...})
163
+
164
+ # list of floats:
165
+ b.run(data={"vals": [1, 2, 3]}, config={...}) # -> FloatList
166
+ ```
167
+
168
+ ## Result object
169
+
170
+ `Result` wraps the raw response `Envelope`:
171
+
172
+ - `fields` — dict of `field_name -> decoded value`
173
+ - `.tracks`, `.visibles`, … — direct field access via `__getattr__`
174
+ - `config` — parsed `config_json`
175
+ - `encoding` — the box's **declared** payload encoding (codec-name string,
176
+ `{field: codec}` map, or `None`), so you can see *why* a field is decoded
177
+ or raw
178
+ - `raw` — the undecoded `Envelope` proto
179
+ - `as_dict()` — JSON-friendly version (numpy arrays -> `tolist`)
180
+
181
+ ### Decoding: declared first, legacy guess as fallback
182
+
183
+ The authoritative path is the box's **declaration**: the response `config_json`
184
+ carries a generic `"encoding"` key — a codec name for all `bytes` fields, or a
185
+ `{field_name: codec_name}` map:
186
+
187
+ ```json
188
+ { "lang_sam": { "status": "done", "encoding": "zstd_pickle" } }
189
+ ```
190
+
191
+ ``encoding`` scans the parsed config top-level, then each section (first hit
192
+ wins). Named codecs (`[src/boxes_client/codec.py](src/boxes_client/codec.py)`,
193
+ registry `CODECS` / `decode_with`, full design in [docs/CODECS.md](../docs/CODECS.md)):
194
+
195
+ | name | payload | decoded to |
196
+ |---------------|---------------------------------------|-------------------------|
197
+ | `identity` | raw bytes (the default) | `bytes` unchanged |
198
+ | `json` | UTF-8 JSON | `list`/`dict` |
199
+ | `torch` | `torch.save()` tensor / dict | `Tensor` / `dict` |
200
+ | `numpy` | raw float32 buffer | `np.ndarray` |
201
+ | `zstd_pickle` | `zstd.compress(pickle.dumps(obj))` | decoded Python |
202
+
203
+ Unknown names or a missing codec library degrade to **raw bytes + a
204
+ warning** — never an exception.
205
+
206
+ The legacy guess chain
207
+ (`[src/boxes_client/decode_util.py](src/boxes_client/decode_util.py)`):
208
+ **JSON → torch (if installed) → numpy → raw bytes**, kept only for boxes that
209
+ declared nothing. Guessing is approximate (the numpy branch will happily
210
+ reinterpret any 4-byte-aligned blob); boxes are expected to declare.
211
+
212
+ ## What's supported
213
+
214
+ | Box | In scope | Notes |
215
+ |-----|----------|-------|
216
+ | tapnext (`Process`) | ✅ | v1 target; `trace(box, ...)` convenience over `Box.run` |
217
+ | vggt, yolo, opencv_box, lang_segm, clip (`Process`) | ✅ envelope shape | call via `Box.run(...)` with the box-specific `config`; extras like yolo `DetectSequence` / opencv `similarity_check` need the box's own proto for `method=` |
218
+ | cotracker, textEmbedding (`Forward`) | ⏸ pending | use `Box.run` after they're migrated to the shared envelope (client needs no changes) |
219
+
220
+ ### Method dispatch caveat
221
+
222
+ `Box.run(..., method=...)` resolves to a method on the **client-stub**, which is
223
+ built from the shared `pipeline.proto`. Today the shared proto defines only
224
+ `Process`, so `method=` is currently useful only for `Process`. Boxes that add
225
+ extra `PipelineService` RPCs (e.g. opencv's `similarity_check`, yolo's
226
+ `DetectSequence`/`TrackSequence`/`AllProcessing`) will need either (a) to also
227
+ be migrated to a *single* `Process` with a `command` field (the tapnext pattern),
228
+ or (b) to have their own proto vendored into the client. This is the only
229
+ remaining "per-box" knowledge in the client — everything else (field names,
230
+ payload types, config shape) is fully generic.
231
+
232
+ ## Run the tests
233
+
234
+ ```bash
235
+ # in-process fake box (no GPU, no real box needed)
236
+ python boxes_client/tests/fake_box_smoke.py
237
+
238
+ # real tapnext box at BOX_HOST:PORT
239
+ BOX_HOST=localhost:8061 python boxes_client/tests/live_tapnext.py
240
+ ```
241
+
242
+ ## Notes / design
243
+
244
+ - **Client-side only.** The box stays a box: independent, restartable,
245
+ composable. Distribution is preserved because the *client* dials the box
246
+ directly, so "local box" and "remote box" are the same call.
247
+ - **Auto-protocol.** `Box.info()` uses gRPC reflection to confirm the box
248
+ serves `pipeline.PipelineService`. If the box does not serve reflection,
249
+ calls still work — `info()` just reports `reflection: False`.
250
+ - **Declared-first decoding.** Boxes declare `"encoding"` in the response
251
+ config; the named codec decodes it. Undeclared payloads fall back to the
252
+ legacy JSON → torch → numpy → raw-bytes guess chain. Nothing raises; you
253
+ always get a `Result` (unknown codec / missing library → raw bytes +
254
+ warning).
255
+ - **Forward boxes deferred.** cotracker / textEmbedding use bespoke
256
+ `Forward` messages; they will work through this client with no changes once
257
+ migrated to the shared envelope (clip is already migrated).
@@ -0,0 +1,17 @@
1
+ boxes_client/__init__.py,sha256=wy6l-AHmwOeCDE_EgJGRiQg5YlxrD6Lim9W6DVht7wQ,1624
2
+ boxes_client/_pb_loader.py,sha256=F-n3WaxCZbx_1lSk6YFaPoWc-oSr1MgdGDEehcoPDAM,642
3
+ boxes_client/box.py,sha256=YkgPp0RTeAG1VWdKjpJCanatAFkRLLD8F_5a0gsl9Aw,8259
4
+ boxes_client/codec.py,sha256=FTm6V52yytiIlz5jnQ8dqOwOwSfkyEoyL-cvXhboa_M,4081
5
+ boxes_client/conveniences.py,sha256=KNWqMvdoTc0xwSCmmNwVY5YA5JSjOgG7aENkfaztgYs,3648
6
+ boxes_client/decode_util.py,sha256=C06620qHpmLQsv0PUg5k4q3ZcpB8IZ5AU4FbDPYG9n0,3439
7
+ boxes_client/envelope.py,sha256=buU46Sixg8leKwFhKG4orCrX6slGWxN2mhM9IBJb0v0,4282
8
+ boxes_client/result.py,sha256=jlk6czEJpv9EQODc3n9B881YVDwT8SxiyqNFWsXZs-k,5030
9
+ boxes_client/_pb/__init__.py,sha256=5bIZkZk_hY8DdZmlsQ0oxLTryZ9iPXCUHfbSRa_PpH0,270
10
+ boxes_client/_pb/aux.py,sha256=xlEh38o2CpIoRujHkn1HehIV3ebUeRrHkPqrb3AS7Xg,1263
11
+ boxes_client/_pb/pipeline_pb2.py,sha256=7fdu73F2FXQPhIMiT2K5hdG71-6SaxQOxxkLufLnvxs,2803
12
+ boxes_client/_pb/pipeline_pb2_grpc.py,sha256=LFcIJzebgNSZKwjRPGXdnieWxHWAhOb-ACSUGVGzQfM,3300
13
+ boxes_client-0.1.1.dist-info/licenses/LICENSE,sha256=OXLcl0T2SZ8Pmy2_dmlvKuetivmyPd5m1q-Gyd-zaYY,35149
14
+ boxes_client-0.1.1.dist-info/METADATA,sha256=A59Qx3tzjffoGm0FlvlJbjiRLBCMjYiSpTbqIeooKQw,10794
15
+ boxes_client-0.1.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
16
+ boxes_client-0.1.1.dist-info/top_level.txt,sha256=Oxf7b8trFFSP25ms2Na4b7O1aHD0rROV5RhO5X6jBlM,13
17
+ boxes_client-0.1.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+