embodied-ops 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,66 @@
1
+ """Public capability protocols for embodied systems."""
2
+
3
+ from ._version import __version__
4
+ from .device import (
5
+ API_VERSION,
6
+ CalibratableDevice,
7
+ Capability,
8
+ CommandDevice,
9
+ CommandLeaseDevice,
10
+ DeviceManifest,
11
+ HealthReport,
12
+ HealthStatus,
13
+ ObservableDevice,
14
+ OperationalDevice,
15
+ ResettableDevice,
16
+ )
17
+ from .errors import (
18
+ BackendConflictError,
19
+ BackendNotFoundError,
20
+ ContractError,
21
+ EmbodiedOpsError,
22
+ LifecycleError,
23
+ RpcError,
24
+ )
25
+ from .endpoints import unix_socket_path
26
+ from .features import FeatureKind, FeatureSpec, index_features, validate_feature_values
27
+ from .registry import (
28
+ BACKEND_ENTRY_POINT_GROUP,
29
+ BackendFactory,
30
+ BackendRegistry,
31
+ create_device,
32
+ default_registry,
33
+ device_session,
34
+ )
35
+
36
+ __all__ = [
37
+ "API_VERSION",
38
+ "BACKEND_ENTRY_POINT_GROUP",
39
+ "BackendConflictError",
40
+ "BackendFactory",
41
+ "BackendNotFoundError",
42
+ "BackendRegistry",
43
+ "CalibratableDevice",
44
+ "Capability",
45
+ "CommandDevice",
46
+ "CommandLeaseDevice",
47
+ "ContractError",
48
+ "DeviceManifest",
49
+ "EmbodiedOpsError",
50
+ "FeatureKind",
51
+ "FeatureSpec",
52
+ "HealthReport",
53
+ "HealthStatus",
54
+ "LifecycleError",
55
+ "ObservableDevice",
56
+ "OperationalDevice",
57
+ "ResettableDevice",
58
+ "RpcError",
59
+ "__version__",
60
+ "create_device",
61
+ "default_registry",
62
+ "device_session",
63
+ "index_features",
64
+ "unix_socket_path",
65
+ "validate_feature_values",
66
+ ]
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
embodied_ops/device.py ADDED
@@ -0,0 +1,134 @@
1
+ """Capability-oriented device contracts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass, field
7
+ from enum import Enum
8
+ from types import MappingProxyType
9
+ from typing import Protocol, runtime_checkable
10
+
11
+ from .errors import ContractError
12
+ from .features import FeatureSpec, index_features
13
+
14
+ API_VERSION = 1
15
+
16
+
17
+ class Capability(str, Enum):
18
+ OBSERVE = "observe"
19
+ COMMAND = "command"
20
+ HEALTH = "health"
21
+ CALIBRATE = "calibrate"
22
+ RESET = "reset"
23
+
24
+
25
+ class HealthStatus(str, Enum):
26
+ UNKNOWN = "unknown"
27
+ HEALTHY = "healthy"
28
+ DEGRADED = "degraded"
29
+ FAULT = "fault"
30
+
31
+
32
+ @dataclass(frozen=True, slots=True)
33
+ class HealthReport:
34
+ status: HealthStatus
35
+ summary: str
36
+ details: Mapping[str, object] = field(default_factory=dict)
37
+
38
+ def __post_init__(self) -> None:
39
+ if not self.summary:
40
+ raise ContractError("health summary must not be empty")
41
+ object.__setattr__(self, "details", MappingProxyType(dict(self.details)))
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class DeviceManifest:
46
+ """Backend-neutral description of one operational device."""
47
+
48
+ identifier: str
49
+ capabilities: tuple[Capability, ...]
50
+ observation_features: tuple[FeatureSpec, ...] = ()
51
+ action_features: tuple[FeatureSpec, ...] = ()
52
+ metadata: Mapping[str, str] = field(default_factory=dict)
53
+ api_version: int = API_VERSION
54
+
55
+ def __post_init__(self) -> None:
56
+ if not self.identifier or self.identifier.strip() != self.identifier:
57
+ raise ContractError(f"invalid device identifier: {self.identifier!r}")
58
+ if self.api_version != API_VERSION:
59
+ raise ContractError(
60
+ f"unsupported manifest API version {self.api_version}; expected {API_VERSION}"
61
+ )
62
+ if len(set(self.capabilities)) != len(self.capabilities):
63
+ raise ContractError("device capabilities must be unique")
64
+ index_features(self.observation_features)
65
+ index_features(self.action_features)
66
+ if self.observation_features and Capability.OBSERVE not in self.capabilities:
67
+ raise ContractError("observation features require the observe capability")
68
+ if self.action_features and Capability.COMMAND not in self.capabilities:
69
+ raise ContractError("action features require the command capability")
70
+ object.__setattr__(self, "metadata", MappingProxyType(dict(self.metadata)))
71
+
72
+ def to_dict(self) -> dict[str, object]:
73
+ return {
74
+ "api_version": self.api_version,
75
+ "identifier": self.identifier,
76
+ "capabilities": [capability.value for capability in self.capabilities],
77
+ "observation_features": [feature.to_dict() for feature in self.observation_features],
78
+ "action_features": [feature.to_dict() for feature in self.action_features],
79
+ "metadata": dict(self.metadata),
80
+ }
81
+
82
+
83
+ @runtime_checkable
84
+ class OperationalDevice(Protocol):
85
+ """Minimum lifecycle and health surface implemented by every backend."""
86
+
87
+ @property
88
+ def manifest(self) -> DeviceManifest: ...
89
+
90
+ @property
91
+ def is_connected(self) -> bool: ...
92
+
93
+ def connect(self) -> None: ...
94
+
95
+ def health(self) -> HealthReport: ...
96
+
97
+ def disconnect(self) -> None: ...
98
+
99
+
100
+ @runtime_checkable
101
+ class ObservableDevice(OperationalDevice, Protocol):
102
+ def observe(self) -> Mapping[str, object]: ...
103
+
104
+
105
+ @runtime_checkable
106
+ class CommandDevice(OperationalDevice, Protocol):
107
+ def command(self, action: Mapping[str, object]) -> Mapping[str, object]: ...
108
+
109
+
110
+ @runtime_checkable
111
+ class CommandLeaseDevice(CommandDevice, Protocol):
112
+ """Optional hooks for transports that grant exclusive command ownership.
113
+
114
+ Observation resources may remain connected while a transport acquires and
115
+ releases the command path independently. Implementations must make release
116
+ fail closed and idempotent.
117
+ """
118
+
119
+ def acquire_command_lease(self) -> None: ...
120
+
121
+ def release_command_lease(self) -> None: ...
122
+
123
+
124
+ @runtime_checkable
125
+ class CalibratableDevice(OperationalDevice, Protocol):
126
+ @property
127
+ def is_calibrated(self) -> bool: ...
128
+
129
+ def calibrate(self) -> None: ...
130
+
131
+
132
+ @runtime_checkable
133
+ class ResettableDevice(OperationalDevice, Protocol):
134
+ def reset(self, target: str | None = None) -> None: ...
@@ -0,0 +1,22 @@
1
+ """Dependency-free transport endpoint validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from .errors import ContractError
8
+
9
+ UNIX_PREFIX = "unix://"
10
+
11
+
12
+ def unix_socket_path(endpoint: str) -> Path:
13
+ if not isinstance(endpoint, str) or endpoint.strip() != endpoint:
14
+ raise ContractError(f"invalid RPC endpoint: {endpoint!r}")
15
+ if not endpoint.startswith("unix:///"):
16
+ raise ContractError("RPC endpoint must use an absolute unix:/// path")
17
+ path = Path(endpoint.removeprefix(UNIX_PREFIX))
18
+ if not path.is_absolute() or str(path) == "/":
19
+ raise ContractError("RPC Unix socket path must be absolute and non-root")
20
+ if len(str(path).encode()) > 100:
21
+ raise ContractError("RPC Unix socket path is too long for portable AF_UNIX use")
22
+ return path
embodied_ops/errors.py ADDED
@@ -0,0 +1,25 @@
1
+ """Public exception hierarchy for embodied-ops."""
2
+
3
+
4
+ class EmbodiedOpsError(Exception):
5
+ """Base class for SDK errors."""
6
+
7
+
8
+ class ContractError(EmbodiedOpsError, ValueError):
9
+ """A manifest, feature value, or backend violates the public contract."""
10
+
11
+
12
+ class BackendNotFoundError(EmbodiedOpsError, LookupError):
13
+ """No installed backend is registered under the requested name."""
14
+
15
+
16
+ class BackendConflictError(EmbodiedOpsError):
17
+ """More than one installed distribution owns the same backend name."""
18
+
19
+
20
+ class LifecycleError(EmbodiedOpsError, RuntimeError):
21
+ """A device lifecycle operation is invalid or failed."""
22
+
23
+
24
+ class RpcError(EmbodiedOpsError, RuntimeError):
25
+ """A versioned device RPC could not be completed."""
@@ -0,0 +1,115 @@
1
+ """Feature manifests and hardware-free value validation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import numbers
7
+ from collections.abc import Mapping
8
+ from dataclasses import dataclass
9
+ from enum import Enum
10
+ from types import MappingProxyType
11
+
12
+ from .errors import ContractError
13
+
14
+
15
+ class FeatureKind(str, Enum):
16
+ """Portable feature categories understood by SDK adapters."""
17
+
18
+ SCALAR = "scalar"
19
+ VECTOR = "vector"
20
+ IMAGE = "image"
21
+
22
+
23
+ @dataclass(frozen=True, slots=True)
24
+ class FeatureSpec:
25
+ """Describes one named observation or action feature."""
26
+
27
+ name: str
28
+ kind: FeatureKind = FeatureKind.SCALAR
29
+ dtype: str = "float32"
30
+ shape: tuple[int, ...] = ()
31
+ unit: str | None = None
32
+ minimum: float | None = None
33
+ maximum: float | None = None
34
+
35
+ def __post_init__(self) -> None:
36
+ if not self.name or self.name.strip() != self.name or any(c.isspace() for c in self.name):
37
+ raise ContractError(f"invalid feature name: {self.name!r}")
38
+ if not self.dtype:
39
+ raise ContractError(f"feature {self.name!r} requires a dtype")
40
+ if any(
41
+ not isinstance(size, int) or isinstance(size, bool) or size <= 0 for size in self.shape
42
+ ):
43
+ raise ContractError(f"feature {self.name!r} has an invalid shape: {self.shape!r}")
44
+ if self.kind is FeatureKind.SCALAR and self.shape:
45
+ raise ContractError(f"scalar feature {self.name!r} must have shape=()")
46
+ if self.kind is not FeatureKind.SCALAR and not self.shape:
47
+ raise ContractError(f"{self.kind.value} feature {self.name!r} requires a shape")
48
+ for label, value in (("minimum", self.minimum), ("maximum", self.maximum)):
49
+ if value is not None and not math.isfinite(float(value)):
50
+ raise ContractError(f"feature {self.name!r} has non-finite {label}")
51
+ if self.minimum is not None and self.maximum is not None and self.minimum > self.maximum:
52
+ raise ContractError(f"feature {self.name!r} minimum exceeds maximum")
53
+
54
+ def to_dict(self) -> dict[str, object]:
55
+ return {
56
+ "name": self.name,
57
+ "kind": self.kind.value,
58
+ "dtype": self.dtype,
59
+ "shape": list(self.shape),
60
+ "unit": self.unit,
61
+ "minimum": self.minimum,
62
+ "maximum": self.maximum,
63
+ }
64
+
65
+
66
+ def index_features(features: tuple[FeatureSpec, ...]) -> Mapping[str, FeatureSpec]:
67
+ """Return an immutable name index and reject ambiguous manifests."""
68
+
69
+ indexed: dict[str, FeatureSpec] = {}
70
+ for feature in features:
71
+ if feature.name in indexed:
72
+ raise ContractError(f"duplicate feature name: {feature.name!r}")
73
+ indexed[feature.name] = feature
74
+ return MappingProxyType(indexed)
75
+
76
+
77
+ def validate_feature_values(
78
+ values: Mapping[str, object],
79
+ features: tuple[FeatureSpec, ...],
80
+ *,
81
+ exact: bool = True,
82
+ ) -> dict[str, object]:
83
+ """Validate a feature mapping without rewriting or clamping it.
84
+
85
+ Scalar values are checked exhaustively. Vector and image payload ownership stays
86
+ with the backend because this dependency-free SDK does not impose an array library.
87
+ """
88
+
89
+ specs = index_features(features)
90
+ keys = set(values)
91
+ expected = set(specs)
92
+ missing = expected - keys
93
+ unknown = keys - expected
94
+ if missing:
95
+ raise ContractError(f"missing feature values: {sorted(missing)}")
96
+ if exact and unknown:
97
+ raise ContractError(f"unknown feature values: {sorted(unknown)}")
98
+
99
+ validated: dict[str, object] = {}
100
+ for name, spec in specs.items():
101
+ value = values[name]
102
+ if spec.kind is FeatureKind.SCALAR:
103
+ if isinstance(value, bool) or not isinstance(value, numbers.Real):
104
+ raise ContractError(f"feature {name!r} must be a real scalar")
105
+ numeric = float(value)
106
+ if not math.isfinite(numeric):
107
+ raise ContractError(f"feature {name!r} must be finite")
108
+ if spec.minimum is not None and numeric < spec.minimum:
109
+ raise ContractError(f"feature {name!r} is below minimum {spec.minimum}")
110
+ if spec.maximum is not None and numeric > spec.maximum:
111
+ raise ContractError(f"feature {name!r} is above maximum {spec.maximum}")
112
+ validated[name] = value
113
+ if not exact:
114
+ validated.update((name, values[name]) for name in unknown)
115
+ return validated
embodied_ops/py.typed ADDED
File without changes
@@ -0,0 +1,91 @@
1
+ """Backend discovery through standard Python package entry points."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Callable, Iterator, Mapping
6
+ from contextlib import contextmanager
7
+ from importlib import metadata
8
+
9
+ from .device import OperationalDevice
10
+ from .errors import BackendConflictError, BackendNotFoundError, ContractError
11
+
12
+ BACKEND_ENTRY_POINT_GROUP = "embodied_ops.backends"
13
+ BackendFactory = Callable[[Mapping[str, object]], OperationalDevice]
14
+
15
+
16
+ class BackendRegistry:
17
+ """Resolve local factories and installed backend entry points by stable name."""
18
+
19
+ def __init__(self) -> None:
20
+ self._local: dict[str, BackendFactory] = {}
21
+
22
+ def register(self, name: str, factory: BackendFactory) -> None:
23
+ if not name or name.strip() != name or any(character.isspace() for character in name):
24
+ raise ContractError(f"invalid backend name: {name!r}")
25
+ if name in self._local:
26
+ raise BackendConflictError(f"backend {name!r} is already registered locally")
27
+ self._local[name] = factory
28
+
29
+ def unregister(self, name: str) -> None:
30
+ self._local.pop(name, None)
31
+
32
+ def names(self) -> tuple[str, ...]:
33
+ return tuple(sorted(set(self._local) | {entry.name for entry in self._entry_points()}))
34
+
35
+ def load(self, name: str) -> BackendFactory:
36
+ if name in self._local:
37
+ return self._local[name]
38
+ matches = [entry for entry in self._entry_points() if entry.name == name]
39
+ if not matches:
40
+ raise BackendNotFoundError(
41
+ f"backend {name!r} is not installed; available backends: {list(self.names())}"
42
+ )
43
+ if len(matches) > 1:
44
+ owners = sorted(
45
+ entry.dist.name if entry.dist is not None else "unknown" for entry in matches
46
+ )
47
+ raise BackendConflictError(
48
+ f"backend {name!r} is provided by multiple packages: {owners}"
49
+ )
50
+ factory = matches[0].load()
51
+ if not callable(factory):
52
+ raise ContractError(f"backend entry point {name!r} did not load a callable factory")
53
+ return factory
54
+
55
+ @staticmethod
56
+ def _entry_points() -> tuple[metadata.EntryPoint, ...]:
57
+ return tuple(metadata.entry_points(group=BACKEND_ENTRY_POINT_GROUP))
58
+
59
+
60
+ default_registry = BackendRegistry()
61
+
62
+
63
+ def create_device(
64
+ backend: str,
65
+ config: Mapping[str, object] | None = None,
66
+ *,
67
+ registry: BackendRegistry = default_registry,
68
+ ) -> OperationalDevice:
69
+ """Instantiate an installed backend without connecting to hardware."""
70
+
71
+ device = registry.load(backend)(dict(config or {}))
72
+ if not isinstance(device, OperationalDevice):
73
+ raise ContractError(f"backend {backend!r} does not implement OperationalDevice")
74
+ return device
75
+
76
+
77
+ @contextmanager
78
+ def device_session(
79
+ backend: str,
80
+ config: Mapping[str, object] | None = None,
81
+ *,
82
+ registry: BackendRegistry = default_registry,
83
+ ) -> Iterator[OperationalDevice]:
84
+ """Connect a backend and guarantee disconnect after success or failure."""
85
+
86
+ device = create_device(backend, config, registry=registry)
87
+ device.connect()
88
+ try:
89
+ yield device
90
+ finally:
91
+ device.disconnect()
@@ -0,0 +1,13 @@
1
+ """Optional versioned RPC transport for embodied-ops devices."""
2
+
3
+ from .client import RemoteDevice
4
+ from .server import DeviceRpcServer
5
+ from .types import PROTOCOL_VERSION, SessionMode, TensorValue
6
+
7
+ __all__ = [
8
+ "PROTOCOL_VERSION",
9
+ "DeviceRpcServer",
10
+ "RemoteDevice",
11
+ "SessionMode",
12
+ "TensorValue",
13
+ ]
@@ -0,0 +1,158 @@
1
+ """Strict protobuf conversion at the transport boundary."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping, Sequence
6
+
7
+ from google.protobuf.json_format import MessageToDict, ParseDict
8
+ from google.protobuf.struct_pb2 import Struct
9
+
10
+ from embodied_ops.device import (
11
+ Capability,
12
+ DeviceManifest,
13
+ HealthReport,
14
+ HealthStatus,
15
+ )
16
+ from embodied_ops.errors import ContractError
17
+ from embodied_ops.features import (
18
+ FeatureKind,
19
+ FeatureSpec,
20
+ index_features,
21
+ validate_feature_values,
22
+ )
23
+ from embodied_ops.rpc.types import TensorValue
24
+ from embodied_ops.rpc.v1 import device_pb2
25
+
26
+
27
+ def manifest_to_proto(manifest: DeviceManifest) -> device_pb2.DeviceManifest:
28
+ return device_pb2.DeviceManifest(
29
+ api_version=manifest.api_version,
30
+ identifier=manifest.identifier,
31
+ capabilities=[capability.value for capability in manifest.capabilities],
32
+ observation_features=[
33
+ feature_to_proto(feature) for feature in manifest.observation_features
34
+ ],
35
+ action_features=[feature_to_proto(feature) for feature in manifest.action_features],
36
+ metadata=dict(manifest.metadata),
37
+ )
38
+
39
+
40
+ def manifest_from_proto(message: device_pb2.DeviceManifest) -> DeviceManifest:
41
+ return DeviceManifest(
42
+ api_version=message.api_version,
43
+ identifier=message.identifier,
44
+ capabilities=tuple(Capability(value) for value in message.capabilities),
45
+ observation_features=tuple(
46
+ feature_from_proto(feature) for feature in message.observation_features
47
+ ),
48
+ action_features=tuple(feature_from_proto(feature) for feature in message.action_features),
49
+ metadata=dict(message.metadata),
50
+ )
51
+
52
+
53
+ def feature_to_proto(feature: FeatureSpec) -> device_pb2.FeatureSpec:
54
+ message = device_pb2.FeatureSpec(
55
+ name=feature.name,
56
+ kind=feature.kind.value,
57
+ dtype=feature.dtype,
58
+ shape=feature.shape,
59
+ )
60
+ if feature.unit is not None:
61
+ message.unit = feature.unit
62
+ if feature.minimum is not None:
63
+ message.minimum = feature.minimum
64
+ if feature.maximum is not None:
65
+ message.maximum = feature.maximum
66
+ return message
67
+
68
+
69
+ def feature_from_proto(message: device_pb2.FeatureSpec) -> FeatureSpec:
70
+ return FeatureSpec(
71
+ name=message.name,
72
+ kind=FeatureKind(message.kind),
73
+ dtype=message.dtype,
74
+ shape=tuple(message.shape),
75
+ unit=message.unit if message.HasField("unit") else None,
76
+ minimum=message.minimum if message.HasField("minimum") else None,
77
+ maximum=message.maximum if message.HasField("maximum") else None,
78
+ )
79
+
80
+
81
+ def values_to_proto(
82
+ values: Mapping[str, object], features: tuple[FeatureSpec, ...]
83
+ ) -> list[device_pb2.FeatureValue]:
84
+ validated = validate_feature_values(values, features)
85
+ messages: list[device_pb2.FeatureValue] = []
86
+ for feature in features:
87
+ value = validated[feature.name]
88
+ message = device_pb2.FeatureValue(name=feature.name)
89
+ if feature.kind is FeatureKind.SCALAR:
90
+ message.scalar = float(value)
91
+ else:
92
+ if not isinstance(value, TensorValue):
93
+ raise ContractError(
94
+ f"feature {feature.name!r} requires an embodied_ops.rpc.TensorValue"
95
+ )
96
+ if value.dtype != feature.dtype or value.shape != feature.shape:
97
+ raise ContractError(
98
+ f"feature {feature.name!r} tensor metadata does not match its manifest"
99
+ )
100
+ message.tensor.CopyFrom(
101
+ device_pb2.TensorPayload(
102
+ dtype=value.dtype,
103
+ shape=value.shape,
104
+ data=value.data,
105
+ )
106
+ )
107
+ messages.append(message)
108
+ return messages
109
+
110
+
111
+ def values_from_proto(
112
+ messages: Sequence[device_pb2.FeatureValue],
113
+ features: tuple[FeatureSpec, ...],
114
+ ) -> dict[str, object]:
115
+ specs = index_features(features)
116
+ values: dict[str, object] = {}
117
+ for message in messages:
118
+ if message.name in values:
119
+ raise ContractError(f"duplicate feature value: {message.name!r}")
120
+ try:
121
+ feature = specs[message.name]
122
+ except KeyError as exc:
123
+ raise ContractError(f"unknown feature value: {message.name!r}") from exc
124
+ payload = message.WhichOneof("payload")
125
+ if feature.kind is FeatureKind.SCALAR:
126
+ if payload != "scalar":
127
+ raise ContractError(f"feature {message.name!r} requires a scalar payload")
128
+ values[message.name] = message.scalar
129
+ else:
130
+ if payload != "tensor":
131
+ raise ContractError(f"feature {message.name!r} requires a tensor payload")
132
+ values[message.name] = TensorValue(
133
+ dtype=message.tensor.dtype,
134
+ shape=tuple(message.tensor.shape),
135
+ data=bytes(message.tensor.data),
136
+ )
137
+ return validate_feature_values(values, features)
138
+
139
+
140
+ def health_to_proto(report: HealthReport) -> device_pb2.HealthResponse:
141
+ details = Struct()
142
+ try:
143
+ ParseDict(dict(report.details), details)
144
+ except (TypeError, ValueError) as exc:
145
+ raise ContractError("health details must be protobuf-Struct compatible") from exc
146
+ return device_pb2.HealthResponse(
147
+ status=report.status.value,
148
+ summary=report.summary,
149
+ details=details,
150
+ )
151
+
152
+
153
+ def health_from_proto(message: device_pb2.HealthResponse) -> HealthReport:
154
+ return HealthReport(
155
+ status=HealthStatus(message.status),
156
+ summary=message.summary,
157
+ details=MessageToDict(message.details),
158
+ )