loop-sdk 0.1.1__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/PKG-INFO +1 -1
  2. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/domain/schema.py +6 -1
  3. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/robot_step_sender.py +26 -18
  4. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/service/producer_session.py +4 -0
  5. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/unit/source/domain/test_schema.py +7 -3
  6. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/unit/source/test_robot_step_sender.py +22 -0
  7. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/.github/workflows/publish.yml +0 -0
  8. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/.gitignore +0 -0
  9. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/CONTRIBUTING.md +0 -0
  10. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/Makefile +0 -0
  11. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/README.md +0 -0
  12. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/data_model/README.md +0 -0
  13. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/data_model/build_descriptor.py +0 -0
  14. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/data_model/build_journey.py +0 -0
  15. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/data_model/build_sequence.py +0 -0
  16. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/data_model/build_visualizer.py +0 -0
  17. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/data_model/consumer_plan.html +0 -0
  18. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/data_model/descriptor.html +0 -0
  19. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/data_model/extract_real_examples.py +0 -0
  20. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/data_model/flow.html +0 -0
  21. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/data_model/flow_sequence.html +0 -0
  22. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/data_model/journey.html +0 -0
  23. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/data_model/real_examples.json +0 -0
  24. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/data_model/visualizer.html +0 -0
  25. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/dev_requirements.md +0 -0
  26. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/foundation_request_consumer_rpc.md +0 -0
  27. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/pm_questions.md +0 -0
  28. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/source_connection_spec.md +0 -0
  29. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/docs/spec_discrepancies.md +0 -0
  30. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/examples/README.md +0 -0
  31. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/examples/policy_action_consumer.py +0 -0
  32. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/examples/quickstart.py +0 -0
  33. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/examples/robot_step_producer.py +0 -0
  34. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/proto/buf.gen.yaml +0 -0
  35. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/proto/buf.yaml +0 -0
  36. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/proto/loop/foundation/source/v1/source_ingest.proto +0 -0
  37. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/pyproject.toml +0 -0
  38. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/__init__.py +0 -0
  39. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/gen/__init__.py +0 -0
  40. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/gen/__init__.pyi +0 -0
  41. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/gen/v1/__init__.py +0 -0
  42. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/gen/v1/__init__.pyi +0 -0
  43. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/gen/v1/source_ingest_pb2.py +0 -0
  44. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/gen/v1/source_ingest_pb2.pyi +0 -0
  45. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/gen/v1/source_ingest_pb2_grpc.py +0 -0
  46. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/__init__.py +0 -0
  47. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/client.py +0 -0
  48. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/consumer.py +0 -0
  49. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/domain/__init__.py +0 -0
  50. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/domain/config.py +0 -0
  51. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/domain/frame.py +0 -0
  52. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/domain/source_exception.py +0 -0
  53. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/domain/source_kind.py +0 -0
  54. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/domain/stats.py +0 -0
  55. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/outbound/__init__.py +0 -0
  56. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/outbound/frame_queue.py +0 -0
  57. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/outbound/grpc_source_client.py +0 -0
  58. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/outbound/grpc_source_reader.py +0 -0
  59. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/outbound/proto_mapping.py +0 -0
  60. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/port/__init__.py +0 -0
  61. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/port/source_client.py +0 -0
  62. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/port/source_reader.py +0 -0
  63. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/service/__init__.py +0 -0
  64. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/setup/__init__.py +0 -0
  65. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/src/loop_sdk/source/setup/config.py +0 -0
  66. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/integration/__init__.py +0 -0
  67. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/integration/source/__init__.py +0 -0
  68. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/integration/source/outbound/__init__.py +0 -0
  69. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/integration/source/outbound/test_consumer_lifecycle.py +0 -0
  70. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/integration/source/outbound/test_consumer_roundtrip.py +0 -0
  71. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/integration/source/outbound/test_grpc_roundtrip.py +0 -0
  72. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/unit/__init__.py +0 -0
  73. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/unit/source/__init__.py +0 -0
  74. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/unit/source/domain/__init__.py +0 -0
  75. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/unit/source/outbound/__init__.py +0 -0
  76. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/unit/source/outbound/test_frame_queue.py +0 -0
  77. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/unit/source/outbound/test_policy_action_decode.py +0 -0
  78. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/unit/source/outbound/test_proto_mapping.py +0 -0
  79. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/unit/source/outbound/test_robot_config_negotiation.py +0 -0
  80. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/unit/source/outbound/test_send_latency.py +0 -0
  81. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/unit/source/service/__init__.py +0 -0
  82. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tests/unit/source/service/test_producer_session.py +0 -0
  83. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/tools/replay_fidelity_check.py +0 -0
  84. {loop_sdk-0.1.1 → loop_sdk-0.1.2}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: loop-sdk
