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.
- boxes_client/__init__.py +40 -0
- boxes_client/_pb/__init__.py +8 -0
- boxes_client/_pb/aux.py +37 -0
- boxes_client/_pb/pipeline_pb2.py +50 -0
- boxes_client/_pb/pipeline_pb2_grpc.py +100 -0
- boxes_client/_pb_loader.py +18 -0
- boxes_client/box.py +211 -0
- boxes_client/codec.py +113 -0
- boxes_client/conveniences.py +102 -0
- boxes_client/decode_util.py +106 -0
- boxes_client/envelope.py +115 -0
- boxes_client/result.py +146 -0
- boxes_client-0.1.1.dist-info/METADATA +257 -0
- boxes_client-0.1.1.dist-info/RECORD +17 -0
- boxes_client-0.1.1.dist-info/WHEEL +5 -0
- boxes_client-0.1.1.dist-info/licenses/LICENSE +674 -0
- boxes_client-0.1.1.dist-info/top_level.txt +1 -0
boxes_client/__init__.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""boxes_client -- a thin client for calling deployed AI "boxes" by IP:port.
|
|
2
|
+
|
|
3
|
+
A *box* is one of the gRPC services in ``/images/`` built to the shared
|
|
4
|
+
``pipeline.PipelineService.Process(Envelope) -> Envelope`` interface. Point
|
|
5
|
+
this client at any one by address and send an ``Envelope``::
|
|
6
|
+
|
|
7
|
+
from boxes_client import Box
|
|
8
|
+
b = Box("localhost:8061") # local box ("10.0.0.5:8061" for remote)
|
|
9
|
+
|
|
10
|
+
# any box -- generic, box-agnostic (data + config dicts)
|
|
11
|
+
res = b.run(data={"sentences": ["hello", "world"]},
|
|
12
|
+
config={"my_box": {"command": "do_thing"}})
|
|
13
|
+
|
|
14
|
+
The core (``Box`` / ``Result`` / envelope builders) knows **no box**. There is
|
|
15
|
+
also an *optional convenience layer* for specific boxes -- e.g. the tapnext
|
|
16
|
+
point-tracking one-liner -- kept separate on purpose::
|
|
17
|
+
|
|
18
|
+
from boxes_client import trace # optional, tapnext-only
|
|
19
|
+
res = trace(b, images=["frame.jpg"], grid_size=30)
|
|
20
|
+
|
|
21
|
+
No registry, no central server: the client connects directly to the box. Local
|
|
22
|
+
and remote boxes are the same call -- just change the address.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import os as _os
|
|
26
|
+
import sys as _sys
|
|
27
|
+
|
|
28
|
+
# Vendored generated proto modules import each other and ``aux`` as top-level
|
|
29
|
+
# names; make that directory reachable before they are imported.
|
|
30
|
+
_PB_DIR = _os.path.join(_os.path.dirname(__file__), "_pb")
|
|
31
|
+
if _os.path.isdir(_PB_DIR) and _PB_DIR not in _sys.path:
|
|
32
|
+
_sys.path.append(_PB_DIR)
|
|
33
|
+
|
|
34
|
+
from .box import Box
|
|
35
|
+
from .result import Result
|
|
36
|
+
from .envelope import load
|
|
37
|
+
from .conveniences import trace # optional, convenience layer (not core)
|
|
38
|
+
|
|
39
|
+
__all__ = ["Box", "Result", "load", "trace"]
|
|
40
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import os as _os
|
|
2
|
+
import sys as _sys
|
|
3
|
+
|
|
4
|
+
# The generated modules import `pipeline_pb2`, `aux` as top-level modules,
|
|
5
|
+
# so this directory has to be on sys.path before they are imported.
|
|
6
|
+
_HERE = _os.path.dirname(__file__)
|
|
7
|
+
if _HERE not in _sys.path:
|
|
8
|
+
_sys.path.append(_HERE)
|
boxes_client/_pb/aux.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import pipeline_pb2
|
|
2
|
+
|
|
3
|
+
def wrap_value(obj):
|
|
4
|
+
"""Wrap a Python object into a pipeline.Value"""
|
|
5
|
+
if isinstance(obj, float):
|
|
6
|
+
return pipeline_pb2.Value(f=obj)
|
|
7
|
+
elif isinstance(obj, str):
|
|
8
|
+
return pipeline_pb2.Value(s=obj)
|
|
9
|
+
elif isinstance(obj, bytes):
|
|
10
|
+
return pipeline_pb2.Value(b=obj)
|
|
11
|
+
|
|
12
|
+
elif isinstance(obj, list):
|
|
13
|
+
if all(isinstance(v, float) for v in obj):
|
|
14
|
+
return pipeline_pb2.Value(ff=pipeline_pb2.FloatList(values=obj))
|
|
15
|
+
elif all(isinstance(v, str) for v in obj):
|
|
16
|
+
return pipeline_pb2.Value(ss=pipeline_pb2.StringList(values=obj))
|
|
17
|
+
elif all(isinstance(v, (bytes, bytearray)) for v in obj):
|
|
18
|
+
return pipeline_pb2.Value(bb=pipeline_pb2.BytesList(values=obj))
|
|
19
|
+
raise TypeError(f"Cannot wrap object of type {type(obj)}: {obj}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def unwrap_value(val: pipeline_pb2.Value):
|
|
23
|
+
"""Unwrap a pipeline.Value into a plain Python object"""
|
|
24
|
+
kind = val.WhichOneof("kind")
|
|
25
|
+
if kind == "f":
|
|
26
|
+
return val.f
|
|
27
|
+
if kind == "s":
|
|
28
|
+
return val.s
|
|
29
|
+
if kind == "b":
|
|
30
|
+
return val.b
|
|
31
|
+
if kind == "ff":
|
|
32
|
+
return list(val.ff.values)
|
|
33
|
+
if kind == "ss":
|
|
34
|
+
return list(val.ss.values)
|
|
35
|
+
if kind == "bb":
|
|
36
|
+
return list(val.bb.values)
|
|
37
|
+
return None
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
# Generated by the protocol buffer compiler. DO NOT EDIT!
|
|
3
|
+
# NO CHECKED-IN PROTOBUF GENCODE
|
|
4
|
+
# source: pipeline.proto
|
|
5
|
+
# Protobuf Python Version: 7.35.1
|
|
6
|
+
"""Generated protocol buffer code."""
|
|
7
|
+
from google.protobuf import descriptor as _descriptor
|
|
8
|
+
from google.protobuf import descriptor_pool as _descriptor_pool
|
|
9
|
+
from google.protobuf import runtime_version as _runtime_version
|
|
10
|
+
from google.protobuf import symbol_database as _symbol_database
|
|
11
|
+
from google.protobuf.internal import builder as _builder
|
|
12
|
+
_runtime_version.ValidateProtobufRuntimeVersion(
|
|
13
|
+
_runtime_version.Domain.PUBLIC,
|
|
14
|
+
7,
|
|
15
|
+
35,
|
|
16
|
+
1,
|
|
17
|
+
'',
|
|
18
|
+
'pipeline.proto'
|
|
19
|
+
)
|
|
20
|
+
# @@protoc_insertion_point(imports)
|
|
21
|
+
|
|
22
|
+
_sym_db = _symbol_database.Default()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0epipeline.proto\x12\x08pipeline\"\x1b\n\tFloatList\x12\x0e\n\x06values\x18\x01 \x03(\x02\"\x1c\n\nStringList\x12\x0e\n\x06values\x18\x01 \x03(\t\"\x1b\n\tBytesList\x12\x0e\n\x06values\x18\x01 \x03(\x0c\"\xa0\x01\n\x05Value\x12\x0b\n\x01\x62\x18\x01 \x01(\x0cH\x00\x12\x0b\n\x01s\x18\x02 \x01(\tH\x00\x12\x0b\n\x01\x66\x18\x05 \x01(\x02H\x00\x12!\n\x02\x62\x62\x18\x06 \x01(\x0b\x32\x13.pipeline.BytesListH\x00\x12\"\n\x02ss\x18\x07 \x01(\x0b\x32\x14.pipeline.StringListH\x00\x12!\n\x02\x66\x66\x18\n \x01(\x0b\x32\x13.pipeline.FloatListH\x00\x42\x06\n\x04kind\"\x89\x01\n\x08\x45nvelope\x12\x13\n\x0b\x63onfig_json\x18\x01 \x01(\t\x12*\n\x04\x64\x61ta\x18\x02 \x03(\x0b\x32\x1c.pipeline.Envelope.DataEntry\x1a<\n\tDataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\x1e\n\x05value\x18\x02 \x01(\x0b\x32\x0f.pipeline.Value:\x02\x38\x01\x32\x44\n\x0fPipelineService\x12\x31\n\x07Process\x12\x12.pipeline.Envelope\x1a\x12.pipeline.Envelopeb\x06proto3')
|
|
28
|
+
|
|
29
|
+
_globals = globals()
|
|
30
|
+
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
|
31
|
+
_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'pipeline_pb2', _globals)
|
|
32
|
+
if not _descriptor._USE_C_DESCRIPTORS:
|
|
33
|
+
DESCRIPTOR._loaded_options = None
|
|
34
|
+
_globals['_ENVELOPE_DATAENTRY']._loaded_options = None
|
|
35
|
+
_globals['_ENVELOPE_DATAENTRY']._serialized_options = b'8\001'
|
|
36
|
+
_globals['_FLOATLIST']._serialized_start=28
|
|
37
|
+
_globals['_FLOATLIST']._serialized_end=55
|
|
38
|
+
_globals['_STRINGLIST']._serialized_start=57
|
|
39
|
+
_globals['_STRINGLIST']._serialized_end=85
|
|
40
|
+
_globals['_BYTESLIST']._serialized_start=87
|
|
41
|
+
_globals['_BYTESLIST']._serialized_end=114
|
|
42
|
+
_globals['_VALUE']._serialized_start=117
|
|
43
|
+
_globals['_VALUE']._serialized_end=277
|
|
44
|
+
_globals['_ENVELOPE']._serialized_start=280
|
|
45
|
+
_globals['_ENVELOPE']._serialized_end=417
|
|
46
|
+
_globals['_ENVELOPE_DATAENTRY']._serialized_start=357
|
|
47
|
+
_globals['_ENVELOPE_DATAENTRY']._serialized_end=417
|
|
48
|
+
_globals['_PIPELINESERVICE']._serialized_start=419
|
|
49
|
+
_globals['_PIPELINESERVICE']._serialized_end=487
|
|
50
|
+
# @@protoc_insertion_point(module_scope)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
|
|
2
|
+
"""Client and server classes corresponding to protobuf-defined services."""
|
|
3
|
+
import grpc
|
|
4
|
+
import warnings
|
|
5
|
+
|
|
6
|
+
import pipeline_pb2 as pipeline__pb2
|
|
7
|
+
|
|
8
|
+
GRPC_GENERATED_VERSION = '1.83.0'
|
|
9
|
+
GRPC_VERSION = grpc.__version__
|
|
10
|
+
_version_not_supported = False
|
|
11
|
+
|
|
12
|
+
try:
|
|
13
|
+
from grpc._utilities import first_version_is_lower
|
|
14
|
+
_version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION)
|
|
15
|
+
except ImportError:
|
|
16
|
+
_version_not_supported = True
|
|
17
|
+
|
|
18
|
+
if _version_not_supported:
|
|
19
|
+
raise RuntimeError(
|
|
20
|
+
f'The grpc package installed is at version {GRPC_VERSION},'
|
|
21
|
+
+ ' but the generated code in pipeline_pb2_grpc.py depends on'
|
|
22
|
+
+ f' grpcio>={GRPC_GENERATED_VERSION}.'
|
|
23
|
+
+ f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}'
|
|
24
|
+
+ f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.'
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PipelineServiceStub:
|
|
29
|
+
"""Universal service interface
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, channel):
|
|
33
|
+
"""Constructor.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
channel: A grpc.Channel.
|
|
37
|
+
"""
|
|
38
|
+
self.Process = channel.unary_unary(
|
|
39
|
+
'/pipeline.PipelineService/Process',
|
|
40
|
+
request_serializer=pipeline__pb2.Envelope.SerializeToString,
|
|
41
|
+
response_deserializer=pipeline__pb2.Envelope.FromString,
|
|
42
|
+
_registered_method=True)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class PipelineServiceServicer:
|
|
46
|
+
"""Universal service interface
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def Process(self, request, context):
|
|
50
|
+
"""Missing associated documentation comment in .proto file."""
|
|
51
|
+
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
|
|
52
|
+
context.set_details('Method not implemented!')
|
|
53
|
+
raise NotImplementedError('Method not implemented!')
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def add_PipelineServiceServicer_to_server(servicer, server):
|
|
57
|
+
rpc_method_handlers = {
|
|
58
|
+
'Process': grpc.unary_unary_rpc_method_handler(
|
|
59
|
+
servicer.Process,
|
|
60
|
+
request_deserializer=pipeline__pb2.Envelope.FromString,
|
|
61
|
+
response_serializer=pipeline__pb2.Envelope.SerializeToString,
|
|
62
|
+
),
|
|
63
|
+
}
|
|
64
|
+
generic_handler = grpc.method_handlers_generic_handler(
|
|
65
|
+
'pipeline.PipelineService', rpc_method_handlers)
|
|
66
|
+
server.add_generic_rpc_handlers((generic_handler,))
|
|
67
|
+
server.add_registered_method_handlers('pipeline.PipelineService', rpc_method_handlers)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# This class is part of an EXPERIMENTAL API.
|
|
71
|
+
class PipelineService:
|
|
72
|
+
"""Universal service interface
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
@staticmethod
|
|
76
|
+
def Process(request,
|
|
77
|
+
target,
|
|
78
|
+
options=(),
|
|
79
|
+
channel_credentials=None,
|
|
80
|
+
call_credentials=None,
|
|
81
|
+
insecure=False,
|
|
82
|
+
compression=None,
|
|
83
|
+
wait_for_ready=None,
|
|
84
|
+
timeout=None,
|
|
85
|
+
metadata=None):
|
|
86
|
+
return grpc.experimental.unary_unary(
|
|
87
|
+
request,
|
|
88
|
+
target,
|
|
89
|
+
'/pipeline.PipelineService/Process',
|
|
90
|
+
pipeline__pb2.Envelope.SerializeToString,
|
|
91
|
+
pipeline__pb2.Envelope.FromString,
|
|
92
|
+
options,
|
|
93
|
+
channel_credentials,
|
|
94
|
+
insecure,
|
|
95
|
+
call_credentials,
|
|
96
|
+
compression,
|
|
97
|
+
wait_for_ready,
|
|
98
|
+
timeout,
|
|
99
|
+
metadata,
|
|
100
|
+
_registered_method=True)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Loading of the vendored generated proto modules.
|
|
2
|
+
|
|
3
|
+
``boxes_client._pb.__init__`` puts its own directory on ``sys.path`` so the
|
|
4
|
+
generated modules (which import each other and ``aux`` as top-level) resolve.
|
|
5
|
+
Importing the package first guarantees that, then we grab the top-level modules.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import importlib
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get():
|
|
12
|
+
"""Return ``(pipeline_pb2, pipeline_pb2_grpc, aux)``."""
|
|
13
|
+
import boxes_client._pb # noqa: F401 -> runs __init__, fixes sys.path
|
|
14
|
+
|
|
15
|
+
pb2 = importlib.import_module("pipeline_pb2")
|
|
16
|
+
pb2_grpc = importlib.import_module("pipeline_pb2_grpc")
|
|
17
|
+
aux = importlib.import_module("aux")
|
|
18
|
+
return pb2, pb2_grpc, aux
|
boxes_client/box.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""``Box`` -- a thin client for one deployed box (a box = a gRPC AI service).
|
|
2
|
+
|
|
3
|
+
Give it an address (``host:port``) and it can send an ``Envelope`` and get a
|
|
4
|
+
decoded ``Result`` back via the shared ``pipeline.PipelineService`` interface.
|
|
5
|
+
|
|
6
|
+
Local and remote boxes are identical: ``Box("localhost:8061")`` vs
|
|
7
|
+
``Box("10.0.0.5:8061")``. No registry, no central server -- the client dials
|
|
8
|
+
the box directly (boxes are push-style servers), which preserves the
|
|
9
|
+
distributed nature of the fleet.
|
|
10
|
+
|
|
11
|
+
The core stays **box-agnostic**: it knows how to build and send an ``Envelope``
|
|
12
|
+
and read a ``Result`` back, but it knows no box, field, or model. Box-specific
|
|
13
|
+
conveniences (one per box, e.g. a point-tracking helper) live in
|
|
14
|
+
:mod:`boxes_client.conveniences`, are built purely on top of :meth:`Box.run`,
|
|
15
|
+
and are never imported by the core.
|
|
16
|
+
|
|
17
|
+
Core call surface
|
|
18
|
+
-----------------
|
|
19
|
+
:meth:`Box.run` -- the generic workhorse: ``run(data=..., config=..., method="Process")``.
|
|
20
|
+
No assumption about field names or payload types.
|
|
21
|
+
:meth:`Box.reset` -- clear server-side state (``{"<box>": {"command": "reset"}}``).
|
|
22
|
+
:meth:`Box.call` -- send an already-built ``Envelope`` via ``Process``.
|
|
23
|
+
:meth:`Box.info` -- reachability + gRPC-reflection self-description probe.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
from typing import Any, Dict, List, Optional, Union
|
|
28
|
+
|
|
29
|
+
import grpc
|
|
30
|
+
|
|
31
|
+
from ._pb_loader import get as _get_pb
|
|
32
|
+
from . import envelope as _env
|
|
33
|
+
from .result import Result
|
|
34
|
+
|
|
35
|
+
_SERVICE = "pipeline.PipelineService"
|
|
36
|
+
_DEFAULT_PORT = 8061
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Box:
|
|
40
|
+
"""A client for a single box.
|
|
41
|
+
|
|
42
|
+
Parameters
|
|
43
|
+
----------
|
|
44
|
+
address:
|
|
45
|
+
``"host:port"`` or just ``"host"`` (defaults to port 8061).
|
|
46
|
+
port:
|
|
47
|
+
Optional explicit port (alternative to the ``":"`` in the address).
|
|
48
|
+
timeout:
|
|
49
|
+
Per-RPC timeout in seconds (default 600).
|
|
50
|
+
config_key:
|
|
51
|
+
Optional name of the box's config section. Needed only by
|
|
52
|
+
:meth:`reset` (and ``run(reset_first=True)``) so it knows which section
|
|
53
|
+
to reset. The generic :meth:`run` does not need it -- you pass the full
|
|
54
|
+
``config`` dict yourself. Defaults to ``None``: the core assumes no box.
|
|
55
|
+
Pass e.g. ``config_key="tapnext"`` when you will reset a stateful box.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
def __init__(
|
|
59
|
+
self,
|
|
60
|
+
address: str,
|
|
61
|
+
port: Optional[int] = None,
|
|
62
|
+
timeout: float = 600,
|
|
63
|
+
config_key: Optional[str] = None,
|
|
64
|
+
):
|
|
65
|
+
self.host, self.port = _split_address(address, port)
|
|
66
|
+
self.timeout = timeout
|
|
67
|
+
self.config_key = config_key
|
|
68
|
+
|
|
69
|
+
self._channel = grpc.insecure_channel(
|
|
70
|
+
f"{self.host}:{self.port}",
|
|
71
|
+
options=[
|
|
72
|
+
("grpc.max_send_message_length", -1),
|
|
73
|
+
("grpc.max_receive_message_length", -1),
|
|
74
|
+
],
|
|
75
|
+
)
|
|
76
|
+
pb2, pb2_grpc, _aux = _get_pb()
|
|
77
|
+
self._stub = pb2_grpc.PipelineServiceStub(self._channel)
|
|
78
|
+
|
|
79
|
+
# ------------------------------------------------------------------ utils
|
|
80
|
+
def close(self):
|
|
81
|
+
try:
|
|
82
|
+
self._channel.close()
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
|
|
86
|
+
def __enter__(self):
|
|
87
|
+
return self
|
|
88
|
+
|
|
89
|
+
def __exit__(self, *exc):
|
|
90
|
+
self.close()
|
|
91
|
+
|
|
92
|
+
# ------------------------------------------------------------- discover
|
|
93
|
+
def info(self, timeout: float = 10) -> Dict[str, Any]:
|
|
94
|
+
"""Ask the box (via gRPC reflection) what it exposes.
|
|
95
|
+
|
|
96
|
+
Returns ``{"service", "methods", "reflection", "reachable"}``.
|
|
97
|
+
``reflection`` is ``False`` if the box does not serve reflection (the
|
|
98
|
+
client still works; it just cannot self-describe).
|
|
99
|
+
"""
|
|
100
|
+
out = {"service": _SERVICE, "methods": [], "reflection": False,
|
|
101
|
+
"reachable": False}
|
|
102
|
+
try:
|
|
103
|
+
grpc.channel_ready_future(self._channel).result(timeout=timeout)
|
|
104
|
+
out["reachable"] = True
|
|
105
|
+
except Exception:
|
|
106
|
+
return out
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
from grpc_reflection.v1alpha import (
|
|
110
|
+
reflection_pb2,
|
|
111
|
+
reflection_pb2_grpc,
|
|
112
|
+
)
|
|
113
|
+
except ImportError:
|
|
114
|
+
return out
|
|
115
|
+
|
|
116
|
+
stub = reflection_pb2_grpc.ServerReflectionStub(self._channel)
|
|
117
|
+
try:
|
|
118
|
+
# Bidirectional-streaming RPC: one request -> (take the first) response.
|
|
119
|
+
def _reqs():
|
|
120
|
+
yield reflection_pb2.ServerReflectionRequest(list_services="*")
|
|
121
|
+
resp = next(stub.ServerReflectionInfo(iter(_reqs()), timeout=timeout))
|
|
122
|
+
services = [e.name for e in resp.list_services_response.service]
|
|
123
|
+
if _SERVICE in services:
|
|
124
|
+
out["reflection"] = True
|
|
125
|
+
out["methods"] = ["Process"]
|
|
126
|
+
else:
|
|
127
|
+
out["methods"] = services
|
|
128
|
+
except Exception:
|
|
129
|
+
out["reflection"] = False
|
|
130
|
+
return out
|
|
131
|
+
|
|
132
|
+
# ---------------------------------------------------------------- internal
|
|
133
|
+
def _send(self, envelope, method: str) -> Result:
|
|
134
|
+
"""Low-level: send ``envelope`` via the named ``method`` on the box."""
|
|
135
|
+
fn = getattr(self._stub, method, None)
|
|
136
|
+
if not callable(fn):
|
|
137
|
+
known = sorted(n for n in dir(self._stub) if not n.startswith("_"))
|
|
138
|
+
raise AttributeError(
|
|
139
|
+
f"Box has no RPC method {method!r}. "
|
|
140
|
+
f"This stub defines: {known} (only 'Process' is guaranteed; "
|
|
141
|
+
f"other methods depend on the box's own .proto)."
|
|
142
|
+
)
|
|
143
|
+
resp = fn(envelope, timeout=self.timeout)
|
|
144
|
+
return Result.from_envelope(resp)
|
|
145
|
+
|
|
146
|
+
# ------------------------------------------------------------------ calls
|
|
147
|
+
def run(
|
|
148
|
+
self,
|
|
149
|
+
data: Optional[Dict[str, Any]] = None,
|
|
150
|
+
config: Optional[Dict[str, Any]] = None,
|
|
151
|
+
method: str = "Process",
|
|
152
|
+
reset_first: bool = False,
|
|
153
|
+
) -> Result:
|
|
154
|
+
"""Generic entry point.
|
|
155
|
+
|
|
156
|
+
Parameters
|
|
157
|
+
----------
|
|
158
|
+
data:
|
|
159
|
+
Mapping of ``field_name -> value`` written into ``Envelope.data``.
|
|
160
|
+
Values follow :mod:`boxes_client.envelope` coercion rules:
|
|
161
|
+
``bytes`` / ``pathlib.Path`` -> bytes; ``str`` -> literal string;
|
|
162
|
+
``int`` -> float; lists of those -> ``BytesList`` / ``StringList``
|
|
163
|
+
/ ``FloatList``.
|
|
164
|
+
config:
|
|
165
|
+
The box-specific control payload (dict) serialized to
|
|
166
|
+
``Envelope.config_json``. Shape depends on the box.
|
|
167
|
+
method:
|
|
168
|
+
RPC name to invoke (default ``"Process"``). Boxes may expose extra
|
|
169
|
+
methods (e.g. opencv's ``similarity_check``, yolo's
|
|
170
|
+
``DetectSequence`` / ``TrackSequence`` / ``AllProcessing``).
|
|
171
|
+
reset_first:
|
|
172
|
+
If ``True``, send a tapnext-style reset (config-only
|
|
173
|
+
``{config_key: {"command": "reset"}}``) on ``Process`` first.
|
|
174
|
+
Useful for stateful boxes; ignored by stateless ones.
|
|
175
|
+
"""
|
|
176
|
+
if reset_first:
|
|
177
|
+
self.reset()
|
|
178
|
+
return self._send(_env.build(data, config), method)
|
|
179
|
+
|
|
180
|
+
def reset(self, config_key: Optional[str] = None) -> Result:
|
|
181
|
+
"""Best-effort clear of server-side state.
|
|
182
|
+
|
|
183
|
+
Sends a config-only ``Process`` call with ``{key: {"command": "reset"}}``
|
|
184
|
+
where ``key`` is ``config_key`` (if given) or the box's ``config_key``
|
|
185
|
+
from the constructor. Boxes treat it as a hard state reset; boxes that
|
|
186
|
+
don't recognize the command typically ignore it.
|
|
187
|
+
"""
|
|
188
|
+
key = config_key or self.config_key
|
|
189
|
+
if not key:
|
|
190
|
+
raise ValueError(
|
|
191
|
+
"Box.reset(): no config_key given. Construct the Box with "
|
|
192
|
+
"config_key=<box> or call Box.reset(config_key=<box>).")
|
|
193
|
+
return self._send(_env.reset_envelope(key), "Process")
|
|
194
|
+
|
|
195
|
+
# Back-compat alias: low-level "I already built the Envelope" call.
|
|
196
|
+
def call(self, envelope: Any) -> Result:
|
|
197
|
+
return self._send(envelope, "Process")
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _split_address(address: str, port: Optional[int]) -> tuple:
|
|
201
|
+
address = (address or "").strip()
|
|
202
|
+
if ":" not in address:
|
|
203
|
+
return (address or "localhost"), (int(port) if port is not None else _DEFAULT_PORT)
|
|
204
|
+
host, _, maybe_port = address.rpartition(":")
|
|
205
|
+
if not host:
|
|
206
|
+
host = "localhost"
|
|
207
|
+
if maybe_port.isdigit():
|
|
208
|
+
return host, int(maybe_port)
|
|
209
|
+
if port is not None:
|
|
210
|
+
return host, int(port)
|
|
211
|
+
return host, _DEFAULT_PORT
|
boxes_client/codec.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Named payload codecs: the *declared* decoding path (see ``CODECS.md``).
|
|
2
|
+
|
|
3
|
+
The client is smart about *shape* and dumb about *content*: a box says how
|
|
4
|
+
its payload is encoded by adding an ``"encoding"`` field to its response
|
|
5
|
+
``config_json`` (a codec name for every ``bytes`` field, or a
|
|
6
|
+
``{field_name: codec_name}`` map for mixed responses). The core only knows the
|
|
7
|
+
generic ``encoding`` keyword and this registry of codecs — **never a box
|
|
8
|
+
name**.
|
|
9
|
+
|
|
10
|
+
Codec vocabulary (all pure ``bytes -> object``):
|
|
11
|
+
|
|
12
|
+
================== =========================================================
|
|
13
|
+
name behavior
|
|
14
|
+
================== =========================================================
|
|
15
|
+
``identity`` raw bytes, unchanged (the default)
|
|
16
|
+
``json`` UTF-8 JSON document -> ``list``/``dict``/scalar
|
|
17
|
+
``torch`` ``torch.save`` bytes -> the unpickled object
|
|
18
|
+
(Tensor or dict of Tensors); needs ``torch``
|
|
19
|
+
``numpy`` raw float32 buffer -> ``np.ndarray``
|
|
20
|
+
``zstd_pickle`` ``zstd.compress(pickle.dumps(obj))`` -> ``obj``;
|
|
21
|
+
needs ``zstandard``
|
|
22
|
+
================== =========================================================
|
|
23
|
+
|
|
24
|
+
A codec whose library is missing (``torch`` / ``zstandard``) degrades to
|
|
25
|
+
``identity`` (raw bytes) plus a warning — **never raises**: the client always
|
|
26
|
+
returns *something* usable.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
import io
|
|
30
|
+
import json
|
|
31
|
+
import pickle
|
|
32
|
+
import warnings
|
|
33
|
+
|
|
34
|
+
import numpy as np
|
|
35
|
+
|
|
36
|
+
__all__ = ["CODECS", "decode_with"]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _decode_identity(buf: bytes) -> bytes:
|
|
40
|
+
return buf
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _decode_json(buf: bytes):
|
|
44
|
+
"""Strict: declared ``json`` fields must parse (or we degrade in
|
|
45
|
+
``decode_with``); no "does it look like JSON?" sniffing."""
|
|
46
|
+
s = buf.decode("utf-8")
|
|
47
|
+
return json.loads(s)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _decode_torch(buf: bytes):
|
|
51
|
+
"""``torch.save`` blob -> whatever object was saved (Tensor or dict).
|
|
52
|
+
|
|
53
|
+
Propagates (rather than hides) decode failures so ``decode_with`` can
|
|
54
|
+
degrade with a warning instead of silently guessing."""
|
|
55
|
+
import torch # noqa: W061 -- ImportError here means the lib is missing
|
|
56
|
+
|
|
57
|
+
return torch.load(io.BytesIO(buf), weights_only=False, map_location="cpu")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _decode_numpy(buf: bytes) -> "np.ndarray":
|
|
61
|
+
"""Raw numeric buffer (float32, per the opencv_box ``np_to_bytes``
|
|
62
|
+
contract)."""
|
|
63
|
+
return np.frombuffer(buf, dtype=np.float32)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _decode_zstd_pickle(buf: bytes):
|
|
67
|
+
"""``zstandard`` compress + ``pickle`` -> the decoded Python object
|
|
68
|
+
(normally a list). The lang_segm -> folder_wd cross-box contract."""
|
|
69
|
+
import zstandard # noqa: W061 -- ImportError here means the lib is missing
|
|
70
|
+
|
|
71
|
+
return pickle.loads(zstandard.ZstdDecompressor().decompress(buf))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
#: The named registry: generic codec name -> pure ``bytes -> object`` function.
|
|
75
|
+
#: No box name appears here or below; boxes *select* a codec by declaring it.
|
|
76
|
+
CODECS = {
|
|
77
|
+
"identity": _decode_identity,
|
|
78
|
+
"json": _decode_json,
|
|
79
|
+
"torch": _decode_torch,
|
|
80
|
+
"numpy": _decode_numpy,
|
|
81
|
+
"zstd_pickle": _decode_zstd_pickle,
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def decode_with(payload, name):
|
|
86
|
+
"""Apply a *named* codec to ``payload`` (raw ``bytes``/``bytearray``).
|
|
87
|
+
|
|
88
|
+
- ``name`` is ``None``/``"identity"`` -> raw bytes, unchanged (the
|
|
89
|
+
agnostic default).
|
|
90
|
+
- unknown name, or a codec whose library is missing, or a payload the
|
|
91
|
+
codec cannot parse -> a warning is emitted and the **raw bytes** are
|
|
92
|
+
returned. ``decode_with`` never raises.
|
|
93
|
+
"""
|
|
94
|
+
if name is None or name == "identity":
|
|
95
|
+
return bytes(payload)
|
|
96
|
+
fn = CODECS.get(name)
|
|
97
|
+
if fn is None:
|
|
98
|
+
warnings.warn(
|
|
99
|
+
f"codec {name!r} is not a known codec (known: {sorted(CODECS)}); "
|
|
100
|
+
"returning raw bytes",
|
|
101
|
+
stacklevel=2,
|
|
102
|
+
)
|
|
103
|
+
return bytes(payload)
|
|
104
|
+
try:
|
|
105
|
+
return fn(payload)
|
|
106
|
+
except Exception as e:
|
|
107
|
+
kind = "library is not installed" if isinstance(e, ImportError) else "decode failed"
|
|
108
|
+
warnings.warn(
|
|
109
|
+
f"codec {name!r}: {kind} ({type(e).__name__}: {e}); "
|
|
110
|
+
"returning raw bytes",
|
|
111
|
+
stacklevel=2,
|
|
112
|
+
)
|
|
113
|
+
return bytes(payload)
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Box-specific *conveniences* -- NOT part of the agnostic core.
|
|
2
|
+
|
|
3
|
+
The core of ``boxes_client`` (``box.py`` / ``envelope.py`` / ``result.py`` /
|
|
4
|
+
``decode_util.py``) is deliberately box-agnostic: it only knows *how* to build
|
|
5
|
+
an ``Envelope`` and send it over the shared ``PipelineService`` interface, and
|
|
6
|
+
*how* to read a ``Result`` back. It knows **no box name, field name, or model**.
|
|
7
|
+
|
|
8
|
+
This module is the opposite, on purpose: it encodes knowledge about a *specific*
|
|
9
|
+
box and only composes the generic ``Box.run`` / ``Box.reset`` primitives. The
|
|
10
|
+
tapnext point-tracking helper :func:`trace` lives here.
|
|
11
|
+
|
|
12
|
+
If you add a convenience for another box (``segment``, ``embed``, ``detect``,
|
|
13
|
+
...), put it here or in a sibling ``conveniences/<box>.py`` -- **never** in
|
|
14
|
+
``box.py`` -- so the core stays agnostic and each box's sugar is isolated,
|
|
15
|
+
visible, and easy to delete.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from typing import Any, List, Sequence, Union
|
|
19
|
+
import pathlib
|
|
20
|
+
|
|
21
|
+
__all__ = ["trace", "load_images"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_images(images: Union[str, bytes, "pathlib.PurePath", Sequence]) -> List[bytes]:
|
|
25
|
+
"""Normalize an ``images`` argument into a list of ``bytes``.
|
|
26
|
+
|
|
27
|
+
``trace`` is the *image-box* convenience, so a bare ``str`` here is treated
|
|
28
|
+
as a **local file path** to serialize (distinct from the generic ``run``
|
|
29
|
+
contract where ``str`` means a literal string). Accepts:
|
|
30
|
+
|
|
31
|
+
* a single path (``str``/``pathlib.Path``) or pre-encoded ``bytes``
|
|
32
|
+
* a list of any of the above
|
|
33
|
+
"""
|
|
34
|
+
if images is None:
|
|
35
|
+
return []
|
|
36
|
+
if isinstance(images, (str, bytes, bytearray, pathlib.PurePath)):
|
|
37
|
+
images = [images]
|
|
38
|
+
out: List[bytes] = []
|
|
39
|
+
for item in images:
|
|
40
|
+
if isinstance(item, (bytes, bytearray, memoryview)):
|
|
41
|
+
out.append(bytes(item))
|
|
42
|
+
elif isinstance(item, (str, pathlib.PurePath)):
|
|
43
|
+
out.append(pathlib.Path(item).read_bytes())
|
|
44
|
+
else:
|
|
45
|
+
raise TypeError(
|
|
46
|
+
f"Unsupported image element: {type(item)!r}. "
|
|
47
|
+
"Pass a path (str/pathlib.Path), pre-encoded bytes, or a list of those."
|
|
48
|
+
)
|
|
49
|
+
return out
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def trace(
|
|
53
|
+
box,
|
|
54
|
+
images: Union[str, bytes, "pathlib.PurePath", Sequence],
|
|
55
|
+
*,
|
|
56
|
+
grid_size: int = None,
|
|
57
|
+
reset_first: bool = True,
|
|
58
|
+
config_key: str = "tapnext",
|
|
59
|
+
**params: Any,
|
|
60
|
+
):
|
|
61
|
+
"""Point-tracking convenience for the **tapnext** box.
|
|
62
|
+
|
|
63
|
+
Built purely on the generic core -- it is equivalent to::
|
|
64
|
+
|
|
65
|
+
box.run(data={"images": [bytes, ...]},
|
|
66
|
+
config={config_key: {"command": "track", "parameters": {...}}},
|
|
67
|
+
method="Process")
|
|
68
|
+
|
|
69
|
+
with an optional preceding ``box.reset(config_key)`` (tapnext *accumulates*
|
|
70
|
+
tracks across sequential requests, so a reset keeps a one-shot clean).
|
|
71
|
+
|
|
72
|
+
Parameters
|
|
73
|
+
----------
|
|
74
|
+
box:
|
|
75
|
+
A :class:`boxes_client.Box` pointed at a tapnext box.
|
|
76
|
+
images:
|
|
77
|
+
A single local path / pre-encoded bytes, or a list of either.
|
|
78
|
+
grid_size:
|
|
79
|
+
TAPNext grid size (added to ``parameters``).
|
|
80
|
+
reset_first:
|
|
81
|
+
Send ``reset`` first (default ``True``).
|
|
82
|
+
config_key:
|
|
83
|
+
The box's config section name (default ``"tapnext"``).
|
|
84
|
+
**params:
|
|
85
|
+
Extra ``parameters`` (e.g. ``threshold=...``).
|
|
86
|
+
"""
|
|
87
|
+
images_bytes = load_images(images)
|
|
88
|
+
if not images_bytes:
|
|
89
|
+
raise ValueError("trace(): no images provided")
|
|
90
|
+
|
|
91
|
+
parameters: Any = dict(params or {})
|
|
92
|
+
if grid_size is not None:
|
|
93
|
+
parameters["grid_size"] = int(grid_size)
|
|
94
|
+
|
|
95
|
+
if reset_first:
|
|
96
|
+
box.reset(config_key)
|
|
97
|
+
|
|
98
|
+
return box.run(
|
|
99
|
+
data={"images": images_bytes},
|
|
100
|
+
config={config_key: {"command": "track", "parameters": parameters}},
|
|
101
|
+
method="Process",
|
|
102
|
+
)
|