3
- Version: 0.1.1
3
+ Version: 0.1.2
4
4
  Summary: Customer-side gRPC client SDK for the Loop Source Bus.
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: grpcio>=1.81.1
@@ -99,7 +99,12 @@ class RobotStreamSchema(BaseModel):
99
99
 
100
100
  source_id: str
101
101
  name: str = ""
102
- channels: tuple[ChannelSpec, ...] = Field(min_length=1)
102
+ # A robot sample is self-describing (its ``state`` keys define the columns the
103
+ # recorder writes), so the declared channel layout is OPTIONAL metadata. Leave
104
+ # it empty to announce the source for discovery/config-negotiation and let the
105
+ # streamed samples define the channels; declare it to attach per-channel
106
+ # metadata (role/unit/...) and have the SDK validate sends against it.
107
+ channels: tuple[ChannelSpec, ...] = ()
103
108
  # Per-axis config candidates the source can open with. Empty (the default)
104
109
  # means no negotiation: the Source Bus opens it with the device default and
105
110
  # no ``on_select`` callback fires.
@@ -225,22 +225,26 @@ class RobotStepSender:
225
225
 
226
226
  def declare(
227
227
  self,
228
- channels: ChannelLayout,
228
+ channels: ChannelLayout = (),
229
229
  *,
230
230
  name: str | None = None,
231
231
  options: RobotConfigOptions | None = None,
232
232
  ) -> bool:
233
- """**Optional the first ``send()`` does this for you** from its own keys.
234
- Call ``declare`` explicitly only to fix the layout and open the source
235
- *before* the first send — i.e. so the Source Bus negotiates the config (your
236
- ``apply_config`` fires at Open) before you start streaming, rather than on the
237
- first tick.
238
-
239
- ``channels`` is the authoritative, COMPLETE set of channel keys this source
240
- will ever carry channel keys, full ``ChannelSpec``s, or a representative
241
- step dict (its flattened keys become the layout). The layout is fixed here:
242
- later ``send()`` calls may omit a key (→ "no reading") but may not introduce
243
- new keys. Idempotent once the source is open.
233
+ """Open the source so the Source Bus can discover and negotiate it (→ "ready"),
234
+ before any data flows. Idempotent once the source is open.
235
+
236
+ ``channels`` is OPTIONAL a robot sample is self-describing, so the recorder
237
+ takes its columns from the streamed samples, not from this layout:
238
+
239
+ * **Omit it (options-only)** to announce the source for discovery / config
240
+ negotiation now and let the FIRST ``send`` capture the channel layout from
241
+ its keys. Use this to reach "ready" at startup without knowing the full
242
+ channel set yet.
243
+ * **Pass it** (keys / ``ChannelSpec``s / a representative step dict) to fix
244
+ the layout up front and have the SDK validate sends against it (and attach
245
+ per-channel metadata).
246
+
247
+ Either way ``options`` + ``apply_config`` negotiation happens at Open.
244
248
  """
245
249
  if not self.connected:
246
250
  self.connect()
@@ -252,10 +256,7 @@ class RobotStepSender:
252
256
  self.name = name
253
257
 
254
258
  specs = self._resolve_specs(channels)
255
- if not specs:
256
- logger.warning("RobotStepSender: empty channel layout; not opening source %s", self.source_id)
257
- return False
258
- self._order = [spec.key for spec in specs]
259
+ self._order = [spec.key for spec in specs] if specs else None
259
260
  schema = SourceSchema(
260
261
  robot=(
261
262
  RobotStreamSchema(
@@ -274,7 +275,10 @@ class RobotStepSender:
274
275
  on_select=self._on_select,
275
276
  )
276
277
  logger.info(
277
- "RobotStepSender: source %s open on %s (%d channels)", self.source_id, self.loop_addr, len(self._order)
278
+ "RobotStepSender: source %s open on %s (%s)",
279
+ self.source_id,
280
+ self.loop_addr,
281
+ f"{len(specs)} channels" if specs else "options-only; layout from first send",
278
282
  )
279
283
  return True
280
284
 
@@ -312,7 +316,11 @@ class RobotStepSender:
312
316
  try:
313
317
  if self._producer is None and not self.declare(flat):
314
318
  return False
315
- assert self._order is not None and self._producer is not None
319
+ if self._order is None:
320
+ # Source was declared options-only — the first frame fixes the layout
321
+ # (and thus the recorder's columns), so make this first frame complete.
322
+ self._order = list(flat)
323
+ assert self._producer is not None
316
324
  self._warn_unknown_keys(flat)
317
325
  state = {key: _normalize_value(flat.get(key)) for key in self._order}
318
326
  seq = self._seq if sequence is None else sequence
@@ -67,6 +67,10 @@ class ProducerSession:
67
67
 
68
68
  def _validate_robot(self, frame: RobotFrame) -> None:
69
69
  declared = self._schema.robot_channel_keys(frame.source_id)
70
+ if not declared:
71
+ # Options-only / self-describing source: no declared layout to enforce —
72
+ # the recorder writes whatever keys each sample carries.
73
+ return
70
74
  unknown = frame.state.keys() - declared
71
75
  if unknown:
72
76
  raise SchemaMismatchError(
@@ -31,9 +31,13 @@ def test_robot_stream_channel_keys_match_declared_channels() -> None:
31
31
  assert stream.kind is SourceKind.ROBOT
32
32
 
33
33
 
34
- def test_robot_stream_requires_at_least_one_joint() -> None:
35
- with pytest.raises(ValidationError):
36
- RobotStreamSchema(source_id="arm/state", channels=())
34
+ def test_robot_stream_allows_empty_channels() -> None:
35
+ # A robot sample is self-describing, so the channel layout is optional metadata:
36
+ # an empty layout announces the source (discovery / config negotiation) and lets
37
+ # the streamed samples define the columns.
38
+ stream = RobotStreamSchema(source_id="arm/state", channels=())
39
+ assert stream.channel_keys == frozenset()
40
+ assert stream.kind is SourceKind.ROBOT
37
41
 
38
42
 
39
43
  def test_schema_rejects_empty() -> None:
@@ -105,6 +105,28 @@ def test_first_send_auto_declares_from_step() -> None:
105
105
  assert {c.key for c in schema.robot[0].channels} == {"x", "y"}
106
106
 
107
107
 
108
+ def test_options_only_declare_reaches_ready_then_first_send_fixes_layout() -> None:
109
+ # declare() with NO channels announces the source (discovery/negotiation -> ready)
110
+ # with an empty layout; the first send fixes the channels from its keys.
111
+ opts = RobotConfigOptions(control_hz=(20,), action_space=("target_cartesian_delta",))
112
+ sender, harness = _sender(options=opts)
113
+ sender.connect()
114
+ assert sender.declare() is True # options-only, no channels
115
+ robot = harness.connect_calls[0]["schema"].robot[0]
116
+ assert robot.channels == () # announced with an empty layout
117
+ assert robot.available_options.control_hz == (20,) # options still negotiated
118
+ assert sender._order is None # layout not fixed yet
119
+ assert harness.producer.sent == [] # declaring sends no sample
120
+
121
+ sender.send(1, {"a": 1.0, "b": 2.0}) # first frame fixes the layout
122
+ _sid, _ts, _seq, state = harness.producer.sent[0]
123
+ assert set(state) == {"a", "b"} and sender._order == ["a", "b"]
124
+
125
+ sender.send(2, {"a": 3.0, "c": 9.0}) # stable key set: c dropped, b -> no reading
126
+ _sid, _ts, _seq, state2 = harness.producer.sent[1]
127
+ assert set(state2) == {"a", "b"} and state2["b"] is None
128
+
129
+
108
130
  def test_nested_step_is_flattened_on_send_not_dropped() -> None:
109
131
  sender, harness = _sender()
110
132
  sender.connect()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes